SwiftUI 阶段动画与关键帧:如何选择正确抽象
SwiftUI 从 iOS 17 起提供阶段动画与关键帧动画。二者都能表达比单次状态变化更丰富的运动,但并不是每个交互都需要它们。选择抽象的顺序应该是:普通状态动画能否说明变化?若需要按离散阶段演进,使用 PhaseAnimator;若多个数值必须沿独立时间轨道精确配合,再使用 KeyframeAnimator。
普通状态动画仍是默认答案
按钮选中、面板展开、数值更新通常只有两个有意义状态。将业务状态绑定到外观,并通过 .animation(_:value:) 或 withAnimation 触发,就能保持模型简单。不要为了获得“更高级”的 API 把布尔状态复制成多个动画阶段,否则取消、反向和快速点击时更难判断当前事实。
动画值应是状态的呈现,不是业务状态本身。网络请求是否完成、表单是否有效,不能由动画播放到第几帧决定。禁用动画或开启 Reduce Motion 后,功能仍必须正确。
用 PhaseAnimator 表达离散节拍
阶段动画适合“强调—停留—恢复”这类少量可命名步骤。每个 phase 对应一组外观,SwiftUI 在它们之间插值:
enum SavePhase: CaseIterable {
case idle, lift, settle
}
struct SaveBadge: View {
let trigger: Int
var body: some View {
Image(systemName: "checkmark.circle.fill")
.phaseAnimator(SavePhase.allCases, trigger: trigger) { view, phase in
view
.scaleEffect(phase == .lift ? 1.18 : 1)
.opacity(phase == .idle ? 0.8 : 1)
} animation: { phase in
phase == .lift ? .spring(duration: 0.22) : .easeOut(duration: 0.16)
}
}
}
阶段名称要表达意图,而不是 step1、step2。这样设计调整时能讨论“settle 是否必要”,测试也能验证触发次数和最终状态。若阶段之间没有语义,只是为了逐帧模仿视频,更适合关键帧或专用媒体资源。
用 KeyframeAnimator 协调多条轨道
关键帧适合位置、缩放、旋转和透明度具有不同节奏的短动画。例如卡片加入收藏时,图标先放大、轻微旋转,再回到静止值。为动画定义一个小型值结构,让每条 track 只修改自己的属性;结尾显式回到稳定值,避免下一次触发从残留状态开始。
关键帧越多,维护成本越高。持续时间、曲线和轨道关系应集中定义,不能散落在视图修饰器里。用户连续触发时要决定重启、忽略还是合并;三种策略都可以,但不能依赖碰巧发生的动画中断行为。
Reduce Motion 是另一种设计
读取 accessibilityReduceMotion,为非必要的移动、缩放和旋转提供淡入或即时更新。替代方案仍要表达成功、错误和层级变化。仅把 duration 设为零可能导致阶段迅速闪烁,既没有信息价值,也可能造成不适。自动循环的装饰动画应能够停止,页面离开或进入后台时也不应继续消耗资源。
运动不能独自承载信息。保存成功同时更新文字、符号或可访问性公告;拖放目标除了弹跳,还要有形状与标签变化。色彩也不能成为唯一状态信号。
性能和测试关注边界
优先动画变换和透明度,谨慎对复杂布局、阴影、模糊和大面积材质持续插值。在真实设备上用 Instruments 检查掉帧和能耗,不用肉眼观察模拟器代替测量。列表里的动画要保持稳定 identity,避免一次局部状态变化让所有行重新计算。
单元测试应验证触发条件、状态归约和 Reduce Motion 分支,而不是等待真实动画秒数。UI 测试只需确认关键反馈出现、重复点击策略正确以及最终可交互状态恢复。少量视觉检查用于曲线品质,业务正确性不应依赖像素时序。
结论
普通动画负责单一状态过渡,阶段动画负责少量有名称的节拍,关键帧负责多属性的精确时间编排。先选择最小抽象,再设计中断、重复触发、Reduce Motion 和最终状态。好的动画解释界面发生了什么;如果关闭运动后产品逻辑也消失,说明动画承担了不该承担的责任。