Разработка
Протокол Animatable: укрощение анимаций в SwiftUI
Хотя протокол Animatable изначально не был разработан для решения проблем с анимацией, он стал мощным инструментом для решения сложных проблем с ней.
В SwiftUI-разработке вы когда-нибудь сталкивались с ситуацией, когда, казалось бы, правильный код анимации не работает так, как ожидалось? Или анимации, которые отлично работают на одних версиях iOS, ведут себя ненормально на других? Эти досадные проблемы с анимацией часто можно решить с помощью мощного, но не слишком заметного инструмента — протокола Animatable
.
Протокол Animatable
Прежде чем перейти к рассмотрению способов устранения аномалий в анимациях, давайте сначала разберемся в основном механизме протокола Animatable
. Наиболее примечательной особенностью этого протокола является то, что он улучшает обработку анимации представления от простого подхода «от начала к концу» до более тонкого подхода «покадровой интерполяции».
Обычный подход, ориентированный на состояние
Начнем с базового примера анимации — эффекта горизонтального движения, управляемого состоянием:
struct OffsetView: View {
@State var x: CGFloat = 0
var body: some View {
Button("Move") {
x = x == 0 ? 200 : 0
}
Rectangle()
.foregroundStyle(.red)
.frame(width:100, height: 100)
.offset(x: x)
.animation(.smooth, value: x)
}
}
Реализация на основе Animatable
Того же эффекта можно добиться, реализовав протокол Animatable
:
xxxxxxxxxx
struct OffsetView: View {
@State var x: CGFloat = 0
var body: some View {
Button("Move") {
x = x == 0 ? 200 : 0
}
MoveView(x: x)
.animation(.smooth, value: x)
}
}
struct MoveView: View, Animatable {
var x: CGFloat
// Receive animation interpolation via animatableData
var animatableData: CGFloat {
get { x }
set { x = newValue }
}
var body: some View {
Rectangle()
.foregroundStyle(.red)
.frame(width: 100, height: 100)
.offset(x: x)
}
}
На первый взгляд, реализация на основе Animatable может показаться излишней. Действительно, в большинстве стандартных сценариев анимации мы можем положиться на механизм SwiftUI на основе состояний для создания плавных анимаций. Именно поэтому в повседневной разработке вам редко придется напрямую взаимодействовать с протоколом Animatable
.
Использование Animatable для решения аномалий анимации
Хотя система анимации в SwiftUI очень мощная, бывают случаи, когда даже, казалось бы, правильный код может привести к неожиданным аномалиям. В таких случаях на помощь часто приходит Animatable
.
Интересно, что во время написания этой статьи я обнаружил, что многие проблемы с анимацией, для устранения которых раньше требовалась Animatable
, были решены в Xcode 16. Чтобы лучше проиллюстрировать проблему, я позаимствовал типичный случай с форумов разработчиков Apple.
Демонстрация проблемы
Давайте рассмотрим пример с использованием нового модификатора animation
, представленного в iOS 17:
xxxxxxxxxx
struct AnimationBugDemo: View {
@State private var animate = false
var body: some View {
VStack {
Text("Hello, world!")
.animation(.default) {
$0
.opacity(animate ? 1 : 0.2)
.offset(y: animate ? 0 : 100) // <-- Animation anomaly
}
Button("Change") {
animate.toggle()
}
}
}
}
Этот код выглядит совершенно нормально — мы используем новую версию модификатора animation
для точного управления скоупом анимации. Однако, запустив ее, вы заметите, что в то время как изменение полупрозрачности работает нормально, анимация смещения полностью отсутствует.
Решение Animatable
При анализе выяснилось, что проблема заключается в том, что модификатор offset
некорректно обрабатывает состояние анимации внутри замыкания анимации. Давайте воспользуемся Animatable
, чтобы реализовать надежную альтернативу:
xxxxxxxxxx
// Code from kurtlee93
public extension View {
func projectionOffset(x: CGFloat = 0, y: CGFloat = 0) -> some View {
self.projectionOffset(.init(x: x, y: y))
}
func projectionOffset(_ translation: CGPoint) -> some View {
modifier(ProjectionOffsetEffect(translation: translation))
}
}
private struct ProjectionOffsetEffect: GeometryEffect {
var translation: CGPoint
var animatableData: CGPoint.AnimatableData {
get { translation.animatableData }
set { translation = .init(x: newValue.first, y: newValue.second) }
}
public func effectValue(size: CGSize) -> ProjectionTransform {
.init(CGAffineTransform(translationX: translation.x, y: translation.y))
}
}
Теперь просто замените оригинальный offset
на наш кастомный модификатор:
xxxxxxxxxx
Text("Hello, world!")
.animation(.default) {
$0
.opacity(animate ? 1 : 0.2)
.projectionOffset(y: animate ? 0 : 100)
}
Почему стоит выбрать Animatable?
Хотя эту проблему можно решить с помощью явной анимации или возврата к старой версии модификатора анимации, решение на основе Animatable имеет явные преимущества:
- Сохраняет возможности точного управления нового модификатора
animation
- Избегает потенциальных побочных эффектов от использования
withAnimation
, таких как запуск анимации в несвязанных представлениях
Другими словами, это решение не только устраняет текущую проблему, но и предоставляет нам более детальный контроль над анимацией.
Использование Animatable для создания более точных анимаций
В предыдущей статье «Руководство по SwiftUI geometryGroup(): От теории к практике» я рассказывал о том, как использовать модификатор geometryGroup
для улучшения анимационных эффектов. Этот модификатор работает аналогично Animatable
— оба преобразуют дискретные состояния в непрерывные потоки данных анимации. Сегодня мы рассмотрим, как использовать Animatable
для дальнейшего улучшения анимационных эффектов.
Отдельное спасибо @Chocoford за предоставленный пример кода. Полную реализацию можно посмотреть здесь.
Аномалия расширения View
Рассмотрим пример расширяющегося меню:
xxxxxxxxxx
ZStack {
if isExpanded {
ItemsView(namespace: namespace)
} else {
Image(systemName: "sun.min")
.matchedGeometryEffect(id: "Sun2", in: namespace, properties: .frame, isSource: false)
}
}
Когда вид меню перетаскивается в центр экрана, чтобы развернуться, эффект анимации полностью теряется из-за отсутствия информации об исходном положении. Хотя добавление geometryGroup
может заставить анимацию появиться снова:
Однако самые зоркие из вас могли заметить, что направление расширения меню неестественно — оно расширяется слева направо, а не по центру. Это говорит о том, что хотя geometryGroup
и обеспечивает анимационную интерполяцию, ее поведением трудно управлять точно.
Решение для оптимизации анимации
Давайте переработаем эту анимацию с помощью Animatable
:
xxxxxxxxxx
struct AnimatableContainerSizeModifier: Animatable, ViewModifier {
var targetSize: CGSize
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(targetSize.width, targetSize.height) }
set { targetSize = CGSize(width: newValue.first, height: newValue.second) }
}
func body(content: Content) -> some View {
content.frame(width: targetSize.width, height: targetSize.height)
}
}
// Apply the new animation controller
FloatingToolbar(isExpanded: isExpanded)
.modifier(AnimatableContainerSizeModifier(targetSize: CGSize(width: isExpanded ? 300 : 100, height: 100)))
Эффект виден сразу:
Это новое решение не только делает анимацию расширения меню более естественной, но и обеспечивает более плавное взаимодействие с пользователем.
Заключение
Хотя протокол Animatable
изначально не был разработан для решения проблем с анимацией, он стал мощным инструментом для решения сложных проблем с ней. Когда вы сталкиваетесь с тем, что:
- Казалось бы, корректный код выдает ненормальную анимацию
- Анимация ведет себя непоследовательно в разных версиях системы
- Вам необходимо более точное управление анимацией
Рассмотрите возможность использования Animatable
— это может стать ключом к разгадке правильной анимации.
-
Видео и подкасты для разработчиков3 недели назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.9
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.10
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.11