第 5 章 动画的本质:两个值之间的补间

动画 = 框架反复给中间值:withAnimation 与 .animation(_:value:) 的分工、为什么有的属性不动、transition 与身份的关系

第 5 章:动画的本质

大多数人以为动画是"给界面加个效果"。SwiftUI 不是这么设计的:动画是"让一次状态变化被看懂"的手段。 想通这一点,.animation 怎么加、加在哪,就都有答案了。

5.1 先破除一个直觉:动画不是"从 A 到 B 的过渡"

直觉上,动画像是"从 A 平滑地移动到 B"。但如果按这个直觉去理解 SwiftUI,会写出很多不生效的代码。

SwiftUI 的真相是:

flowchart LR
    A["状态从 50 变成 200"] --> B["SwiftUI 决定要动画"]
    B --> C["它计算出一条时间曲线<br/>(spring / easeInOut …)"]
    C --> D["按帧反复给中间值:<br/>50 → 61 → 88 → … → 200"]
    D --> E["每给一个值,就重新布局+重绘一次"]
    E --> F["人眼看到连续变化"]

🔥 关键:动画 = 框架按时间曲线,把"中间值"反复喂给你的视图。

这解释了两件反直觉的事:

  1. 你从来没写过"中间值",你只写了起点和终点(两个状态)。中间值是框架算的。
  2. 只有"能接受中间值"的东西才能动画。 一个宽度可以是 61.3,所以能动画;但"这段文字的内容"没有中间值,所以不能。

用一个自定义 Shape 亲眼看这个过程

Shape 协议要求你提供 animatableData——它就是"框架要反复喂的那个值":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct Sweep: Shape, Animatable {
    var progress: Double

    // 框架会反复给这个属性赋中间值
    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var p = Path()
        p.move(to: CGPoint(x: rect.minX, y: rect.midY))
        p.addLine(to: CGPoint(x: rect.minX + rect.width * progress, y: rect.midY))
        return p
    }
}

struct SweepDemo: View {
    @State private var progress = 0.0

    var body: some View {
        VStack(spacing: 30) {
            Sweep(progress: progress)
                .stroke(.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round))
                .frame(height: 8)

            Button("走一遍") {
                withAnimation(.easeInOut(duration: 1.5)) { progress = 1 }
            }
            Button("复位") { progress = 0 }        // 没有 withAnimation,瞬间归零
        }
        .padding()
    }
}

🔬 在 setter 里加一行 print(newValue),你会看到它被调用几十次,每次的值都不一样(0.03、0.08、0.17……)。这就是动画的物理真相。

⚠️ 顺便一个 Swift 6 的注意点:animatableData 的 setter 是 nonisolated 的,所以如果你想在里面积累日志,不能用 @MainActor 的变量存,否则会报:

1
error: main actor-isolated property 'calls' can not be mutated from a nonisolated context

调试时用 print 最省事;真要在并发上下文收集,用 Mutex(见 Swift 速查表第 9 章)。

5.2 两种写法,两种职责

SwiftUI 有两个入口触发动画,很多人混着用。它们的职责其实很清楚:

withAnimation { ... }.animation(_:value:)
加在哪改状态的地方(按钮动作、手势回调)视图上(修饰符)
作用范围这次闭包里发生的所有状态变化这个视图(及其子树)里,当 value 变化时
触发条件你主动调用value 的值发生改变
典型用途用户操作引发的动画“某个数据变了,请平滑地跟上”

withAnimation:把"这一批变化"标记为可动画

1
2
3
4
5
6
Button("展开") {
    withAnimation(.spring(duration: 0.4)) {
        isExpanded.toggle()
        rotation += 180          // 闭包里的多个变化会一起动画
    }
}

🔥 它是命令式的一次性标记:这行代码执行时,闭包里所有状态变化都走动画。

.animation(_:value:):声明式地"跟着某个值走"

1
2
3
Text("\(count)")
    .scaleEffect(isBig ? 1.5 : 1.0)
    .animation(.bouncy, value: isBig)     // isBig 一变,这个 scaleEffect 就动画

⚠️ value: 参数不能省。 老写法 .animation(.spring) 没有 value,会在任何状态变化时都尝试动画,导致难以预测的连锁动画,所以已经被弃用:

1
warning: 'animation' was deprecated in macOS 12.0: Use withAnimation or animation(_:value:) instead.

怎么选

场景用哪个
用户点了按钮/拖了手势,想让这次变化有动画withAnimation
某个状态可能被多处修改,但每处都希望有动画.animation(_:value:)
某个派生效果的动画(比如数字变化时脉冲一下).animation(_:value:)
只想让一部分视图动画,而不是全部.animation 加在那个子视图上

💭 经验:能用 withAnimation 就用它——因为它把"这次动画"和"这次状态变化"绑在一起,代码读起来是因果明确的。.animation(_:value:) 适合"这个视图永远应该平滑跟随那个值"的声明式场景。

5.3 哪些能动画,哪些不能

这是最实用的一节。判断标准只有一条:

🔥 能不能动画,取决于"这个属性有没有中间值"。

类别例子能动画吗
数值型布局属性frame 的宽高、padding、offset、scaleEffect、rotationEffect✅ 能,因为可以是 61.3
颜色foregroundStyle、background、opacity✅ 能,颜色通道可以插值
位置视图在父视图里的位置变化(因为布局变了)✅ 能
形状参数CornerRadius、自定义 animatableData✅ 能(需要 Animatable)
文字内容Text("A") → Text("B")❌ 不能,没有"中间的文字"
视图的类型Text → Image❌ 不能,是两个不同的东西(见 _ConditionalContent)
枚举分支的值从 .loading 变到 .loaded❌ 整体不是连续量;但可以动画转场(下一节)
if 的有无if show { Text("x") }❌ 不是"变化",是"出现/消失"→ 用 transition

⚠️ 最后两行是最容易被误判的。很多人写:

1
2
3
4
5
if isLoading {
    ProgressView()
} else {
    ContentView()
}

然后想用 withAnimation 让它"淡入淡出"——不会生效,因为这不是同一个视图的属性变化,而是两个不同的视图在替换。这种情况要用 .transition。

5.4 .transition:视图出现和消失时的动画

transition 管的是"一个视图从无到有、或从有到无"时的表现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct TransitionDemo: View {
    @State private var showDetail = false

    var body: some View {
        VStack {
            Button(showDetail ? "收起" : "展开") {
                withAnimation(.easeInOut(duration: 0.3)) {
                    showDetail.toggle()
                }
            }

            if showDetail {
                Text("详情内容")
                    .padding()
                    .background(.blue.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
    }
}
内置转场效果
.opacity淡入淡出
.scale从中心缩放
.scale(scale: 0.8, anchor: .top)指定初始缩放与锚点
.move(edge: .top)从某条边滑入/滑出
.slide从前往后滑(列表里常用)
.push(from: .trailing)推入式
.asymmetric(insertion:removal:)进入和离开用不同效果

transition 必须和 withAnimation 配合

⚠️ 这是最常见的"转场不动"的原因:

1
2
3
4
5
6
7
// 🛑 不动:状态变了,但没有告诉框架"这次要动画"
Button("切换") { showDetail.toggle() }
if showDetail { Detail().transition(.opacity) }

// ✅ 动了:用 withAnimation 包住状态变化
Button("切换") { withAnimation { showDetail.toggle() } }
if showDetail { Detail().transition(.opacity) }

🔥 记住分工:transition 描述"怎么进出",withAnimation 提供"这次是动画"。 缺一不可。

💭 另一个选择是给 if 外面套 .animation(_:value:),同样有效:

1
2
3
4
Group {
    if showDetail { Detail().transition(.opacity) }
}
.animation(.easeInOut, value: showDetail)

5.5 身份:为什么动画有时"飞过去"、有时"凭空出现"

这是本章最深入的一节,也是第 6 章的引子。

SwiftUI 在对比新旧视图树时,要判断"这两个视图是不是同一个"。判断依据就是身份:同一个类型 + 同一个位置 = 同一个视图。

  • 同一个视图,属性变了 → 可以补间 → 动画
  • 身份变了(不同的位置、或 id 不同) → 旧视图消失、新视图出现 → 走 transition
1
2
3
4
5
// 情况一:同一个视图,宽度在变 → 会平滑地长/缩
Rectangle().frame(width: big ? 200 : 50)

// 情况二:两个不同分支的视图 → 走转场,不会"变形"
if big { WideView() } else { NarrowView() }

matchedGeometryEffect:告诉框架"这两个是同一个东西"

当你确实想让"情况二"里的两个视图看起来是同一个东西在移动时,用 matchedGeometryEffect:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ExpandDemo: View {
    @Namespace private var ns
    @State private var expanded = false

    var body: some View {
        ZStack {
            if expanded {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue)
                    .frame(width: 300, height: 200)
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring) { expanded = false } }
            } else {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.blue)
                    .frame(width: 100, height: 60)
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring) { expanded = true } }
            }
        }
    }
}

关键点:

要素作用
@Namespace private var ns声明一个"命名空间",把两边关联起来
id: "card"两边必须相同,框架才知道它们是"同一个东西"
in: ns同一个命名空间
外层要有 withAnimation否则瞬间切换

🔥 matchedGeometryEffect 的本质是:"虽然这是两个不同的视图,但请把它们当成同一个,然后动画它们之间的几何差异。" 它常见于照片网格→大图的展开效果。

5.6 PhaseAnimator 与 KeyframeAnimator

前面所有动画都是"从状态 A 到状态 B"。但有些动画没有状态变化——比如"呼吸灯"式的循环脉冲。这时用 PhaseAnimator(iOS 17 / macOS 14 起):

1
2
3
4
5
6
7
8
PhaseAnimator([1.0, 1.3, 1.0]) { phase in
    Circle()
        .fill(.blue)
        .frame(width: 80, height: 80)
        .scaleEffect(phase)
} animation: { _ in
    .easeInOut(duration: 0.6)
}

它会自动循环遍历这些 phase,不需要任何 @State。适合提示性动效(加载中、提醒、骨架屏)。

需要更精细的时间控制时用 KeyframeAnimator:

1
2
3
4
5
6
7
8
KeyframeAnimator(initialValue: 0.0) { offset in
    Circle().fill(.orange).frame(width: 40, height: 40).offset(x: offset)
} keyframes: { _ in
    KeyframeTrack(\.self) {
        CubicKeyframe(120, duration: 0.5)
        SpringKeyframe(0, duration: 0.6, spring: .bouncy)
    }
}
工具什么时候用
withAnimation / .animation状态变化驱动的动画(90% 的场景)
PhaseAnimator循环的多阶段动效,且不需要状态
KeyframeAnimator需要精确控制每个时间点的值(复杂路径、分段缓动)

⚠️ 这两个 API 的可用性起点是 iOS 17 / macOS 14。要支持更老的系统就用 withAnimation + 定时器手动驱动。

5.7 动画的两条纪律

动画写多了以后,最容易犯的两个错误:

纪律一:只动画"用户需要理解的变化"

动画不是装饰。它的作用是让用户明白发生了什么:

好的动画为什么
列表插入一行时淡入 + 滑入用户知道"多了一条,加在这里"
按钮按下时轻微缩放用户知道"点击生效了"
卡片展开时从缩略图位置放大用户知道"这个大图就是刚才那张小的"
坏的动画为什么
每个元素都带弹跳视觉噪音,用户会疲劳
每次数据刷新都整体淡入用户无法判断"哪些变了"
动画时长超过 0.5 秒感觉卡顿,像在等加载

💭 时长参考:微交互 0.15–0.25s,常规过渡 0.25–0.4s,大范围转场 0.4–0.6s。 超过 0.6s 用户就开始觉得慢了。

纪律二:不要动画会"高频变化"的属性

1
2
3
4
5
6
7
// 🛑 滚动位置每帧都在变,给它加动画只会打架
ScrollView { ... }
    .animation(.spring, value: scrollOffset)

// 🛑 输入框每敲一个字都触发
TextField("搜索", text: $query)
    .animation(.bouncy, value: query)

🔥 判据:如果一个值每秒会变几十次,它就不该有动画。 动画是给"状态跃迁"用的,不是给"连续变化"用的。

无障碍:尊重"减弱动态效果"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在视图里读系统的"减弱动态效果"开关
@Environment(\.accessibilityReduceMotion) private var reduceMotion

// 写成一个可选的动画:开关打开时给 nil
var animation: Animation? {
    reduceMotion ? nil : .spring(duration: 0.4)
}

// 用法:withAnimation 接受 Animation?,传 nil 就是"不做动画"
withAnimation(animation) { expanded.toggle() }

⚠️ withAnimation 接受 Animation?,传 nil 就是"不做动画"。系统设置里打开了"减弱动态效果"的用户,通常有前庭功能障碍,强动画会真的让他们不舒服。这是及格线,不是加分项。

5.8 完整可运行文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import SwiftUI

struct AnimationLab: View {
    @State private var isExpanded = false
    @State private var count = 0
    @State private var showBadge = false
    @Namespace private var ns
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.4) }

    var body: some View {
        ScrollView {
            VStack(spacing: 32) {

                // ① 布局属性可动画:宽度
                section("① frame 宽度(可动画)") {
                    RoundedRectangle(cornerRadius: 8)
                        .fill(.blue)
                        .frame(width: isExpanded ? 260 : 80, height: 40)
                }

                // ② 只动画指定属性:value 明确
                section("② 数字变化时脉冲(.animation(value:))") {
                    Text("\(count)")
                        .font(.system(size: 40, weight: .bold, design: .rounded))
                        .scaleEffect(showBadge ? 1.3 : 1.0)
                        .animation(.bouncy(duration: 0.3), value: showBadge)
                }

                // ③ 转场:需要 withAnimation + transition
                section("③ 转场(withAnimation + transition)") {
                    VStack {
                        if showBadge {
                            Text("我是转场出来的")
                                .padding(8)
                                .background(.green.opacity(0.2), in: Capsule())
                                .transition(.move(edge: .top).combined(with: .opacity))
                        }
                    }
                    .frame(height: 50)
                }

                // ④ matchedGeometryEffect:身份不同但当成同一个
                section("④ matchedGeometryEffect") {
                    ZStack {
                        if isExpanded {
                            RoundedRectangle(cornerRadius: 20)
                                .fill(.orange)
                                .frame(width: 240, height: 120)
                                .matchedGeometryEffect(id: "card", in: ns)
                        } else {
                            RoundedRectangle(cornerRadius: 10)
                                .fill(.orange)
                                .frame(width: 80, height: 40)
                                .matchedGeometryEffect(id: "card", in: ns)
                        }
                    }
                    .frame(height: 130)
                }

                // ⑤ PhaseAnimator:无需状态变化的循环动效
                section("⑤ PhaseAnimator(循环脉冲)") {
                    PhaseAnimator([1.0, 1.25, 1.0]) { phase in
                        Image(systemName: "bell.fill")
                            .font(.system(size: 36))
                            .foregroundStyle(.red)
                            .scaleEffect(phase)
                    } animation: { _ in
                        .easeInOut(duration: 0.7)
                    }
                }

                HStack {
                    Button("切换布局") { withAnimation(animation) { isExpanded.toggle() } }
                    Button("加一") { count += 1; showBadge.toggle() }
                    Button("转场开关") { withAnimation(animation) { showBadge.toggle() } }
                }
                .buttonStyle(.bordered)
            }
            .padding()
        }
    }

    func section<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
        VStack(spacing: 8) {
            Text(title).font(.footnote).foregroundStyle(.secondary)
            content()
        }
    }
}

#Preview {
    AnimationLab()
}

📦 这一段是 View + #Preview,没有 @main:新建 Chapter05.swift 放进 Xcode 的 App 项目,再把 App 文件里的 WindowGroup { ContentView() } 改成 WindowGroup { AnimationLab() };只想看效果就直接看 #Preview——预览不需要入口。

💭 跑起来以后重点观察三件事:

  1. ①② 是"同一个视图的属性在变"——平滑补间;
  2. ③④ 涉及"视图的进出/身份"——走转场和几何匹配;
  3. ⑤ 完全没有 @State——PhaseAnimator 自己循环。

5.9 本章易错点速查

你会怎么写实际发生什么正确做法
.animation(.spring)(无 value:)已弃用;任何状态变化都会尝试动画,行为难预测写 .animation(.spring, value: someValue)
if/else 两个分支之间想用 withAnimation 变形不会变形,是两个不同视图用 .transition,或 matchedGeometryEffect
加了 .transition 但不动缺 withAnimation(或 .animation(value:))用 withAnimation 包住状态变化
想动画 Text 的内容变化文字没有中间值用 .contentTransition(.numericText())(数字)或做转场
给高频变化的值加动画滚动/输入时动画互相打架,卡顿只给"状态跃迁"加动画
给每样东西都加弹跳视觉噪音只为"需要被理解的变化"加
忽略"减弱动态效果"部分用户会不适读 \.accessibilityReduceMotion,为真时传 nil
在 animatableData 的 setter 里用 @MainActor 变量main actor-isolated property can not be mutated from a nonisolated context用 print 调试,或用 Mutex 收集

5.10 下一章

动画讲完了,但它反复提到一个词:身份。什么情况下算"同一个视图"、什么时候算"换了一个"——这决定了动画是补间还是转场,也决定了状态会不会被重置。

下一章是最后一章:手势与身份。我们会把"用户的手"和"框架的记忆"这两件事接上。

最后修改 September 21, 2026: 更新 (ac821931b)