Вопрос или проблема
Как можно создать «сложные» анимации в SwiftUI? Где сложные означают, что полная анимация состоит из нескольких под-анимаций.
Пример
Я работаю над переносом существующего проекта с UIKit
на SwiftUI
. Проект использует пользовательский UIView
для отображения индикатора прогресса, который анимирует изменения между положительными и отрицательными значениями. Положительные значения отображаются зеленым индикатором, отрицательные – красным.
Предположим, что maxValue
равно 100:
- Значение +60 будет показано как 60% зеленый индикатор.
- Изменение значения на +20 анимирует зеленый индикатор вниз до 20% за 1 секунду.
- Изменение значения на -40 анимирует зеленый индикатор до 0 за 1/3 секунды, а затем анимирует красный индикатор плавно от 0 до 40% за 2/3 секунды. Эта полная анимация также занимает 1 секунду всего.
Таким образом, общая длительность анимации всегда составляет 1 секунду, независимо от того, как меняется значение. При изменении с положительного на отрицательное значение (или наоборот) индикатор сжимается до 0, прежде чем изменить свой цвет и снова стать длиннее.
Простой ProgressBar
Создание простого индикатора прогресса в SwiftUI не составило труда:
struct SomeProgressBar: View {
var height: CGFloat = 20
var minValue: Double = 0
var maxValue: Double = 100
var currentValue: Double = 20
var body: some View {
let maxV = max(minValue, maxValue)
let minV = min(minValue, maxValue)
let currentV = max(min(abs(currentValue - minValue), maxV), minV)
let isNegative = currentValue < minValue
let progress = (currentV - minV) / (maxV - minV)
Rectangle()
.fill(.gray)
.frame(height: height)
.overlay(alignment: .leading) {
GeometryReader { geometry in
Rectangle()
.fill(isNegative ? .red : .green)
.frame(width: geometry.size.width * progress)
}
}
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
struct SomeProgressBarTestView: View {
@State var value: Double = 40
var body: some View {
VStack {
SomeProgressBar(currentValue: value)
Button("Изменить") {
value = .random(in: -100...100)
}
}
}
}
Проблема
Хотя это работает нормально, когда используются только положительные или только отрицательные значения, переключение между положительными и отрицательными значениями не анимируется так, как задумано. Например, переключение между +x и -x изменит только цвет индикатора, но не анимирует сам индикатор. Это неудивительно, поскольку ширина не меняется, она составляет x% до и после изменения значения. Однако желаемый результат заключался бы в анимации ширины от x до 0 и обратно до x.
Все источники, которые я нашел до сих пор, только различают между неявными и явными анимациями. Хотя это изменяет способ реализации / описания анимации, похоже, что это не влияет на то, что анимация может делать.
Какой будет «правильный» подход к созданию желаемой «сложной» анимации?
Две разные анимации?
Конечно, можно рассчитать, произошел ли переход от положительного к отрицательному, и в этом случае инициировать две разные анимации с помощью .withAnimation { ... }
. Рассчитать длительность первой анимации и второй анимации по разнице значений не составит проблемы.
Однако, это будет работать только для линейных анимаций. При использовании любой функции замедления, такой как пружина и т.д., добиться непрерывной анимации с таким подходом не получится.
Кроме того, индикатор прогресса – это только один пример. Что если нужно объединить еще больше анимаций для получения желаемого результата.
Существует ли решение для создания пользовательских анимаций, подобных этим?
Вы можете соответствовать Animatable
. Извлеките часть, относящуюся к анимации, в ViewModifier
, который соответствует Animatable
.
var body: some View {
let maxV = max(minValue, maxValue)
let minV = min(minValue, maxValue)
let currentV = max(min(abs(currentValue - minValue), maxV), minV)
let progress = (currentV - minV) / (maxV - minV)
Rectangle()
.fill(.gray)
.frame(height: height)
.overlay {
Rectangle()
.modifier(ProgressBarModifier(
progress: currentValue < minV ? -progress : progress
))
}
.animation(.linear(duration: 1), value: progress)
}
struct ProgressBarModifier: ViewModifier, Animatable {
var progress: Double // между -1 и 1
nonisolated var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.foregroundStyle(progress < 0 ? .red : .green)
// это яснее, чем GeometryReader, но GeometryReader также должен работать
.scaleEffect(x: abs(progress), anchor: .leading)
}
}
На каждом кадре анимации SwiftUI будет устанавливать animatableData
на некоторое интерполированное значение между начальной и конечной точками анимации. Затем будет вызван body
и обновлен интерфейс пользователя с новым кадром.
Вы также можете настроить весь SomeProgressBar
под Animatable
, но в этом случае модификатор animation
необходимо переместить в родительский вид.
Ответ или решение
Как создать сложные анимации в SwiftUI
Создание сложных анимаций в SwiftUI может оказаться более изящным и мощным, чем в UIKit. SwiftUI позволяет комбинировать различные анимации для создания более динамичного пользовательского интерфейса. В данной статье мы обсудим, как можно реализовать сложные анимации на примере прогресс-бара, который показывает изменения значений как в положительном, так и в отрицательном диапазоне.
Определение задачи
Наша цель – создать анимацию прогресс-бара, которая учитывает следующие аспекты:
- Положительные значения отображаются зелёным цветом.
- Отрицательные значения отображаются красным цветом.
- Переход между положительными и отрицательными значениями осуществляется через анимацию, которая плавно изменяет ширину прогресс-бара.
- Мы хотим, чтобы смена состояния (положительное/отрицательное) происходила за фиксированное время в 1 секунду, включая сжатие к нулю и плавную смену цвета.
Основная структура прогресс-бара
Простая реализация прогресс-бара может выглядеть так:
struct SimpleProgressBar: View {
var height: CGFloat = 20
var minValue: Double = 0
var maxValue: Double = 100
var currentValue: Double = 20
var body: some View {
let progress = (currentValue - minValue) / (maxValue - minValue)
Rectangle()
.fill(Color.gray)
.frame(height: height)
.overlay(alignment: .leading) {
Rectangle()
.fill(currentValue < minValue ? Color.red : Color.green)
.frame(width: (max(0, min(1, progress)) * 100))
}
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
Реализация сложной анимации
Теперь давайте модифицируем реализацию, добавив сложную анимацию, включая переключение между положительным и отрицательным значениями. Чтобы это сделать, мы воспользуемся протоколом Animatable
и создадим ViewModifier
.
Создание ProgressBarModifier
struct ProgressBarModifier: ViewModifier, Animatable {
var progress: Double // between -1 and 1
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.foregroundStyle(progress < 0 ? Color.red : Color.green)
.scaleEffect(x: abs(progress), anchor: .leading)
}
}
Использование ProgressBarModifier
в SomeProgressBar
struct SomeProgressBar: View {
var height: CGFloat = 20
var minValue: Double = 0
var maxValue: Double = 100
@State var currentValue: Double = 20
var body: some View {
let progress = (currentValue - minValue) / (maxValue - minValue)
Rectangle()
.fill(Color.gray)
.frame(height: height)
.overlay {
Rectangle()
.modifier(ProgressBarModifier(progress: currentValue < minValue ? -progress : progress))
}
.animation(.linear(duration: 1), value: progress)
}
}
Как работает анимация
В этом примере, когда currentValue
изменяется, SwiftUI автоматически рассчитывает промежуточные состояния для animatableData
, передавая новые значения в body
. Это позволяет ProgressBarModifier
обновлять UI в каждом кадре анимации. Таким образом, при переходе между положительными и отрицательными значениями, бар сначала сжимается до нуля, а затем раздвигается до нового значения с соответствующим цветом.
Пример тестового представления
Вот как может выглядеть тестовое представление, которое вызывает изменения значений:
struct SomeProgressBarTestView: View {
@State private var value: Double = 40
var body: some View {
VStack {
SomeProgressBar(currentValue: value)
Button("Изменить") {
let randomValue = Double.random(in: -100...100)
withAnimation {
value = randomValue
}
}
}
}
}
Заключение
Используя Animatable
и соответствующие ViewModifier
, вы можете создавать сложные анимации в SwiftUI, позволяя вашему приложению выглядеть более интерактивным и отзывчивым. Эта методология не только упрощает управление состояниями анимаций, но также повышает читаемость и поддержку вашего кода. Анализируя переключения значений, вы всегда можете обеспечить плавные переходы и эффектные визуализации для улучшения пользовательского взаимодействия.