SwiftUI 动画的常见失控方式,是先堆 modifier,再问为什么某段会动、为什么另一段继承了错误的曲线。更稳的理解顺序恰好相反:用户事件改变状态,状态更新携带一个 Transaction,视图从旧值重新求值到新值,可动画属性再由 Animation 随时间插值。Phase 和 Keyframe 不是“更炫的 modifier”,而是两种更明确的时间建模工具。
本文面向已会使用 withAnimation,但需要协调多段或多属性动画的开发者。我们会回答三个问题:动画上下文从哪里来;什么时候选择 Phase;什么时候 Keyframe 的控制力才值得每帧成本。
版本边界
PhaseAnimator、KeyframeAnimator 以及本文使用的新式 spring 参数从 iOS 17、macOS 14 等对齐版本起稳定可用。Transaction 则是更早就存在的 SwiftUI 更新上下文。WWDC23:Explore SwiftUI animation 从更新、Animatable、Animation 一直讲到 Transaction;高级动画 Session 则给出 Phase 与 Keyframe 的选择原则。
四段精确代码已映射到 examples/swiftui/Sources/WebSunSwiftUIExamples/AnimationExamples.swift,通过 macOS Swift Package 与通用 iOS Simulator 编译;当前没有动画运行时、快照或帧率证据。Xcode 27 当前仍为 Beta;本文不使用 Beta 专属动画 API,也不把测试版 Transaction 新属性视为稳定前提。
Transaction 是一次状态更新的上下文
Apple 将 Transaction 定义为当前状态处理更新的上下文。它可以携带 animation、禁用动画标志和其他事务值。withAnimation 本质上为闭包内的状态变化建立带动画的根事务;下游视图可以读取或修改它。
struct ExpandButton: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
var body: some View {
Button(isExpanded ? "收起" : "展开") {
var transaction = Transaction(
animation: reduceMotion ? nil : .snappy(duration: 0.32)
)
transaction.disablesAnimations = reduceMotion
withTransaction(transaction) {
isExpanded.toggle()
}
}
}
}
状态是事实,Transaction 是“这次事实变化如何呈现”的上下文。不要把 isAnimating 当作每个动画的主状态,再用延迟手动复位;它会在用户连续点击、任务取消或视图消失时漂移。真正需要业务锁定时,应由领域状态表达“提交中”;纯视觉过渡应尽量由状态差异和事务驱动。
.animation(_:value:) 把动画绑定到指定 value 的变化,适合局部、稳定的隐式规则;withAnimation 或 withTransaction 更适合某个用户意图产生的一组更新。无 value 的旧式 .animation(_:) 容易让同一子树中其他变化意外继承,现代代码应避免模糊传播范围。
图 1:先确定事件语义,再选择最小时间模型,并经过 Reduce Motion 闸门;本站原创 1400×800 程序化 SVG。
第一层选择:一次状态过渡就够了吗
展开、选中、插入、颜色切换,大多只有旧状态与新状态。它们优先使用 withAnimation、transition 或 .animation(_:value:)。Spring 能在交互打断时保持速度连续性;Apple 的动画 Session 也强调,内置 effect 通常比自定义 Animatable 每帧重新执行 body 更经济。
先问“一个目标状态能否完整描述结果”。若答案是能,就不需要 Phase 或 Keyframe。工具越复杂,调试时间、无障碍分支和中断语义也越复杂。
PhaseAnimator:离散步骤构成的序列
Phase 适合“先抬起,再强调,再落回”这类串行阶段。每个阶段是一份离散配置,阶段之间仍使用普通 SwiftUI Animation。Apple 的 PhaseAnimator 文档 说明,trigger 变化时按给定序列推进;序列必须非空。
private enum SavePhase: CaseIterable {
case idle
case lift
case confirm
var scale: Double {
switch self {
case .idle: 1
case .lift: 1.08
case .confirm: 1
}
}
var offset: Double {
self == .lift ? -8 : 0
}
}
struct SavedBadge: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let saveCount: Int
var body: some View {
Label("已保存", systemImage: "checkmark.circle.fill")
.phaseAnimator(
reduceMotion ? [.idle] : SavePhase.allCases,
trigger: saveCount
) { content, phase in
content
.scaleEffect(phase.scale)
.offset(y: phase.offset)
} animation: { phase in
switch phase {
case .idle: .smooth(duration: 0.18)
case .lift: .snappy(duration: 0.22)
case .confirm: .spring(duration: 0.3, bounce: 0.2)
}
}
}
}
Phase enum 应表达视觉阶段,不要混入网络请求生命周期。保存失败、保存完成是业务状态;“lift”“confirm”是展示步骤。业务结果触发动画,但动画播放到哪一步不应决定数据是否真的保存。
连续型 phase animator 会自动循环,适合克制的状态提示;trigger 版本只在值变化时播放,更适合确认反馈。若 Reduce Motion 开启,示例把序列缩为一个静态阶段,而不是把三段动画都设成零秒。后者仍可能制造闪烁和多次重绘,也没有重新思考信息是否通过颜色、图标和文字被表达。
KeyframeAnimator:属性拥有独立时间轨
当 scale、rotation、offset 需要不同起止时间,Phase 会迫使所有属性一起进入下一状态。Keyframe 将每个属性拆成 track,在指定时点产生插值值。Apple 的 KeyframeAnimator 文档 明确提醒:动画期间 content closure 每帧更新,不应在其中执行昂贵操作。
private struct ReactionValues {
var scale = 1.0
var rotation = Angle.zero
var y = 0.0
}
struct ReactionIcon: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let trigger: Int
var body: some View {
Image(systemName: "hand.thumbsup.fill")
.keyframeAnimator(
initialValue: ReactionValues(),
trigger: reduceMotion ? 0 : trigger
) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.y)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.18, duration: 0.2, spring: .snappy)
SpringKeyframe(1.0, duration: 0.28, spring: .smooth)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(-8), duration: 0.12)
CubicKeyframe(.degrees(6), duration: 0.14)
CubicKeyframe(.zero, duration: 0.22)
}
KeyframeTrack(\.y) {
SpringKeyframe(-14, duration: 0.24, spring: .snappy)
SpringKeyframe(0, duration: 0.24, spring: .smooth)
}
}
}
}
这里三个 track 独立推进。LinearKeyframe 适合匀速区间,CubicKeyframe 适合明确曲线,SpringKeyframe 保留弹簧插值,MoveKeyframe 用于不插值的跳变。Controlling the timing and movements of your animations 还指出,同一 track 的连续关键帧会保留速度,以获得连续运动。
content closure 里只应用 cheap modifier。不要做图片解码、数组过滤、日期格式化或模型查询;这些值应在动画外预先准备。Keyframe 也不适合无限扩张的业务流程:它更像一段可触发的视觉片段,而不是可恢复状态机。应用退到后台、视图离屏或用户再次触发时,产品必须能接受动画被中断或重启。
三种模型的决策表
| 问题 | 普通 Animation | PhaseAnimator | KeyframeAnimator |
|---|---|---|---|
| 目标 | 旧状态到新状态 | 多个离散阶段 | 多属性独立时间轨 |
| 时间控制 | 一条曲线或 spring | 每个阶段一条 Animation | 每个 track 多个关键帧 |
| 中间值 | 框架插值 | 阶段间插值 | 每帧提供聚合值 |
| 主要成本 | 通常最低 | 阶段重算内容 | content 每帧执行 |
| 最佳场景 | 展开、选择、转场 | 提示、确认、串行动作 | 编排式强调、多轨反馈 |
选择原则是满足需求的最小模型。先用普通动画,确认“离散阶段”是问题核心再用 Phase,只有属性必须独立排程时才上 Keyframe。
Reduce Motion 是另一套呈现策略
accessibilityReduceMotion 不等于“所有 duration 乘 0.2”。大幅位移、旋转、缩放可以改成 opacity、颜色或即时状态;必要反馈不能被一起删除。例如保存成功仍应显示 checkmark 和文字,只是不必弹跳。持续循环动画应停止,装饰性视差应关闭,用户触发的短过渡可以依产品语义减弱。
把无障碍判断放在触发层比在每个 keyframe 里散布条件更清楚。对复杂组件可以先构造 MotionPolicy,集中决定是否允许位移、是否循环和推荐 duration。这样 Preview 和测试可显式注入策略。
事务传播与局部隔离
父级一次 withAnimation 可能影响整棵发生变化的子树。某些内容不应动画,例如实时数字或可读性敏感的图表,可以在局部修改 transaction:
LiveCounter(value: count)
.transaction { transaction in
transaction.animation = nil
}
这比在父级拆成多个异步更新更可靠,因为业务状态仍在同一次操作提交,只是展示策略在子树边界被覆盖。反过来,不要到处把 transaction 清空来修补意外动画;先查明是谁给更新附加了过宽的隐式 animation。
打断与完成回调必须服务语义
交互动画会被新手势或新状态打断。Spring 能继承速度并平滑驶向新目标,但 Keyframe 更像被 trigger 播放的一段片段;快速改变 trigger 时,不应假设上一段已经抵达最后关键帧。视觉结果必须始终由当前业务状态兜底,即使动画中途消失,界面仍显示正确的已保存、已选择或已关闭状态。
完成回调适合移除临时视觉资源或发送无业务影响的埋点,不适合提交订单、删除数据或解锁核心流程。Apple 的 Transaction 提供动画 completion 能力,但完成条件与感知时长仍属于呈现层。关键业务操作应在任务成功时更新状态,动画只是响应这个结果。测试动画完成前后都应保证语义控件可用,避免 VoiceOver 或 Reduce Motion 用户被迫等待一段装饰时间线。
失败模式与验证
- 用 delay 串联多个
withAnimation,但没有处理任务取消和视图消失。 - 把 Phase 当业务状态机,动画完成反过来决定请求是否成功。
- Keyframe content 每帧过滤大数组或创建 formatter。
- 同时叠加父级隐式 animation、局部 Phase 和 Keyframe,导致事务来源不可追踪。
- Reduce Motion 只缩短时长,仍保留大范围旋转、闪烁和无限循环。
- 用“看起来 60 FPS”代替 Instruments 证据,或发布没有设备和构建配置的性能数字。
验证至少覆盖:单次触发、快速连续触发、触发中导航离开、后台再前台、Reduce Motion、Dynamic Type 和低电量设备。本文的代码块已在稳定 SDK 上编译,但未执行这个运行时矩阵。单元测试负责业务状态与 trigger 生成;动画最终几何可用 Preview 或 snapshot 检查关键状态;每帧开销必须在稳定 SDK、目标设备和 Release 配置中用 Instruments 测量。因此文章级动效与性能结论仍保持 source-reviewed,不虚构运行结果。
好的 SwiftUI 动画先有语义,再有时间。Transaction 界定一次更新如何传播;Phase 组织离散步骤;Keyframe 协调独立轨道。只要保持业务状态与视觉时间线分离,并把 Reduce Motion 当成第一等策略,复杂动效就仍然可以被中断、测试和维护。
Sources
- Transaction — Apple Developer Documentation
- PhaseAnimator — Apple Developer Documentation
- KeyframeAnimator — Apple Developer Documentation
- Controlling the timing and movements of your animations — Apple sample code
- Explore SwiftUI animation — WWDC23
- Wind your way through advanced animations in SwiftUI — WWDC23