SwiftUI 动画的常见失控方式,是先堆 modifier,再问为什么某段会动、为什么另一段继承了错误的曲线。更稳的理解顺序恰好相反:用户事件改变状态,状态更新携带一个 Transaction,视图从旧值重新求值到新值,可动画属性再由 Animation 随时间插值。Phase 和 Keyframe 不是“更炫的 modifier”,而是两种更明确的时间建模工具。

本文面向已会使用 withAnimation,但需要协调多段或多属性动画的开发者。我们会回答三个问题:动画上下文从哪里来;什么时候选择 Phase;什么时候 Keyframe 的控制力才值得每帧成本。

版本边界

PhaseAnimatorKeyframeAnimator 以及本文使用的新式 spring 参数从 iOS 17、macOS 14 等对齐版本起稳定可用。Transaction 则是更早就存在的 SwiftUI 更新上下文。WWDC23:Explore SwiftUI animation 从更新、AnimatableAnimation 一直讲到 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 的变化,适合局部、稳定的隐式规则;withAnimationwithTransaction 更适合某个用户意图产生的一组更新。无 value 的旧式 .animation(_:) 容易让同一子树中其他变化意外继承,现代代码应避免模糊传播范围。

普通动画、PhaseAnimator 与 KeyframeAnimator 的选择路径

图 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