Highlight
SwiftUI 在 iOS 17 引入了两套新 API:
phaseAnimator用于定义多阶段循环/事件驱动动画,keyframeAnimator用于创建多轨道关键帧动画,两者都支持自定义每阶段的动画曲线,让复杂动画的实现从”手写状态机”变为”声明式配置”。
核心内容
传统动画的局限
Tim 从 SwiftUI 团队出发,指出 withAnimation 只能处理”从旧状态到新状态”的过渡。很多动画需求更复杂:加载指示器需要持续循环,按钮点击后需要播放一个脉冲效果再恢复。这些动画有多个步骤,步骤之间不是简单的状态切换。
Phase Animator:多阶段动画
(03:13)phaseAnimator 修饰符接受一组阶段(phases),SwiftUI 自动在阶段之间循环切换。
最简单的例子是两阶段循环——高亮和正常:
OverdueReminderView()
.phaseAnimator([false, true]) { content, value in
content
.foregroundStyle(value ? .red : .primary)
} animation: { _ in
.easeInOut(duration: 1.0)
}
关键点:
- 提供阶段数组,SwiftUI 自动循环
- 每个阶段可以修改视图的任意属性
animation闭包接收目标阶段,可以为不同阶段指定不同动画- 默认使用 spring,可以用
animation闭包覆盖
(06:20)用自定义枚举定义更复杂的阶段:
ReactionView()
.phaseAnimator(
Phase.allCases,
trigger: reactionCount
) { content, phase in
content
.scaleEffect(phase.scale)
.offset(y: phase.verticalOffset)
} animation: { phase in
switch phase {
case .initial: .smooth
case .move: .easeInOut(duration: 0.3)
case .scale: .spring(duration: 0.3, bounce: 0.7)
}
}
enum Phase: CaseIterable {
case initial
case move
case scale
var verticalOffset: Double {
switch self {
case .initial: 0
case .move, .scale: -64
}
}
var scale: Double {
switch self {
case .initial: 1.0
case .move: 1.1
case .scale: 1.8
}
}
}
关键点:
- 自定义枚举遵循
CaseIterable,每个 case 定义该阶段的属性值 trigger参数让动画在事件触发时播放一次(而非循环)- 每个阶段可以有自己的动画曲线
Keyframe Animator:关键帧动画
(09:48)keyframeAnimator 允许为每个动画属性定义独立的轨道(track),每个轨道有自己的一组关键帧。
struct AnimationValues {
var scale = 1.0
var verticalStretch = 1.0
var verticalTranslation = 0.0
var angle = Angle.zero
}
ReactionView()
.keyframeAnimator(initialValue: AnimationValues()) { content, value in
content
.foregroundStyle(.red)
.rotationEffect(value.angle)
.scaleEffect(value.scale)
.scaleEffect(y: value.verticalStretch)
.offset(y: value.verticalTranslation)
} keyframes: { _ in
KeyframeTrack(\.angle) {
CubicKeyframe(.zero, duration: 0.58)
CubicKeyframe(.degrees(16), duration: 0.125)
CubicKeyframe(.degrees(-16), duration: 0.125)
CubicKeyframe(.degrees(16), duration: 0.125)
CubicKeyframe(.zero, duration: 0.125)
}
KeyframeTrack(\.verticalStretch) {
CubicKeyframe(1.0, duration: 0.1)
CubicKeyframe(0.6, duration: 0.15)
CubicKeyframe(1.5, duration: 0.1)
CubicKeyframe(1.05, duration: 0.15)
CubicKeyframe(1.0, duration: 0.88)
CubicKeyframe(0.8, duration: 0.1)
CubicKeyframe(1.04, duration: 0.4)
CubicKeyframe(1.0, duration: 0.22)
}
KeyframeTrack(\.scale) {
LinearKeyframe(1.0, duration: 0.36)
SpringKeyframe(1.5, duration: 0.8, spring: .bouncy)
SpringKeyframe(1.0, spring: .bouncy)
}
KeyframeTrack(\.verticalTranslation) {
LinearKeyframe(0.0, duration: 0.1)
SpringKeyframe(20.0, duration: 0.15, spring: .bouncy)
SpringKeyframe(-60.0, duration: 1.0, spring: .bouncy)
SpringKeyframe(0.0, spring: .bouncy)
}
}
关键点:
initialValue定义动画开始时的初始值- 每个
KeyframeTrack对应一个属性的动画轨道 - 关键帧类型:
LinearKeyframe(线性插值)、CubicKeyframe(三次曲线)、SpringKeyframe(弹簧动画) - 不同轨道可以有不同的持续时间和关键帧数量
- 轨道之间可以交错,产生丰富的组合效果
Map 关键帧动画
(15:22)关键帧动画也可以用于 Map 的相机:
Map(initialPosition: .rect(route.rect)) {
MapPolyline(coordinates: route.coordinates)
.stroke(.orange, lineWidth: 4.0)
Marker("Start", coordinate: route.start)
.tint(.green)
Marker("End", coordinate: route.end)
.tint(.red)
}
.mapCameraKeyframeAnimation(trigger: playTrigger) { initialCamera in
KeyframeTrack(\.centerCoordinate) {
let points = route.points
for point in points {
CubicKeyframe(point.coordinate, duration: 16.0 / Double(points.count))
}
CubicKeyframe(initialCamera.centerCoordinate, duration: 4.0)
}
KeyframeTrack(\.heading) {
CubicKeyframe(heading(from: route.start.coordinate, to: route.end.coordinate), duration: 6.0)
CubicKeyframe(heading(from: route.end.coordinate, to: route.end.coordinate), duration: 8.0)
CubicKeyframe(initialCamera.heading, duration: 6.0)
}
KeyframeTrack(\.distance) {
CubicKeyframe(24000, duration: 4)
CubicKeyframe(18000, duration: 12)
CubicKeyframe(initialCamera.distance, duration: 4)
}
}
关键点:
mapCameraKeyframeAnimation是 Map 特有的关键帧动画修饰符- 可以控制相机的中心坐标、朝向(heading)和距离(distance)
- 相机沿路线平滑飞行,最后回到初始位置
KeyframeTimeline
(16:26)KeyframeTimeline 可以独立使用,不依赖视图:
let myKeyframes = KeyframeTimeline(initialValue: CGPoint.zero) {
KeyframeTrack(\.x) { ... }
KeyframeTrack(\.y) { ... }
}
let duration: TimeInterval = myKeyframes.duration
let value = myKeyframes.value(time: 1.2)
关键点:
- 可以查询动画总时长
- 可以查询任意时间点的值
- 适合用于自定义动画逻辑或与其他系统集成
详细内容
Phase Animator 的工作原理
(03:57)Phase Animator 的工作流程:
- 视图首次出现时,第一个阶段激活
- SwiftUI 立即开始动画过渡到下一个阶段
- 动画完成后,自动前进到下一个阶段
- 到达最后一个阶段后,循环回到第一个阶段
如果提供了 trigger 参数,动画在 trigger 值变化时播放一次所有阶段,然后停止。
关键帧类型详解
三种关键帧类型各有适用场景:
- LinearKeyframe:匀速变化,适合需要恒定速度的属性
- CubicKeyframe:平滑曲线,适合大多数视觉属性
- SpringKeyframe:物理弹簧效果,适合需要弹性的属性
SpringKeyframe 可以只指定目标值和弹簧参数,让 SwiftUI 自动计算持续时间。
核心启发
-
循环提醒动画
- 做什么:为待办事项、通知提醒添加呼吸灯效果
- 为什么值得做:
phaseAnimator让循环动画变成几行代码,不需要定时器或状态机 - 怎么开始:定义两个阶段的数组(正常/高亮),用
.phaseAnimator修饰提醒视图,在animation闭包中指定.easeInOut(duration: 1.5)
-
点赞/收藏动效
- 做什么:用户点赞时播放一个多阶段的心形放大动画
- 为什么值得做:
trigger模式让事件驱动动画变得简单,可以组合缩放、旋转、颜色变化 - 怎么开始:定义
initial->scaleUp->settle三个阶段,用trigger: likeCount触发,每个阶段指定不同的弹簧参数
-
地图路线漫游
- 做什么:在地图上展示跑步/骑行路线,相机沿路线飞行
- 为什么值得做:
mapCameraKeyframeAnimation让复杂的相机控制变成声明式配置 - 怎么开始:准备路线的坐标点数组,为
centerCoordinate轨道创建关键帧,每个点对应一个CubicKeyframe
-
加载状态指示器
- 做什么:自定义加载动画,比系统
ProgressView更有品牌特色 - 为什么值得做:
KeyframeTimeline可以独立使用,配合Canvas绘制任意形状的动画 - 怎么开始:创建
KeyframeTimeline定义形状属性随时间的变化,用TimelineView驱动重绘
- 做什么:自定义加载动画,比系统
-
复杂转场动画
- 做什么:视图出现/消失时播放多属性组合动画
- 为什么值得做:关键帧动画让多个属性(位置、缩放、旋转、透明度)可以独立控制又协调配合
- 怎么开始:定义包含所有动画属性的结构体,为每个属性创建
KeyframeTrack,在视图修饰符中应用.keyframeAnimator
关联 Session
- Explore SwiftUI animation — SwiftUI 动画基础原理
- Animate with springs — 弹簧动画深入讲解
- SwiftUI essentials — SwiftUI 基础概念
- Beyond scroll views — ScrollView 中的滚动过渡动画
评论
GitHub Issues · utterances