В 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
:
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:
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
, чтобы реализовать надежную альтернативу:
// 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
на наш кастомный модификатор:
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
Рассмотрим пример расширяющегося меню:
ZStack { if isExpanded { ItemsView(namespace: namespace) } else { Image(systemName: "sun.min") .matchedGeometryEffect(id: "Sun2", in: namespace, properties: .frame, isSource: false) } }
Когда вид меню перетаскивается в центр экрана, чтобы развернуться, эффект анимации полностью теряется из-за отсутствия информации об исходном положении. Хотя добавление geometryGroup
может заставить анимацию появиться снова:
Однако самые зоркие из вас могли заметить, что направление расширения меню неестественно — оно расширяется слева направо, а не по центру. Это говорит о том, что хотя geometryGroup
и обеспечивает анимационную интерполяцию, ее поведением трудно управлять точно.
Решение для оптимизации анимации
Давайте переработаем эту анимацию с помощью Animatable
:
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
— это может стать ключом к разгадке правильной анимации.