Пришло время заставить эти анимации работать.

С тех пор, как вышел SwiftUI, я стал его большим поклонником. Наконец-то больше никаких раскадровок, никакой дублирующей чепухи, состоящей наполовину из кода, а наполовину в графическом интерфейсе.
Но в последнее время анимация напомнила мне, что в SwiftUI есть еще много неочевидных трюков, которым нужно научиться. Вот три вывода, которые помогут вам начать создавать мощные анимации в SwiftUI.
1. Основы: есть два механизма анимации SwiftUI.
Давайте начнем с простой задачи: на холсте 800x800 вы хотите анимировать круг, перемещающийся из одной точки в другую. Я начну с определения двух состояний для положения круга: xpos и ypos. Затем я установлю положение круга с помощью модификатора position.
Пока все просто — вот наше превью:

Далее я покажу вам оба способа анимации движения. Первый — использовать модификатор animation. Он прикрепляется после модификатора, который вы хотите анимировать — в данном случае после модификатора положения.
Как часть модификатора анимации вы должны указать значение для отслеживания изменений:
.position(x: xpos, y: ypos) .animation(.easeInOut(duration: 2.0), value: ypos) // Animation follows the position modifier
Здесь мы отслеживаем изменения в ypos — это означает, что всякий раз, когда вы меняете ypos, круг будет перемещаться в новую анимированную позицию.
Чтобы вызвать изменение в ypos, вы можете использовать, например, метод onAppear:
.onAppear {
ypos = 600 // Trigger the animation to start
}
Вот полный код и результат:

Одной проблемой при таком подходе является аргумент value: ypos. Что, если вы хотите анимировать оба изменения в xpos и ypos? Кроме того, это немного сбивает с толку — мы должны помнить, что модификатор анимации изменяет предыдущий модификатор (модификатор позиции)… ??? Это может легко привести к ошибкам — если вы поместите модификатор анимации сразу после Circle(), вы заставите его появиться/исчезнуть!
Второй способ сделать анимацию мне более интуитивно понятен — использовать withAnimation . Вместо модификатора .animation используем:
withAnimation(.easeInOut(duration: 2.0)) {
// withAnimation tells that states modified in closure are animated
ypos = 600
}
Блок withAnimation отслеживает любые изменения State в замыкании и анимирует их. Это гораздо более чистое решение для меня. Теперь мы также можем свободно менять xpos в том же блоке, что также будет анимировать положение в горизонтальном направлении.
2. Используйте animableData для более умной анимации по заданным траекториям.
Давайте усложним — давайте анимируем движение маленького синего круга вокруг большого черного круга:

Чтобы нарисовать это, я добавил к ZStack следующее:
Circle() .strokeBorder(.black, lineWidth: 2) .foregroundColor(.clear) .frame(width: CGFloat(2*radius), height: CGFloat(2*radius)) .position(x: 400, y: 400)
Теперь вместо переменной состояния xpos и ypos у нас есть angle по кругу:
@State var angle: Float = Float.pi / 2.0
а положение меньшего синего круга определяется как:
.position(x: CGFloat(400 + radius * cos(angle)), y: CGFloat(400 - radius * sin(angle)))
В анимации мы пытаемся изменить угол с начального pi/2 на -pi/2:
.onAppear {
withAnimation(.easeInOut(duration: 2.0)) {
// withAnimation tells that states modified in closure are animated
angle = -Float.pi / 2.0
}
}
Давай посмотрим что происходит:

…ну… в любом случае, теория была хороша.
Иди по кругу!
Как мы заставляем его ходить? Со свойством animatableData.
Во-первых, мы определяем пользовательскую Shape — анимируемую форму круга с именем CircleWithAnimatableAngle:
struct CircleWithAnimatableAngle: Shape {
var angle: Float
}
Функция path рисует окружность следующим образом:
func path(in rect: CGRect) -> Path {
return Path { path in
let x = CGFloat(400 + radius * cos(angle))
let y = CGFloat(400 - radius * sin(angle))
path.move(to: CGPoint(x: x, y: y))
path.addEllipse(in: CGRect(x: x - 25.0, y: y - 25.0, width: 50.0, height: 50.0))
}
}
Кроме того, мы добавляем свойство animatableData, которое просто изменяет угол:
var animatableData: Float {
get { angle }
set { angle = newValue }
}
Наконец, мы можем заменить Circle в виде тела на CircleWithAnimatableAngle:
CircleWithAnimatableAngle(angle: angle, radius: radius) .foregroundColor(.blue)
Вот полный код и результат:

Ура! Намного лучше.
Но есть очевидная проблема. Теперь мы имеем дело с фигурой — а как насчет любого другого объекта SwiftUI? Как насчет добавленияText, что мы хотим двигаться вдоль круга?
Нам нужно более общее решение.
3. Общее решение: AnimatableModifier
Это настоящий секрет анимации в SwiftUI: каждая анимация получает свой собственный AnimatableModifier. Вот AnimatableModifier для анимации круга:
Мы видим тот же animatableData, что и раньше. Но теперь вместо метода path для формы мы реализуем метод body, который возвращает некое модифицированное представление. В этом случае мы используем угол для обновления положения вида.
Чтобы применить его, мы просто используем модификатор .modifier:
.modifier(CircleAnimation(angle: angle, radius: radius))
Мы можем добавить этот модификатор к чему угодно — к любому объекту SwiftUI. Затем, когда мы изменим angle в блоке withAnimation, модификатор будет управлять тем, как изменение угла преобразуется в изменение положения (или любые другие модификаторы, которые изменяются).
Например, мы можем создать ZStack, используя как Circle, так и Text:
ZStack {
Circle()
.frame(width: 50, height: 50)
.foregroundColor(.blue)
Text("Hello!")
.offset(x: 80)
.font(.system(size: 24))
}
.modifier(CircleAnimation(angle: angle, radius: radius))
Вот результат и полный код:

Последние мысли
Мы прошли путь от простых модификаторов .animation до наследования от протокола AnimatableModifier. Я действительно предпочитаю этот последний подход, так как он не только более мощный, позволяя вам управлять анимацией, но и более удобочитаемый — какие данные действительно анимируются, четко определено в свойстве animatableData. Кроме того, его можно использовать повторно — его можно применять к любому объекту SwiftUI для создания согласованных анимаций — что, на мой взгляд, оправдывает дополнительный код.
Спасибо за прочтение!