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 等依赖变化时:
- 事件触发,打开一个 update transaction
- 依赖变化,视图被标记为无效
- transaction 关闭时,框架调用
body生成新的视图值 - attribute 的值逐层更新
- 图发出绘制命令更新渲染
这是 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:35)Animatable 协议决定”哪些属性需要动画插值”。遵循该协议的类型需要声明 animatableData 属性,类型必须遵循 VectorArithmetic。
VectorArithmetic 支持向量加法和标量乘法。CGFloat 和 Double 是一维向量,CGPoint 和 CGSize 是二维向量,CGRect 是四维向量。SwiftUI 用统一的泛型实现来动画所有这些类型。
scaleEffect 实际上定义了一个四维向量:宽度缩放、高度缩放、锚点 x、锚点 y。用 AnimatablePair 将多个向量融合成一个更大的向量。
(09:00)自定义 Animatable 的一个例子:宠物领奖台(Podium)视图用 RadialLayout 把子视图沿圆弧分布。默认情况下改变偏移角度时,宠物头像沿直线移动。通过让 Podium 遵循 Animatable 并使用偏移角度作为 animatableData,可以让头像沿圆弧移动。
但要注意:自定义 Animatable 会在每一帧调用 body,比内建效果昂贵得多。只在无法用内建效果实现时才使用。
Animation 类型
(12:16)SwiftUI 的动画分为几类:
Timing curve 动画:用曲线和持续时间定义。如 easeInOut、linear、easeIn、easeOut。UnitCurve 类型可以独立计算任意时间点的值和速度。
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:25)CustomAnimation 协议提供了与 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:56)Spring 类型可以独立使用:
let spring = Spring(duration: 1.0, bounce: 0)
spring.value(target: 1, time: 0.25)
spring.velocity(target: 1, time: 0.25)
关键点:
duration是感知持续时间,bounce控制弹跳程度- 可以计算任意时间点的位置和速度
- 内置三种预设:
smooth、snappy、bouncy
UnitCurve
(12:48)UnitCurve 定义了动画的速度曲线:
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)
关键点:
- 用贝塞尔控制点定义曲线
- 可以计算任意相对时间点的值和速度
- 内置预设:
linear、easeIn、easeOut、easeInOut
核心启发
-
交互反馈动画
- 做什么:为按钮、卡片等可交互元素添加缩放或阴影动画
- 为什么值得做:SwiftUI 的
.animation(.bouncy, value:)让实现变得极简单,且默认使用物理弹簧动画,手感自然 - 怎么开始:在
onTapGesture中切换@State,用.animation(.bouncy, value: isPressed)修饰目标视图
-
自定义布局动画
- 做什么:让自定义布局中的子视图沿特定路径动画(如圆形排列、网格重排)
- 为什么值得做:遵循
Animatable协议后,SwiftUI 会在每一帧调用body并传入插值后的数据 - 怎么开始:让自定义
Layout或View遵循Animatable,声明animatableData,在body中根据该值计算子视图位置
-
差异化动画效果
- 做什么:同一个视图的不同属性使用不同的动画曲线
- 为什么值得做:阴影用
.smooth显得稳重,缩放用.bouncy显得活泼,组合起来层次更丰富 - 怎么开始:叠加多个
.animation()修饰符,每个指定不同的value参数
-
程序化触发的动画
- 做什么:从外部(如网络请求完成、定时器)触发视图动画
- 为什么值得做:用
withAnimation包裹状态变更,不需要用户交互也能播放动画 - 怎么开始:将
@State改为@Binding,外部通过withAnimation(.spring) { selected = true }触发
-
Transaction 传递自定义上下文
- 做什么:区分”用户点击触发的动画”和”程序触发的动画”,使用不同的动画曲线
- 为什么值得做:用户交互可以更活泼,程序变更可以更克制
- 怎么开始:定义
TransactionKey,在点击时用withTransaction设置标记,在.transaction修饰符中根据标记选择动画
关联 Session
- Animate with springs — 深入理解弹簧动画的原理和使用
- Wind your way through advanced animations in SwiftUI — 多步骤动画和关键帧动画
- SwiftUI essentials — SwiftUI 基础概念
- Beyond scroll views — ScrollView 动画和过渡
评论
GitHub Issues · utterances