WWDC Quick Look 💓 By SwiftGGTeam
Explore SwiftUI animation

Explore SwiftUI animation

观看原视频

Highlight

SwiftUI 的动画系统由四个核心机制组成:视图刷新(attribute graph 更新)、Animatable 协议(决定哪些属性参与插值)、Animation 类型(定义插值曲线)和 Transaction(传递动画上下文),开发者可以通过 withAnimation.animation() 修饰符和自定义 TransactionKey 精确控制动画行为。

核心内容

为什么动画在 SwiftUI 中很重要

Kyle 从 SwiftUI 团队出发,用一个宠物投票 App 作为贯穿全场的例子。这个 App 里每个宠物头像可以点击投票,票数变化时头像会重新排列。点击头像时头像放大——这个简单的交互没有动画时很生硬,加上动画后立刻变得生动。

SwiftUI 的设计初衷就是让动画变得简单。动画可以给 UI 带来清晰度和生命力,SwiftUI 的声明式架构天然适合动画。

视图刷新的解剖

02:08)SwiftUI 维护一个长期存在的依赖图(attribute graph),每个节点叫 attribute,对应 UI 的一个细粒度部分。当 @State 等依赖变化时:

  1. 事件触发,打开一个 update transaction
  2. 依赖变化,视图被标记为无效
  3. transaction 关闭时,框架调用 body 生成新的视图值
  4. attribute 的值逐层更新
  5. 图发出绘制命令更新渲染

这是 SwiftUI 视图更新的完整流程。理解这个流程对理解动画至关重要。

添加动画

04:13)用 withAnimation 包裹状态变化:

struct Avatar: View {
    var pet: Pet
    @State private var selected: Bool = false

    var body: some View {
        Image(pet.type)
            .scaleEffect(selected ? 1.5 : 1.0)
            .onTapGesture {
                withAnimation {
                    selected.toggle()
                }
            }
    }
}

关键点:

  • withAnimation 在 transaction 中设置动画
  • selected 变化后,下游 attribute 被标记为无效
  • body 被调用,生成新的 attribute 值
  • scaleEffect 是一个 animatable attribute,检测到值变化且 transaction 中有动画,就复制动画并插值
  • SwiftUI 在后台线程完成插值计算,不调用任何视图代码

Animatable 协议

06:35Animatable 协议决定”哪些属性需要动画插值”。遵循该协议的类型需要声明 animatableData 属性,类型必须遵循 VectorArithmetic

VectorArithmetic 支持向量加法和标量乘法。CGFloatDouble 是一维向量,CGPointCGSize 是二维向量,CGRect 是四维向量。SwiftUI 用统一的泛型实现来动画所有这些类型。

scaleEffect 实际上定义了一个四维向量:宽度缩放、高度缩放、锚点 x、锚点 y。用 AnimatablePair 将多个向量融合成一个更大的向量。

09:00)自定义 Animatable 的一个例子:宠物领奖台(Podium)视图用 RadialLayout 把子视图沿圆弧分布。默认情况下改变偏移角度时,宠物头像沿直线移动。通过让 Podium 遵循 Animatable 并使用偏移角度作为 animatableData,可以让头像沿圆弧移动。

但要注意:自定义 Animatable 会在每一帧调用 body,比内建效果昂贵得多。只在无法用内建效果实现时才使用。

Animation 类型

12:16)SwiftUI 的动画分为几类:

Timing curve 动画:用曲线和持续时间定义。如 easeInOutlineareaseIneaseOutUnitCurve 类型可以独立计算任意时间点的值和速度。

Spring 动画:通过弹簧模拟计算值。iOS 17 起,withAnimation 的默认动画从 easeInOut 变成了 smooth spring。内置三种预设:smooth(无弹跳)、snappy(少量弹跳)、bouncy(更多弹跳)。

Higher order 动画:修改基础动画,如减速、加速、延迟、重复。

Custom 动画:通过 CustomAnimation 协议实现自定义动画算法。

Transaction

19:57)Transaction 是 SwiftUI 隐式传递更新上下文的字典,类似于 Environment 和 Preferences。每次视图更新都有一个 Transaction,最常用的是携带动画配置。

withAnimation 只是 withTransaction 的薄封装。Transaction 在更新结束后被丢弃,所以每个值都会恢复到默认值。

22:44.animation() 修饰符可以精确控制动画作用范围:

Image(pet.type)
    .scaleEffect(selected ? 1.5 : 1.0)
    .animation(.bouncy, value: selected)

关键点:

  • 只在 value 变化时才写入动画到 transaction
  • 避免无差别的动画覆盖导致的意外动画

多个动画修饰符可以叠加:

Image(pet.type)
    .shadow(radius: selected ? 12 : 8)
    .animation(.smooth, value: selected)
    .scaleEffect(selected ? 1.5 : 1.0)
    .animation(.bouncy, value: selected)

25:20)对于包含任意子内容的通用组件,使用带 body 闭包的 .animation() 修饰符,将动画 narrowly scope 到指定的 animatable attribute:

struct Avatar<Content: View>: View {
    var content: Content
    @Binding var selected: Bool

    var body: some View {
        content
            .animation(.smooth) {
                $0.shadow(radius: selected ? 12 : 8)
            }
            .animation(.bouncy) {
                $0.scaleEffect(selected ? 1.5 : 1.0)
            }
            .onTapGesture {
                selected.toggle()
            }
    }
}

28:45)自定义 TransactionKey 可以在 transaction 中传递自定义数据:

private struct AvatarTappedKey: TransactionKey {
    static let defaultValue = false
}

extension Transaction {
    var avatarTapped: Bool {
        get { self[AvatarTappedKey.self] }
        set { self[AvatarTappedKey.self] = newValue }
    }
}

Image(pet.type)
    .scaleEffect(selected ? 1.5 : 1.0)
    .transaction(value: selected) {
        $0.animation = $0.avatarTapped ? .bouncy : .smooth
    }
    .onTapGesture {
        withTransaction(\.avatarTapped, true) {
            selected.toggle()
        }
    }

关键点:

  • 创建唯一类型遵循 TransactionKey,提供 defaultValue
  • Transaction 扩展中声明计算属性
  • withTransaction 设置值,withAnimation 是其薄封装
  • 新版本的 .transaction() 修饰符也支持 value 参数和 body 闭包来精确控制作用范围

详细内容

自定义动画

17:25CustomAnimation 协议提供了与 SwiftUI 内建动画相同的底层入口:

struct MyLinearAnimation: CustomAnimation {
    var duration: TimeInterval

    func animate<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V? {
        if time <= duration {
            value.scaled(by: time / duration)
        } else {
            nil // animation has finished
        }
    }
}

关键点:

  • animate 接收目标向量、已过去的时间和上下文
  • 返回当前动画值,或 nil 表示动画结束
  • 实际动画的是 delta 向量(从旧值到新值的变化量),从 0 到目标值
  • 因为泛型实现,支持任意维度的 animatable data

19:50)可选的 velocity 方法用于在动画合并时保留速度:

func velocity<V: VectorArithmetic>(
    value: V, time: TimeInterval, context: AnimationContext<V>
) -> V? {
    value.scaled(by: 1.0 / duration)
}

shouldMerge 决定新动画是否合并到正在运行的动画中。Timing curve 返回 false(叠加运行),spring 返回 true(保留速度并 retarget)。

Spring 模型

13:56Spring 类型可以独立使用:

let spring = Spring(duration: 1.0, bounce: 0)
spring.value(target: 1, time: 0.25)
spring.velocity(target: 1, time: 0.25)

关键点:

  • duration 是感知持续时间,bounce 控制弹跳程度
  • 可以计算任意时间点的位置和速度
  • 内置三种预设:smoothsnappybouncy

UnitCurve

12:48UnitCurve 定义了动画的速度曲线:

let curve = UnitCurve(
    startControlPoint: UnitPoint(x: 0.25, y: 0.1),
    endControlPoint: UnitPoint(x: 0.25, y: 1))
curve.value(at: 0.25)
curve.velocity(at: 0.25)

关键点:

  • 用贝塞尔控制点定义曲线
  • 可以计算任意相对时间点的值和速度
  • 内置预设:lineareaseIneaseOuteaseInOut

核心启发

  1. 交互反馈动画

    • 做什么:为按钮、卡片等可交互元素添加缩放或阴影动画
    • 为什么值得做:SwiftUI 的 .animation(.bouncy, value:) 让实现变得极简单,且默认使用物理弹簧动画,手感自然
    • 怎么开始:在 onTapGesture 中切换 @State,用 .animation(.bouncy, value: isPressed) 修饰目标视图
  2. 自定义布局动画

    • 做什么:让自定义布局中的子视图沿特定路径动画(如圆形排列、网格重排)
    • 为什么值得做:遵循 Animatable 协议后,SwiftUI 会在每一帧调用 body 并传入插值后的数据
    • 怎么开始:让自定义 LayoutView 遵循 Animatable,声明 animatableData,在 body 中根据该值计算子视图位置
  3. 差异化动画效果

    • 做什么:同一个视图的不同属性使用不同的动画曲线
    • 为什么值得做:阴影用 .smooth 显得稳重,缩放用 .bouncy 显得活泼,组合起来层次更丰富
    • 怎么开始:叠加多个 .animation() 修饰符,每个指定不同的 value 参数
  4. 程序化触发的动画

    • 做什么:从外部(如网络请求完成、定时器)触发视图动画
    • 为什么值得做:用 withAnimation 包裹状态变更,不需要用户交互也能播放动画
    • 怎么开始:将 @State 改为 @Binding,外部通过 withAnimation(.spring) { selected = true } 触发
  5. Transaction 传递自定义上下文

    • 做什么:区分”用户点击触发的动画”和”程序触发的动画”,使用不同的动画曲线
    • 为什么值得做:用户交互可以更活泼,程序变更可以更克制
    • 怎么开始:定义 TransactionKey,在点击时用 withTransaction 设置标记,在 .transaction 修饰符中根据标记选择动画

关联 Session

评论

GitHub Issues · utterances