1.1 动画的两种触发方式:implicit 与 explicit
SwiftUI 的动画系统建立在 "状态变化驱动 UI 重算" 之上。无论 implicit 还是 explicit,本质都是:当某个 @State/@Published 变化引发 view 重新求值时,差异部分按某条 Animation 曲线插值。区别只在于 "谁来声明用哪条曲线"。
Implicit Animation(隐式)
用 .animation(_:value:) 修饰符(iOS 15+ 推荐写法,旧的单参数 .animation(_:) 已 deprecated)。它的语义是:"当我观察的这个 value 变了,我下面这棵子树里所有可动画属性都按这条曲线插"。
// iOS 15+
struct PlayButton: View {
@State private var isPlaying = false
var body: some View {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.scaleEffect(isPlaying ? 1.2 : 1.0)
.foregroundStyle(isPlaying ? .orange : .secondary)
.animation(.snappy(duration: 0.25), value: isPlaying)
.onTapGesture { isPlaying.toggle() }
}
}
Explicit Animation(显式)
withAnimation { ... } 把状态变更包裹起来。语义是:"这次变更产生的所有差异,全部按这条曲线插"。它跨 view 边界生效(属于事务级别 Transaction),而 implicit 只对它修饰的 subtree 生效。
// iOS 17+
withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
appState.openedEpisode = episode // 触发整个 sheet 弹出动画
}
选哪个?
| 场景 | 建议 | 理由 |
|---|---|---|
| 单一 view 的视觉反馈(图标缩放、颜色切换) | implicit | 声明在最近的 modifier,读起来直观 |
| 跨 view、多个状态联动(导航/sheet/list 增删) | explicit | 一次声明,所有 diff 都用这条曲线 |
同一帧改 N 个 @State,但只想动其中一个 | explicit + implicit 组合 | 用 Transaction.animation = nil 关掉不想动的子树 |
| 手势驱动(drag/scrub) | explicit + .interactiveSpring | 需要 inject velocity |
官方资料:WWDC23 "Animate with springs" (Session 10158)、WWDC23 "Wind your way through advanced animations in SwiftUI" (Session 10157)、Apple Developer Documentation: SwiftUI/View/animation(_:value:)、SwiftUI/withAnimation(_:_:)。
1.2 Animation 类型大全
线性与缓动家族(iOS 13+)
.linear(duration:)— 匀速;适合进度条、循环刷新指示,不适合任何"位置变化"。.easeIn(duration:)— 慢起快收;元素离场时用。.easeOut(duration:)— 快起慢收;元素入场时用(用户更早看到结果)。.easeInOut(duration:)— 两端慢中间快;通用过渡,不知道选啥就它。.timingCurve(_:_:_:_:duration:)— 三次贝塞尔,控制点 (c1x, c1y, c2x, c2y);做 Material Design 风格曲线时用。
// iOS 13+
Rectangle()
.frame(width: expanded ? 300 : 80, height: 80)
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.4), value: expanded)
// M3 emphasized 曲线 ≈ (0.2, 0.0, 0, 1.0),这里调过
Spring 家族(iOS 13+ 老 API)
.interpolatingSpring(stiffness:damping:initialVelocity:)— 物理弹簧的"刚度+阻尼+初速度"参数化,完成时间不可控,靠物理模拟自然停下。.spring(response:dampingFraction:blendDuration:)— Apple 后来推的"易调"参数化,response控总时间感觉,dampingFraction控反弹。blendDuration是和上一个 spring 中途切换时的混合时间,绝大多数场景填 0 就行。.interactiveSpring(response:dampingFraction:blendDuration:)— 和上面参数一致,但默认response = 0.15、dampingFraction = 0.86,专为手势设计的低延迟弹簧。
iOS 17+ 新简写:smooth / snappy / bouncy
WWDC23 重新设计了 spring API(Session 10158 "Animate with springs"),引入新参数化方式 (duration:bounce:),把易调性提到最高。
.smooth—duration: 0.5, bounce: 0,无反弹,平顺到位。.snappy—duration: 0.5, bounce: 0.15,轻微反弹,"利落"。.bouncy—duration: 0.5, bounce: 0.3,明显弹性,"活泼"。
三个都接受 (duration:extraBounce:) 形式微调,例如 .snappy(duration: 0.3, extraBounce: 0.1) 把基准 0.15 加到 0.25。
// iOS 17+
struct ToastBanner: View {
@State private var shown = false
var body: some View {
VStack {
if shown {
Text("已加入下载队列")
.padding()
.background(.regularMaterial, in: .capsule)
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
}
.animation(.snappy, value: shown)
.onAppear { shown = true }
}
}
1.3 新旧 Spring 参数对照
iOS 17 新简写底层就是 Spring(duration:bounce:)。它和老的 (response:dampingFraction:) 之间数学上是一一映射:
duration≡response(都表达"感知动画时长",单位秒)。bounce与dampingFraction的关系:bounce = 1 - dampingFraction(所以bounce: 0↔dampingFraction: 1.0↔ critically damped;bounce: 0.3↔dampingFraction: 0.7)。
| 简写 | 新参数 | 等价旧 spring |
|---|---|---|
.smooth | (duration: 0.5, bounce: 0) | .spring(response: 0.5, dampingFraction: 1.0) |
.snappy | (duration: 0.5, bounce: 0.15) | .spring(response: 0.5, dampingFraction: 0.85) |
.bouncy | (duration: 0.5, bounce: 0.3) | .spring(response: 0.5, dampingFraction: 0.7) |
反过来,旧项目里看到 .spring(response: 0.4, dampingFraction: 0.8),等价于 .spring(duration: 0.4, bounce: 0.2)。
1.4 Animation 组合:delay / repeat / speed
Animation 是不可变值类型,所有组合都是返回新值的链式调用。顺序无关(除了 .speed 影响后续解释)。
// iOS 13+
Circle()
.scaleEffect(pulse ? 1.4 : 1.0)
.opacity(pulse ? 0 : 1)
.animation(
.easeOut(duration: 1.0)
.repeatForever(autoreverses: false)
.delay(0.2),
value: pulse
)
.onAppear { pulse = true }
.delay(_:)— 推迟开始,单位秒。Implicit 和 explicit 都生效。.repeatCount(_:autoreverses:)— 整数次重复;autoreverses: true时正反算两次,所以repeatCount(3, autoreverses: true)实际播 1.5 个周期来回。.repeatForever(autoreverses:)— 永久循环,需要在 view 消失或状态切换时主动停(把驱动 value 改回静止值,但 implicit 模式很难"立刻冻住",常见解法是用.id()重建 view)。.speed(_:)— 倍率,0.5是慢动作,2.0加速。
1.5 Spring 物理:三套参数化与换算
iOS 17 引入了独立的 Spring 值类型,可以脱离 Animation 单独描述一条物理弹簧,再喂给 Animation.spring(_:) 或 .interactiveSpring(_:)。三种构造方式互相等价:
// iOS 17+
let s1 = Spring(response: 0.5, dampingRatio: 0.8)
let s2 = Spring(duration: 0.5, bounce: 0.2) // bounce = 1 - dampingRatio
let s3 = Spring(stiffness: 158.0, damping: 20.0, mass: 1.0)
// 三者等价(数值上同一条曲线)
// 直接用 Spring 实例查询任意时刻位置/速度
let pos = s1.value(target: 100, time: 0.2)
let vel = s1.velocity(target: 100, time: 0.2)
底层物理换算(mass 默认 1.0):
- stiffness k = (2π / response)² × mass
- damping c = 4π × dampingRatio × mass / response
所以 response: 0.5, dampingRatio: 0.8, mass: 1 → k ≈ 158, c ≈ 20.1,正是上面 s3。
1.6 物理含义直观解释
| 参数 | 直观含义 | 调大→ |
|---|---|---|
response / duration | 弹簧周期,到位"感觉用了多久" | 整体变慢,更"懒" |
dampingFraction / dampingRatio | 阻尼比,反弹强弱(1.0=刚好不弹,<1 弹,>1 黏) | 越接近 1 越无反弹 |
bounce | = 1 - dampingRatio,反弹强度 | 越大越 Q 弹,可以为负(>1 阻尼) |
mass | "重量",惯性 | 启动更慢、停得更晚,需要更大力 |
stiffness k | 弹簧硬度,回弹力 = -k·x | 来回更快、更刚 |
damping c | 摩擦力 = -c·v | 越大越快停下 |
三种阻尼区间:
- Underdamped(dampingRatio < 1):会过冲、来回振荡。bouncy 风格。
- Critically damped(dampingRatio = 1):最快"刚好到位"且不过冲。smooth 风格。
- Overdamped(dampingRatio > 1):像在油里走,缓慢逼近不过冲,会显"肉"。一般避免。
1.7 Velocity 与手势耦合(interactiveSpring 实战)
从拖拽手势松手时,让动画"接住"最后的速度继续走,是手感的关键。DragGesture 的 onEnded 会给 predictedEndTranslation,但更精细做法是把速度直接喂给 spring:
// iOS 17+
struct DismissibleSheet: View {
@State private var offsetY: CGFloat = 0
@GestureState private var dragY: CGFloat = 0
var body: some View {
Color.orange
.ignoresSafeArea()
.offset(y: offsetY + dragY)
.gesture(
DragGesture()
.updating($dragY) { value, state, _ in
state = max(0, value.translation.height)
}
.onEnded { value in
let v = value.velocity.height // pt/s
let shouldDismiss = value.translation.height > 150 || v > 800
let target: CGFloat = shouldDismiss ? 900 : 0
withAnimation(.interactiveSpring(response: 0.35,
dampingFraction: 0.86,
blendDuration: 0.1)) {
offsetY = target
}
// 注:iOS 17 的 .interactiveSpring 在 withAnimation 内部
// 会自动从当前 view 速度采样接续,无需手填 initialVelocity
}
)
}
}
更老的项目里用 .interpolatingSpring(stiffness:damping:initialVelocity:) 显式传 initialVelocity。iOS 17 之后推荐用 .interactiveSpring + withAnimation,框架会自动续上当前动画速度。
1.8 完成回调 (iOS 17+)
iOS 17 新增 withAnimation(_:completionCriteria:_:completion:),终于能精确知道"这次动画播完了":
// iOS 17+
@State private var loading = false
func startTask() {
withAnimation(.smooth(duration: 0.4)) {
loading = true
} completion: {
// logicallyComplete (默认): spring 数值上已到稳态时触发
Task { await fetch() }
}
}
// 区分两种判定时机
withAnimation(.bouncy, completionCriteria: .removed) {
showOverlay = false
} completion: {
// .removed: transition 完全从 view tree 移除后才触发
// 适合"等 dismiss 动画结束再 navigate"场景
}
| Criteria | 触发时机 | 用途 |
|---|---|---|
.logicallyComplete(默认) | 动画值数学上到达稳态,但 spring 可能还在"回弹尾巴" | 启动后续逻辑(网络请求、状态切换) |
.removed | 所有视图修改、transition 都彻底完成 | 清理 view、链式弹窗 |
1.9 Transaction API:精确控制动画范围
Transaction 是每次状态变更产生的事务对象,承载 animation、disablesAnimations 等元信息。withAnimation 本质就是包了一层 withTransaction(\.animation, ...)。
// iOS 13+ (transaction modifier 自 iOS 13;扩展能力 iOS 14/15 渐进)
struct EpisodeRow: View {
let title: String
@Binding var isFavorite: Bool
var body: some View {
HStack {
Text(title)
Spacer()
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundStyle(isFavorite ? .red : .secondary)
.transaction { tx in
// 即使外层 withAnimation 给了 spring,
// 这个心形我也只想要瞬切(避免颜色 lerp 出脏色)
tx.animation = nil
}
}
}
}
// iOS 17+ 用 transaction(value:_:) 限定触发条件
.transaction(value: scrubbing) { tx in
if scrubbing { tx.animation = nil } // 拖动时不动画,松手时恢复
}
Transaction.disablesAnimations 是更强的"全局禁动画"开关,连子 view 自己声明的 implicit animation 都会失效,慎用。
1.10 修饰符顺序对动画的影响
SwiftUI modifier 是从内向外作用。scaleEffect 后接 rotationEffect 等于"先缩放再旋转",反过来则是"先旋转再缩放"。动画时这会导致插值轨迹完全不同。
// iOS 13+
// A: 先 scale 再 rotate —— 旋转的是"被放大的形状",扫过区域更大
view
.scaleEffect(big ? 1.5 : 1.0)
.rotationEffect(.degrees(big ? 90 : 0))
.animation(.snappy, value: big)
// B: 先 rotate 再 scale —— 旋转完成后整体放大,路径更"老实"
view
.rotationEffect(.degrees(big ? 90 : 0))
.scaleEffect(big ? 1.5 : 1.0)
.animation(.snappy, value: big)
同理 .offset 在 .rotationEffect 之内 vs 之外:内层会让平移方向"跟着旋转走",外层则是屏幕坐标系的平移。在做 dock 弹出、card flip 这类组合动画时这是核心要点。
1.11 iOS 18 / iOS 26 新增能力
- iOS 18 (WWDC24 Session 10145 "Enhance your UI animations and transitions"):
- 新的
.transition协议接口可以读取phase(identity/willAppear/didDisappear),自定义带阶段的入场/出场。 PhaseAnimator和KeyframeAnimator(iOS 17 引入)在 18 上稳定,并新增了与SymbolEffect的协同。- SF Symbols 6 的
.symbolEffect(.wiggle)、.breathe、.rotate与SymbolEffectOptions(speed:)。
- 新的
- iOS 26(Liquid Glass 时代,WWDC25 "Build a SwiftUI app with the new design"、"Meet Liquid Glass" Sessions):
.glassEffect()与GlassEffectContainer形变会自动用一条专门优化过的 spring,跨 view 合并/分离时形成"水滴融合"动画。在自定义 spring 时尽量用.smooth/.snappy与之协调,不要手填过激 bounce。- 新的
NavigationTransition/matchedTransitionSource/navigationTransition(.zoom(...))让 push/sheet 共享 hero 元素,底层用 spring 驱动。 Animation增加了对 reduce-motion 的更细粒度回退支持;建议用@Environment(\.accessibilityReduceMotion)在 spring 与 linear 之间切换。
// iOS 17+: PhaseAnimator
struct LoadingDot: View {
var body: some View {
Circle()
.frame(width: 12, height: 12)
.phaseAnimator([1.0, 0.6, 1.0]) { dot, scale in
dot.scaleEffect(scale).opacity(scale)
} animation: { _ in .easeInOut(duration: 0.4) }
}
}
// iOS 17+: KeyframeAnimator —— 多通道独立时间线
struct LikeBurst: View {
@State private var trigger = 0
var body: some View {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.keyframeAnimator(initialValue: AnimValues(),
trigger: trigger) { content, v in
content.scaleEffect(v.scale).rotationEffect(.degrees(v.angle))
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.6, duration: 0.25, spring: .bouncy)
SpringKeyframe(1.0, duration: 0.35, spring: .smooth)
}
KeyframeTrack(\.angle) {
CubicKeyframe(-15, duration: 0.15)
CubicKeyframe(15, duration: 0.15)
CubicKeyframe(0, duration: 0.20)
}
}
.onTapGesture { trigger += 1 }
}
struct AnimValues { var scale = 1.0; var angle = 0.0 }
}
1.12 implicit vs explicit 的取舍 + 多 @State 同帧的陷阱
同一帧改多个 @State,又只对其中一个 withAnimation,新手最常翻车。规则:
- 放在
withAnimation闭包内的所有 状态变更,触发的 view diff 都会用这条 animation。 - 放在闭包外的,沿用各自子树的 implicit
.animation(_:value:);没有就是瞬切。 - 如果你在
withAnimation内改了 N 个 state,但只想让 1 个动起来 —— 把不想动的那个的 view 加.transaction { $0.animation = nil }。 - 反过来,闭包外改的 state 想动,就在 view 上加
.animation(_:value:)。
// iOS 17+
struct MiniPlayerRow: View {
@State private var expanded = false
@State private var unread = 0
var body: some View {
HStack {
Text("Episode A")
.scaleEffect(expanded ? 1.05 : 1.0)
.animation(.snappy, value: expanded) // implicit, 只关心 expanded
Spacer()
Text("\(unread)")
.transaction { $0.animation = nil } // 数字别 lerp
}
.onTapGesture {
withAnimation(.bouncy) {
expanded.toggle() // 这次动
unread += 1 // 这次也在 withAnimation 里,
// 但被 transaction 拦下,瞬切
}
}
}
}
踩坑速查 / Pitfalls
- 不要用单参数
.animation(_:)(iOS 15 起 deprecated),因为它会监听整个 subtree 的所有变化,频繁误触发;一律用.animation(_:value:)显式声明依赖。.repeatForever切不断:implicit 模式下把驱动 value 改回去并不会立刻停(动画引擎已经"承诺"了一段曲线)。常见解法是.id(running)让 view 随状态重建,或改用PhaseAnimator/SymbolEffect这类自带可暂停语义的 API。- Spring 完成时间 ≠ duration:
.spring(duration: 0.5, bounce: 0.3)的 0.5 是感知时长(无反弹时刚好到位),实际尾部回弹会再持续一段。需要等到完全静止用completion: .removed。- 颜色/数字插值产生脏中间态:红→绿会经过棕色,未读数 3→7 会出现 4/5/6。这种字段加
.transaction { $0.animation = nil }或.animation(nil, value: ...)(iOS 17+)。blendDuration几乎用不上:除非你在 spring 还没结束时换成另一条 spring 想平滑过渡,否则填 0;非 0 时手势手感会发"糊"。- 修饰符顺序坑:
.frame在.scaleEffect之外才会改变布局空间;scaleEffect只是绘制变换,不影响占位。做"卡片放大挤开邻居"要用.frame(width:height:)直接动 size。- 同帧多 state + 单 withAnimation:所有变更都会被这条 animation 接管,数字、计数器这类不希望插值的,必须显式
transaction.animation = nil。- 手势
.interactiveSpring别加.delay:手感会断;要"延迟启动"用普通.spring。- iOS 17
.smooth/.snappy/.bouncy不是常量是函数:可以传(duration:extraBounce:),extraBounce是叠加不是覆盖(.snappy(extraBounce: 0.1)= bounce 0.25)。- Reduce Motion 用户:spring 反弹会被系统自动弱化,但不会完全关;要彻底关掉装饰性动画请读
\.accessibilityReduceMotion自己分支到.linear(duration: 0)或nil。- iOS 26 Liquid Glass 与自定义 spring 冲突:在
GlassEffectContainer内对成员用激进 bouncy spring 会和系统的形变 spring "打架",出现两段不同步抖动;优先用.smooth与系统协调。withAnimation完成回调不会在被中途打断时调用:如果新一次withAnimation覆盖了未播完的旧动画,旧的 completion 会被丢弃;需要"无论如何执行一次"的副作用别放 completion,放调用点同步执行。