Site icon AppTractor

Протокол Animatable: укрощение анимаций в SwiftUI

В 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 имеет явные преимущества:

Другими словами, это решение не только устраняет текущую проблему, но и предоставляет нам более детальный контроль над анимацией.

Использование 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 — это может стать ключом к разгадке правильной анимации.

Exit mobile version