在iOS 17之前,SwiftUI的动画系统基本依赖withAnimation和animation修饰符,这套机制的本质是「状态插值」:你改变一个状态值,框架自动在旧值和新值之间做一次平滑过渡。这种方式简单好用,但有个明显的短板——你只能描述起点和终点,中间过程完全是黑盒。如果想让一个视图在移动的同时先放大再缩小、旋转角度分三段变化、透明度延迟两秒再淡出,旧机制写起来会非常别扭,往往要靠嵌套多层withAnimation加延时Hack来实现。

KeyframeAnimator和PhaseAnimator的出现在很大程度上补齐了这块短板。它们把传统动画软件里「关键帧」的概念带进了声明式UI:你可以显式地在时间线上标记若干个节点,指定每个节点上的属性值以及到达该节点所使用的插值曲线,框架负责把整条时间线串起来渲染。这篇文章会从原理讲到实战,把这两个API的用法、差异和典型场景都梳理一遍。
KeyframeAnimator:单次触发的多轨关键帧动画
KeyframeAnimator的核心思想是「多轨道并行」。你可以把它想象成一个动画剪辑软件的时间线面板:横轴是时间,纵轴是多个属性轨道,每个轨道上可以摆放若干个关键帧,不同轨道之间彼此独立。这意味着位移可以用弹性曲线,旋转用线性曲线,缩放用缓入缓出,三者同时进行、互不干扰,这在旧的animation机制里几乎做不到。
先看一个最基础的用法,实现一个点击后弹跳并旋转的方块:
struct BounceView: View {
@State private var isAnimating = false
var body: some View {
KeyframeAnimator(
initialValue: AnimationValues(),
triggering: isAnimating
) { values in
RoundedRectangle(cornerRadius: 16)
.fill(.orange.gradient)
.frame(width: 100, height: 100)
.scaleEffect(values.scale)
.rotationEffect(.degrees(values.angle))
.offset(y: values.offsetY)
} keyframes: { _ in
KeyframeTrack(\.scaleY) {
CubicKeyframe(0.6, duration: 0.2)
SpringKeyframe(1.0, spring: .bouncy(duration: 0.4))
}
KeyframeTrack(\.angle) {
LinearKeyframe(0, duration: 0)
LinearKeyframe(360, duration: 1.0)
}
KeyframeTrack(\.offsetY) {
CubicKeyframe(-150, duration: 0.3)
CubicKeyframe(0, duration: 0.5)
}
}
.onTapGesture { isAnimating.toggle() }
}
}
struct AnimationValues {
var scaleY: CGFloat = 1.0
var angle: Double = 0
var offsetY: CGFloat = 0
}这段代码里有几个关键点值得展开。第一,initialValue必须是一个具体类型的值(可以是结构体),后续所有关键帧轨道都通过KeyPath来引用这个结构体里的属性,类型安全且编译期就能检查错误。第二,triggering参数是触发开关,每次它发生变化时动画就会从头完整播放一遍,这和withAnimation的触发逻辑类似,但区别在于动画内容完全由keyframes闭包描述,与视图状态解耦。第三,content闭包接收到的values是框架逐帧插值后的实时值,你只需要把它绑定到对应的修饰符上即可。
关于插值曲线的选择,框架提供了四种关键帧类型:CubicKeyframe支持自定义贝塞尔曲线,适合精细控制节奏;SpringKeyframe是物理弹簧,自带过冲回弹效果,注意它没有duration参数,时长由弹簧参数决定;LinearKeyframe是匀速线性插值;MoveKeyframe则是瞬时跳变,常用于离散变化比如切换表情符号。合理混搭这几种曲线,可以做出非常自然的复合动画。
还有一个容易被忽略的细节:每个轨道的第一个关键帧定义的是「从当前值出发到达该值」的过程。比如上面旋转轨道写的LinearKeyframe(0, duration: 0),意思是立即归零,随后在一秒内匀速转到360度。如果第一个关键帧的值与initialValue不同,框架会先把属性从初始值过渡到第一个关键帧值,这个隐式过渡的时长也由该关键帧的duration决定。
PhaseAnimator:多阶段自动循环动画
如果说KeyframeAnimator适合「一次触发、完整播完」的动画,那PhaseAnimator解决的是另一个问题:让视图在你定义的一组阶段(phase)之间自动、反复地流转。它特别适合做加载指示器、呼吸灯效果、背景氛围动画这类需要无限循环的场景,而且不需要任何Timer或递归调用withAnimation。
PhaseAnimator的泛型参数是一个任意哈希的Phase枚举,框架会按照你提供的顺序在各个case之间依次切换,到达末尾后回到开头继续循环。看一个三阶段脉冲光圈的例子:
enum PulsePhase: CaseIterable {
case idle, grow, shrink
var scale: CGFloat {
switch self {
case .idle: return 1.0
case .grow: return 1.5
case .shrink: return 0.8
}
}
var opacity: Double {
switch self {
case .idle: return 0.3
case .grow: return 0.9
case .shrink: return 0.5
}
}
}
struct PulseView: View {
var body: some View {
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.fill(.blue.opacity(0.4))
.frame(width: 120, height: 120)
.scaleEffect(phase.scale)
.opacity(phase.opacity)
} animation: { phase in
switch phase {
case .idle: return .easeIn(duration: 0.4)
case .grow: return .spring(duration: 0.5, bounce: 0.6)
case .shrink: return .easeOut(duration: 0.3)
}
}
}
}这里的结构非常清晰:content闭包负责根据当前phase渲染视图,animation闭包负责定义「进入这个phase时用什么样的动画」。每个阶段可以拥有完全不同的动画曲线和时长,这就比传统的repeatForever灵活得多——后者只能用同一种曲线正反来回播放,节奏感很机械。
PhaseAnimator还有一个可选参数trigger。如果不传,动画默认无限循环;如果传入了某个值,循环只会在该值变化时执行一轮,然后停住。这个特性让PhaseAnimator也能承担单次多阶段动画的职责,比如页面出现时播放一段由三个阶段组成的入场动画。另外要注意Phase枚举必须遵循Hashable,使用CaseIterable配合allCases传入是最省事的写法。
两者的选择策略与组合技巧
面对一个动画需求时,可以这样快速判断:需要属性级精细控制、各属性曲线不同、播放一次就结束,选KeyframeAnimator;需要按阶段顺序流转、天然循环、逻辑以「状态」而非「属性」组织,选PhaseAnimator。两者并不互斥,实际项目中经常组合使用,比如用PhaseAnimator驱动一个持续呼吸的背景光圈,用户点击时再触发一层KeyframeAnimator叠加弹跳动效。
组合时需要注意层级关系。如果两个动画同时修改同一个属性(比如都改scaleEffect),后应用的修饰符会覆盖前者,结果往往不是叠加而是互相打架。正确做法是让它们分别作用于不同属性,或者把两层动画放到不同的视图层级上。例如外层容器做位移,内层内容做缩放:
struct CombinedEffectView: View {
@State private var triggered = false
var body: some View {
PhaseAnimator(PulsePhase.allCases) { phase in
KeyframeAnimator(
initialValue: AnimationValues(),
triggering: triggered
) { values in
Circle()
.fill(.indigo.gradient)
.frame(width: 100, height: 100)
.scaleEffect(values.scaleY)
.rotationEffect(.degrees(values.angle))
} keyframes: { _ in
KeyframeTrack(\.scaleY) {
CubicKeyframe(1.2, duration: 0.25)
CubicKeyframe(1.0, duration: 0.35)
}
KeyframeTrack(\.angle) {
LinearKeyframe(180, duration: 0.6)
}
}
.scaleEffect(phase.scale)
.opacity(phase.opacity)
}
.onTapGesture { triggered.toggle() }
}
}性能方面也要提一句。这两个API底层走的是SwiftUI的渲染管线,插值计算在主线程完成但开销极小,正常使用不必担心掉帧。真正需要警惕的是在content闭包里创建复杂视图树,因为每个动画帧都会重新求值这个闭包,如果里面包含了重量级的列表或图片解码,最好把这些静态内容拆到动画区域之外,只让轻量的修饰符参数(scaleEffect、offset、opacity)参与逐帧变化。
最后补充一个调试技巧:开发阶段可以在关键帧轨道的关键位置打印values值,观察插值轨迹是否符合预期。由于动画闭包每帧都会执行,记得用条件编译包裹日志语句,发布构建时移除,避免日志洪泛影响真实性能表现。掌握这两个API后,你会发现过去那些需要 graft CADisplayLink 或者引入Lottie才能实现的复杂时序动画,现在用纯SwiftUI声明式代码就能优雅搞定。
SwiftUIKeyframeAnimatorPhaseAnimator修改时间:2026-09-11 06:20:42