Animatable 协议:让 SwiftUI 动画不再“失控”

发表于

在 SwiftUI 开发中,你是否遇到过看似正确的动画代码却无法按预期工作的情况?或者在某些 iOS 版本上完美运行的动画,却在其他版本上表现异常?这些令人困扰的动画问题往往可以通过一个强大而低调的工具来解决 —— Animatable 协议。

Animatable 协议

在探讨如何解决动画异常之前,让我们先了解 Animatable 协议的核心机制。这个协议最大的特点,是将视图的动画处理方式从简单的“起点-终点”状态驱动,提升到了更细腻的“逐帧插值”驱动。

正常的状态驱动方式

让我们先看一个最基础的动画示例 —— 通过状态驱动的水平移动效果:

Swift
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 协议来达成:

Swift
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
  // 通过 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 解决动画异常

SwiftUI 的动画系统虽然强大,但有时即便代码看似完全正确,也可能出现意外的动画异常。这种情况下,Animatable 往往能成为我们的救星。

有趣的是,在写作本文时我发现,许多在早期版本需要通过 Animatable 来修复的动画问题,已经在 Xcode 16 中得到了解决。为了更好地说明问题,我借用了 苹果开发者论坛 中的一个典型案例。

问题展示

让我们看一个使用 iOS 17 新引入的 animation 修饰器的例子:

Swift
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) // <-- 动画异常
        }
      Button("Change") {
        animate.toggle()
      }
    }
  }
}

这段代码看起来再正常不过 —— 我们使用了新版本的 animation 修饰器来精确控制动画范围。然而运行后你会发现,虽然透明度变化正常,但 offset 的动画效果完全消失了。

Animatable 解决方案

分析发现,问题出在 offset 修饰器在动画闭包中未能正确处理动画状态。让我们用 Animatable 来实现一个可靠的替代方案:

Swift
// 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 替换为我们的自定义修饰器:

Swift
Text("Hello, world!")
    .animation(.default) {
        $0
           .opacity(animate ? 1 : 0.2)
           .projectionOffset(y: animate ? 0 : 100)
     }

为什么选择 Animatable?

虽然这个问题也可以通过使用显式动画或回退到旧版本的 animation 修饰器来解决,但基于 Animatable 的方案有着明显的优势:

  • 保持新版本 animation 修饰器的精确控制能力
  • 避免使用 withAnimation 可能带来的副作用,如触发无关视图的动画

换句话说,这个方案不仅解决了当前的问题,还为我们提供了更细致的动画控制能力。

用 Animatable 打造更精准的动画

在之前的文章 SwiftUI geometryGroup() 指南:从原理到实践 中,我介绍了如何使用 geometryGroup 修饰器来改善动画效果。这个修饰器的工作原理与 Animatable 类似 —— 都是将离散的状态转换为连续的动画数据流。今天,让我们探索如何运用 Animatable 来进一步提升动画体验。

特别感谢 @Chocoford 提供的示例代码。完整实现可在 这里查看

视图展开异常

考虑下面这个展开菜单的例子:

Swift
ZStack {
  if isExpanded {
    ItemsView(namespace: namespace)
  } else {
    Image(systemName: "sun.min")
      .matchedGeometryEffect(id: "Sun2", in: namespace, properties: .frame, isSource: false)
  }
}

当菜单视图拖动到屏幕中心展开时,由于缺失原始位置信息,动画效果完全丢失。虽然添加 geometryGroup 可以让动画重新显现:

但细心的你可能已经发现:菜单的展开方向不太自然 —— 它是从左向右展开的,而不是预期中的从中心向两侧扩展。这说明虽然 geometryGroup 实现了动画插值,但其行为却难以精确控制。

Animatable 优化方案

让我们用 Animatable 来重新设计这个动画:

Swift
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)
  }
}

// 应用新的动画控制器
FloatingToolbar(isExpanded: isExpanded)
    .modifier(AnimatableContainerSizeModifier(targetSize: CGSize(width: isExpanded ? 300 : 100, height: 100)))

效果立竿见影:

这个新方案不仅让菜单展开动画更加自然,还提供了更流畅的用户体验。

结语

虽然 Animatable 协议最初的设计目的并非修复动画问题,但它却成为了处理棘手动画问题的有力工具。当你在开发中遇到:

  • 看似正确的代码产生异常动画
  • 动画在不同系统版本表现不一致
  • 需要更精确的动画控制

不妨考虑使用 Animatable —— 它可能正是打开正确动画之门的钥匙。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!