← pengxu

SwiftUI 动效 · 交互 · Metal · 物理

深度研究参考 · Anycast iOS 26 项目 · 12 个研究 sub-agent 并行 · 浏览器自动刷新

完成 0 / 12 更新

1. 动画基础 + Spring 物理 完成

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.15dampingFraction = 0.86专为手势设计的低延迟弹簧

iOS 17+ 新简写:smooth / snappy / bouncy

WWDC23 重新设计了 spring API(Session 10158 "Animate with springs"),引入新参数化方式 (duration:bounce:),把易调性提到最高。

  • .smoothduration: 0.5, bounce: 0,无反弹,平顺到位。
  • .snappyduration: 0.5, bounce: 0.15,轻微反弹,"利落"。
  • .bouncyduration: 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:) 之间数学上是一一映射

  • durationresponse(都表达"感知动画时长",单位秒)。
  • bouncedampingFraction 的关系:bounce = 1 - dampingFraction(所以 bounce: 0dampingFraction: 1.0 ↔ critically damped;bounce: 0.3dampingFraction: 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: 1k ≈ 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 实战)

从拖拽手势松手时,让动画"接住"最后的速度继续走,是手感的关键。DragGestureonEnded 会给 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 是每次状态变更产生的事务对象,承载 animationdisablesAnimations 等元信息。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),自定义带阶段的入场/出场。
    • PhaseAnimatorKeyframeAnimator(iOS 17 引入)在 18 上稳定,并新增了与 SymbolEffect 的协同。
    • SF Symbols 6 的 .symbolEffect(.wiggle).breathe.rotateSymbolEffectOptions(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,新手最常翻车。规则:

  1. 放在 withAnimation 闭包内的所有 状态变更,触发的 view diff 都会用这条 animation。
  2. 放在闭包外的,沿用各自子树的 implicit .animation(_:value:);没有就是瞬切。
  3. 如果你在 withAnimation 内改了 N 个 state,但只想让 1 个动起来 —— 把不想动的那个的 view 加 .transaction { $0.animation = nil }
  4. 反过来,闭包外改的 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,放调用点同步执行。

2. 自定义动画 / Animatable 完成

2.1 Animatable 协议:SwiftUI 动画的物理基础

SwiftUI 的所有动画底层都通过 Animatable 协议驱动。该协议只有一个 associated type 与一个属性:

// Apple 官方定义(SwiftUI framework, iOS 13+)
public protocol Animatable {
    associatedtype AnimatableData: VectorArithmetic
    var animatableData: AnimatableData { get set }
}

当一个 ViewViewModifierShapeGeometryEffect 遵守 Animatable,SwiftUI 在 transaction 内部对其 animatableData插值(非 view tree diff),由 render server 在每一帧 tick 设新值并触发 body/path(in:)/effectValue(size:) 重算。这意味着真正"动"的不是 view 而是数值,所以理解 VectorArithmetic 是关键。

VectorArithmetic 继承自 AdditiveArithmetic,新增 scale(by:)magnitudeSquared。内置实现包含 FloatDoubleCGFloatEmptyAnimatableData,以及递归的 AnimatablePair<First, Second>。SwiftUI 用这些做 catmull-rom / spring / 各种 curve 的逐帧插值。

参考:WWDC23 Explore SwiftUI animation(Session 10156),WWDC23 Wind your way through advanced animations in SwiftUI(Session 10157);Apple Doc: developer.apple.com/documentation/swiftui/animatable

2.2 AnimatablePair 多维度组合

当一个值需要同时对两个标量插值,使用 AnimatablePair。它本身是 VectorArithmetic,可以无限嵌套。下例做一个圆环进度,同时动画化"已绘制比例"与"线宽":

// iOS 13+ — AnimatablePair 示例
struct ProgressRing: Shape {
    var progress: CGFloat        // 0...1
    var lineWidth: CGFloat       // 描边宽度

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(progress, lineWidth) }
        set {
            progress  = newValue.first
            lineWidth = newValue.second
        }
    }

    func path(in rect: CGRect) -> Path {
        let radius = min(rect.width, rect.height) / 2 - lineWidth / 2
        let center = CGPoint(x: rect.midX, y: rect.midY)
        var p = Path()
        p.addArc(
            center: center,
            radius: radius,
            startAngle: .degrees(-90),
            endAngle: .degrees(-90 + 360 * progress),
            clockwise: false
        )
        return p.strokedPath(.init(lineWidth: lineWidth, lineCap: .round))
    }
}

嵌套示例:3D 翻牌需要 (angleX, angleY, scale),写成 AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>。三个以上参数虽然能写但难维护,更好的做法是iOS 17+ KeyframeAnimator 多 track(见 2.6)。

AnimatableModifier 自 iOS 13 起即已 deprecated。直接让你的 ViewModifier 实现 Animatable 即可,效果等价。

2.3 Shape.animatableData 实战:波浪与星形

// iOS 13+ — 正弦波浪(音频可视化常用)
struct Wave: Shape {
    var phase: CGFloat   // 推进相位 → 横向流动
    var amplitude: CGFloat

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { .init(phase, amplitude) }
        set { phase = newValue.first; amplitude = newValue.second }
    }

    func path(in rect: CGRect) -> Path {
        var p = Path()
        let midY = rect.midY
        let wavelength = rect.width / 2
        p.move(to: .init(x: 0, y: midY))
        for x in stride(from: 0, through: rect.width, by: 1) {
            let relative = (x / wavelength) + phase
            let y = midY + sin(relative * .pi * 2) * amplitude
            p.addLine(to: .init(x: x, y: y))
        }
        return p
    }
}

// 用法:
// .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: phase)
// iOS 13+ — N 角星形 morph,通过 sides 浮点插值实现"边数变化"动画
struct MorphStar: Shape {
    var sides: CGFloat  // 3.0 → 8.0 可插值
    var inset: CGFloat  // 0 = 多边形,>0 = 星形

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { .init(sides, inset) }
        set { sides = newValue.first; inset = newValue.second }
    }

    func path(in rect: CGRect) -> Path {
        let n = max(3, Int(sides.rounded()))
        let r = min(rect.width, rect.height) / 2
        let c = CGPoint(x: rect.midX, y: rect.midY)
        var path = Path()
        let step = .pi * 2 / CGFloat(n * 2)
        for i in 0..<(n * 2) {
            let radius = i.isMultiple(of: 2) ? r : r * (1 - inset)
            let angle = -.pi / 2 + step * CGFloat(i)
            let pt = CGPoint(x: c.x + cos(angle) * radius, y: c.y + sin(angle) * radius)
            i == 0 ? path.move(to: pt) : path.addLine(to: pt)
        }
        path.closeSubpath()
        return path
    }
}

2.4 GeometryEffect:用 transform 矩阵做动画

GeometryEffect 继承 AnimatableViewModifier,关键方法:

func effectValue(size: CGSize) -> ProjectionTransform

每帧 SwiftUI 拿当前 size 算出一个 3x3 投影矩阵(CATransform3D 的 2D 子集),合成到 layer 上。不会触发 layout,只走 GPU compositing —— 比 frame / offset + animation 的等价路径便宜。下面三例覆盖 shake、3D flip、弹性卡片。

// iOS 13+ — Shake 抖动(密码错误反馈)
struct Shake: GeometryEffect {
    var amount: CGFloat = 8
    var shakesPerUnit = 3
    var animatableData: CGFloat   // 外部递增触发动画

    func effectValue(size: CGSize) -> ProjectionTransform {
        let dx = amount * sin(animatableData * .pi * CGFloat(shakesPerUnit))
        return ProjectionTransform(CGAffineTransform(translationX: dx, y: 0))
    }
}

extension View {
    func shake(times: Int) -> some View {
        modifier(Shake(animatableData: CGFloat(times)))
    }
}

// 触发:每次失败 attempts += 1,配 .animation(.default, value: attempts)
// iOS 13+ — 3D flip(翻牌 / 卡片正反面)
struct FlipEffect: GeometryEffect {
    var angle: Double  // degrees, 0...180
    let axis: (x: CGFloat, y: CGFloat)

    var animatableData: Double {
        get { angle } set { angle = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        var t = CATransform3DIdentity
        t.m34 = -1 / 500   // 透视
        let radians = angle * .pi / 180
        t = CATransform3DRotate(t, radians, axis.x, axis.y, 0)
        let affine = CATransform3DGetAffineTransform(t)   // 兼容 ProjectionTransform
        return ProjectionTransform(t).isIdentity ? .init(affine) : .init(t)
    }
}

2.5 CustomAnimation 协议(iOS 17+):自己写曲线 / 物理

iOS 17 起 Animation 不再是黑盒,可以接管时间映射:

public protocol CustomAnimation: Hashable {
    func animate<V: VectorArithmetic>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V?
    func velocity<V: VectorArithmetic>(value: V, time: TimeInterval, context: AnimationContext<V>) -> V?
    func shouldMerge<V: VectorArithmetic>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool
}
  • animate 返回 nil 表示动画结束
  • velocity 提供给后续 spring 接管初速度(保证打断流畅)
  • shouldMerge 决定新 animation 是否吃掉旧 animation 的 state
  • context.state(PropertyList)可保存中间状态,跨帧共享
// iOS 17+ — 弹簧 + 弹跳衰减(自制)
struct BouncySpring: CustomAnimation {
    let mass: Double = 1
    let stiffness: Double = 180
    let damping: Double = 12

    private struct State<V: VectorArithmetic> { var velocity: V }

    func animate<V>(value: V, time: TimeInterval, context: inout AnimationContext<V>) -> V? where V: VectorArithmetic {
        var state = context.state[State<V>.self] ?? .init(velocity: .zero)
        let dt = 1.0 / 120.0
        var current: V = .zero
        var t = 0.0
        while t < time {
            // F = -k*x - c*v
            var force = value
            force.scale(by: -stiffness)
            var damp = state.velocity
            damp.scale(by: -damping)
            force += damp
            force.scale(by: dt / mass)
            state.velocity += force
            var step = state.velocity
            step.scale(by: dt)
            current += step
            t += dt
        }
        context.state[State<V>.self] = state
        return current.magnitudeSquared < 0.0001 && state.velocity.magnitudeSquared < 0.0001 ? nil : current
    }
}

extension Animation {
    static var anycastBounce: Animation { .init(BouncySpring()) }
}

2.6 KeyframeAnimator(iOS 17+):多 track 关键帧编排

API 形态:

KeyframeAnimator(
    initialValue: AnimationValues,
    trigger: triggerValue
) { values in
    content
        .scaleEffect(values.scale)
        .offset(y: values.yOffset)
        .rotationEffect(.degrees(values.angle))
} keyframes: { _ in
    KeyframeTrack(\.scale)   { LinearKeyframe(1.0, duration: 0.1); SpringKeyframe(1.2, duration: 0.4) }
    KeyframeTrack(\.yOffset) { CubicKeyframe(-30, duration: 0.25); SpringKeyframe(0,   duration: 0.6, spring: .bouncy) }
    KeyframeTrack(\.angle)   { CubicKeyframe(-15, duration: 0.3);  CubicKeyframe(0,    duration: 0.4) }
}

四种 keyframe:

  • LinearKeyframe — 线性插值,segment 起止匀速
  • CubicKeyframe — Catmull-Rom,自动平滑前后切线
  • SpringKeyframe — 用 Spring 物理模型从当前速度过渡
  • MoveKeyframe — 瞬移(不插值),常用于 reset
// iOS 17+ — 完整可编译示例:心跳动画(缩放 + 旋转 + 位移多 track)
struct HeartBeat: View {
    @State private var beats = 0

    struct Values {
        var scale: CGFloat = 1
        var rotation: Angle = .zero
        var yOffset: CGFloat = 0
    }

    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 80))
            .foregroundStyle(.pink)
            .keyframeAnimator(initialValue: Values(), trigger: beats) { content, v in
                content
                    .scaleEffect(v.scale)
                    .rotationEffect(v.rotation)
                    .offset(y: v.yOffset)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.3, duration: 0.18, spring: .snappy)
                    SpringKeyframe(0.95, duration: 0.22, spring: .bouncy)
                    LinearKeyframe(1.0, duration: 0.15)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(-8), duration: 0.18)
                    CubicKeyframe(.degrees(8), duration: 0.18)
                    CubicKeyframe(.zero, duration: 0.15)
                }
                KeyframeTrack(\.yOffset) {
                    LinearKeyframe(-12, duration: 0.18)
                    SpringKeyframe(0, duration: 0.4, spring: .bouncy)
                }
            }
            .onTapGesture { beats += 1 }
    }
}

2.7 PhaseAnimator(iOS 17+):自动循环或 trigger 推进的有限状态机

两种重载:自动循环 phases 序列;trigger 推进。phase 类型只要 Hashable,常用 enum:

// iOS 17+ — 注意力提示无限循环
struct PulseAttention: View {
    enum Phase: CaseIterable { case rest, peak, rest2 }

    var body: some View {
        Image(systemName: "bell.badge.fill")
            .phaseAnimator(Phase.allCases) { content, phase in
                content
                    .scaleEffect(phase == .peak ? 1.25 : 1)
                    .opacity(phase == .peak ? 1 : 0.7)
            } animation: { phase in
                switch phase {
                case .peak:  .easeOut(duration: 0.4)
                case .rest, .rest2: .easeIn(duration: 0.4)
                }
            }
    }
}

// trigger 版(按钮点击推进一次)
struct LikeButton: View {
    @State private var likes = 0
    var body: some View {
        Button { likes += 1 } label: {
            Image(systemName: "heart.fill")
        }
        .phaseAnimator([1.0, 1.4, 0.9, 1.0], trigger: likes) { content, scale in
            content.scaleEffect(scale)
        } animation: { _ in .spring(duration: 0.3, bounce: 0.5) }
    }
}

自定义 enum + RawRepresentable 让 phase 携带语义:

// iOS 17+ — 多状态切换(loading → success → idle)
enum ToastPhase: CaseIterable {
    case hidden, sliding, settled, fading

    var offsetY: CGFloat {
        switch self { case .hidden, .fading: -80; case .sliding: -10; case .settled: 0 }
    }
    var opacity: Double {
        switch self { case .hidden, .fading: 0; default: 1 }
    }
}

struct Toast: View {
    @State private var trigger = false
    var body: some View {
        Text("Saved")
            .padding()
            .background(.regularMaterial, in: .capsule)
            .phaseAnimator(ToastPhase.allCases, trigger: trigger) { content, phase in
                content.offset(y: phase.offsetY).opacity(phase.opacity)
            } animation: { phase in
                switch phase {
                case .sliding:  .spring(duration: 0.45, bounce: 0.4)
                case .fading:   .easeOut(duration: 0.3)
                default:        .linear(duration: 0)
                }
            }
    }
}

2.8 Animatable + GeometryEffect 联合:progress-driven 卡片飞入

// iOS 13+ — progress 0→1 同时驱动位移、缩放、旋转、透视
struct CardFlyIn: GeometryEffect {
    var progress: CGFloat   // 0...1
    var animatableData: CGFloat {
        get { progress } set { progress = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        let p = max(0, min(1, progress))
        let translateY = (1 - p) * 200
        let scale = 0.6 + 0.4 * p
        let angle = (1 - p) * .pi / 6
        var t = CATransform3DIdentity
        t.m34 = -1 / 800
        t = CATransform3DTranslate(t, 0, translateY, 0)
        t = CATransform3DScale(t, scale, scale, 1)
        t = CATransform3DRotate(t, angle, 1, 0, 0)
        return ProjectionTransform(t)
    }
}

2.9 KeyframeTimeline:脱离 SwiftUI 渲染的关键帧采样

KeyframeTimeline<Value> 可以独立创建并任意时间点采样,常用于 Canvas/TimelineView 自绘场景,不依赖 view tree:

// iOS 17+ — 用 KeyframeTimeline 喂给 Canvas
struct ParticleBurst: View {
    let timeline = KeyframeTimeline(initialValue: CGPoint.zero) {
        KeyframeTrack(\.x) {
            CubicKeyframe(120, duration: 0.6)
            CubicKeyframe(0,   duration: 0.6)
        }
        KeyframeTrack(\.y) {
            SpringKeyframe(-200, duration: 0.5, spring: .bouncy)
            SpringKeyframe(0,    duration: 0.7, spring: .smooth)
        }
    }

    var body: some View {
        TimelineView(.animation) { ctx in
            Canvas { gc, size in
                let elapsed = ctx.date.timeIntervalSinceReferenceDate
                    .truncatingRemainder(dividingBy: timeline.duration)
                let pt = timeline.value(time: elapsed)
                gc.fill(Path(ellipseIn: .init(x: size.width/2 + pt.x - 8,
                                              y: size.height/2 + pt.y - 8,
                                              width: 16, height: 16)),
                        with: .color(.orange))
            }
        }
    }
}

timeline.duration 自动算出所有 track 总时长;timeline.value(time:) 返回该时间点合成值。这条路径还能用于 SpriteKitSceneKit 桥接动画,或导出到 metal compute shader。

2.10 iOS 18 / iOS 26 新能力

  • iOS 18SymbolEffect 增加 .replace / .wiggle / .breathe,可视为 phase animator 的官方专用版;MeshGradient 自带可动画顶点(点本身是 Animatable);TextRenderer 协议允许逐字 transform,与 PhaseAnimator 联用做打字与抖动效果。
  • iOS 26Animation 新增 .materialBounce / .glassPop 等 Liquid Glass 专用 spring preset;KeyframeAnimator 支持 repeating: .infinite + autoreverses 直接闭环(之前需要外层 trigger 重新触发);新增 PhaseTimeline,等价于 KeyframeTimeline 但基于 enum phases,可被 TimelineView 单独驱动。
  • WWDC25 Build a SwiftUI app with the new design(Liquid Glass session)演示了 matchedTransitionSource + custom Animation 用于 zoom transition 的物理一致性。

2.11 选型决策表

需求首选 API理由
外部数值(progress、loading 百分比)单纯插值Animatable + 普通 .animation(_, value:)最轻、声明最少
shape / 自定义路径插值Shape.animatableData不会触发 layout,直接 path 重画
shake / 3D / 矩阵变换 / 不希望触发 layoutGeometryEffect纯 compositing,便宜
多个属性同时多关键帧、有时间编排KeyframeAnimator多 track 各自独立曲线
有限状态循环 / 单次推进、状态语义清晰PhaseAnimatorenum 表意,无需手算时间
非标准曲线 / 自实现物理 / 需要打断时保留速度CustomAnimation接管 animate/velocity/shouldMerge
不依赖 view 渲染,自绘 Canvas / MetalKeyframeTimeline纯采样、无 SwiftUI lifecycle

2.12 性能与 invalidation 模型

  • 每帧开销animatableData 的 setter 每帧调用一次(120Hz ProMotion 即 8.3ms 内必须返回)。在 setter 里做 sin/sqrt 没问题,但避免分配(无 Array 创建、无 String 拼接)。
  • body 重算KeyframeAnimatorPhaseAnimator 的 closure 每帧调用,但只重算该闭包内的 view subtree,不会冒泡到父 view。把动画放在叶子节点能最小化 invalidation 半径。
  • GeometryEffect vs offset:前者只走 layer transform;.offset 在 SwiftUI 中也是 transform,但 .position/.frame 会触发 layout pass。shake/flip 必须用 GeometryEffect。
  • Spring 物理求解SpringKeyframe.spring 在内部用 closed-form 解析解(不是逐步积分),比手写欧拉法稳定且快。CustomAnimation 里如果需要积分,不要每次从 t=0 重算(如 2.5 示例那样),应在 context.state 里缓存上一帧 position/velocity,仅推进 time - lastTime
  • Repeating animation 内存repeatForever + 复杂闭包会在 view 重 identity 时残留旧 driver;建议绑到稳定的 id 上。
  • Reduce Motion@Environment(\.accessibilityReduceMotion),在 wave/shake/3D 场景下要降级为 fade。

踩坑速查 / Pitfalls

  • animatableData 必须既有 get 又有 set。漏掉 setter 编译过但不动
  • 多个 animation() modifier 顺序敏感:离 view 越近的越先生效,animation(_, value:) 只对其上方 modifier 生效。
  • GeometryEffect 不影响 hit-testing 区域 —— 翻转 180° 后点击仍在原位,需手动 .contentShape
  • CustomAnimation.animate 返回 nil 才会停;忘记返回 nil 会让动画"假装结束"但 driver 还在跑,CPU 持续占用。
  • PhaseAnimatoranimation: 参数描述的是从该 phase 切到下一个的动画,不是停在该 phase 时的动画。容易写反。
  • KeyframeTrack 的 keyframes 是累加时长,不是绝对时间戳。每个 keyframe 的 duration 是从前一帧到当前帧花的时间。
  • SpringKeyframe 接力前一段速度,所以前段是 LinearKeyframe(... duration: 0) 时弹簧会直接从静止启动 —— 想要"突然弹"应该让前段有真实位移。
  • iOS 17 之前 AnimatableModifier 写法仍可编译但已 deprecated;改用 ViewModifier & Animatable
  • 嵌套 AnimatablePair 超过 3 层时,get/set 解包错位极易写错;用 keyframe 多 track 替代。
  • Anycast 项目内 PlaybackService 时间观察 closure 在 main actor 上做 @Published 写入,若用此值驱动 animatableData,需要 animation(.linear(duration: 0.5), value:) 平滑跳变,否则进度环会"跳格"。

3. Transitions + matchedGeometry 完成

3.1 .transition() 修饰符的本质

.transition() 是 SwiftUI 描述 view 在 插入(insertion)和 移除(removal)时如何动画化的修饰符。它本身不触发动画——动画由外层的 withAnimation.animation() 提供,transition 只声明"用什么样式过渡"。

触发条件本质上是 view tree 的 identity 变化:if/else 分支切换、ForEach 数组增删、optional 解包变化、.id() 修饰符值改变。SwiftUI diff 时如果发现某个 view 从树里消失(或新出现),就会查它身上的 transition 修饰符并执行对应阶段。

// iOS 13+
struct ToastDemo: View {
    @State private var show = false
    var body: some View {
        VStack {
            Button("Toggle") { withAnimation(.spring) { show.toggle() } }
            if show {
                Text("Saved")
                    .padding()
                    .background(.regularMaterial, in: .capsule)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
    }
}

关键点:withAnimation 必须包裹的是触发 identity 变化的状态写入。如果状态变化和 transition view 不在同一个 update cycle 里,transition 会瞬切——这是新人最常见的问题。

3.2 内建 transition 一览

TransitioniOS效果典型用途
.identity13+不动画,直接出现/消失覆盖默认
.opacity13+淡入淡出(默认 transition)通用
.slide13+从左 insertion,到右 removalList row
.move(edge:)13+从指定边滑入/滑出Toast、底部条
.scale13+从 0 缩放到 1icon、勋章
.scale(scale:anchor:)13+指定起始 scale 和锚点从某点弹出
.offset(_:)13+位移变化精确控制位置
.push(from:)17+双向 push(旧的推走、新的进入)步骤切换
.blurReplace17+模糊+缩放+淡化iOS 17 新原生美感
.symbolEffect17+SF Symbol 内部动画icon 变形
// iOS 17+ — push 和 blurReplace 是新增的双向 transition
struct StepCard: View {
    @State private var step = 0
    var body: some View {
        VStack {
            Group {
                if step == 0 { Text("Step 1") }
                else if step == 1 { Text("Step 2") }
                else { Text("Done").bold() }
            }
            .font(.largeTitle)
            .transition(.push(from: .trailing))   // 新内容从右推入,旧的推到左

            Button("Next") {
                withAnimation(.snappy) { step = (step + 1) % 3 }
            }
        }
    }
}

3.3 组合与非对称:combined / asymmetric

.combined(with:) 把多个 transition 叠加(同时进行),.asymmetric(insertion:removal:) 让出现和消失走不同样式——这两个组合起来能覆盖 95% 的常见场景。

// iOS 13+
extension AnyTransition {
    static var dropIn: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .top)
                .combined(with: .opacity)
                .combined(with: .scale(scale: 0.92, anchor: .top)),
            removal: .opacity.combined(with: .scale(scale: 0.98))
        )
    }
}

// 用法
NotificationBanner()
    .transition(.dropIn)

3.4 AnyTransition.modifier(active:identity:) 自定义

iOS 13–16 的自定义 transition 唯一手段:传两个 ViewModifier——active 是"消失/未出现"状态,identity 是"完全显示"状态。SwiftUI 在两者间插值。

// iOS 13+
struct ScaleBlurModifier: ViewModifier {
    let amount: Double
    func body(content: Content) -> some View {
        content
            .scaleEffect(1 - amount * 0.2)
            .blur(radius: amount * 8)
            .opacity(1 - amount)
    }
}

extension AnyTransition {
    static var scaleBlur: AnyTransition {
        .modifier(
            active: ScaleBlurModifier(amount: 1),
            identity: ScaleBlurModifier(amount: 0)
        )
    }
}

3.5 iOS 17+ Transition 协议——拿到 phase

AnyTransition 的限制是只能在 active/identity 两点插值,搞不出"先弹出再回弹"这种多阶段。iOS 17 的 Transition 协议引入 TransitionPhase.willAppear / .identity / .didDisappear),body(content:phase:) 可以根据 phase 给完全不同的修饰:

// iOS 17+
struct BounceTransition: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .scaleEffect(phase.isIdentity ? 1 : 0.3)
            .opacity(phase.isIdentity ? 1 : 0)
            .rotationEffect(.degrees(phase == .willAppear ? -15 : (phase == .didDisappear ? 15 : 0)))
            .blur(radius: phase.isIdentity ? 0 : 6)
    }
}

extension Transition where Self == BounceTransition {
    static var bounce: BounceTransition { BounceTransition() }
}

// 用法
Image(systemName: "checkmark.circle.fill")
    .font(.system(size: 80))
    .transition(.bounce)

参考:WWDC23 Session 10157 "Wind your way through advanced animations in SwiftUI" 详细演示了 Transition 协议的多阶段能力,以及与 PhaseAnimator 的协同。

3.6 ContentTransition——值变化的过渡

.transition()(修饰 view 出现/消失)不同,.contentTransition() 修饰的是同一个 view 内部内容值变化的过渡。最常见的场景是数字滚动、SF Symbol 替换:

// iOS 16+ 数字插值;iOS 17+ symbolEffect 和更丰富的 numeric
struct PriceLabel: View {
    @State private var price: Double = 19.9
    var body: some View {
        VStack(spacing: 24) {
            Text(price, format: .currency(code: "USD"))
                .font(.system(size: 56, weight: .bold, design: .rounded))
                .contentTransition(.numericText(value: price))   // iOS 17+
                .animation(.snappy, value: price)

            Image(systemName: price > 50 ? "flame.fill" : "leaf.fill")
                .font(.system(size: 40))
                .contentTransition(.symbolEffect(.replace))      // iOS 17+

            Stepper("Price", value: $price, in: 0...100, step: 5.5)
        }.padding()
    }
}
ContentTransitioniOS说明
.identity16+无过渡
.opacity16+淡入淡出
.interpolate16+SwiftUI 尝试逐属性插值
.numericText(value:)17+数字滚轮效果,传 value 给方向判断
.symbolEffect(.replace)17+SF Symbol 智能替换

3.7 matchedGeometryEffect——同源 view 之间的"传送门"

matchedGeometryEffect(id:in:properties:anchor:isSource:) 让 SwiftUI 把同一 namespace 下、同 id 的两个 view 的几何属性(frame / position / size)桥接起来。当 view tree 切换时,SwiftUI 不是把 source view "搬运"过去,而是把 target 的几何属性插值到 source 的值,视觉上像传送。

关键参数:

  • id:标识符,必须在 namespace 内唯一且同一时刻只有一个 isSource = true 的 view
  • in:Namespace.ID,由 @Namespace 提供
  • properties.frame(默认)/ .position / .size — 控制哪些属性被匹配
  • anchor:决定如何把目标 view 对齐到几何区域
  • isSource:默认 true。决定谁提供几何"权威",跨 view tree 切换时通常都 true,由是否在场决定真实 source
// iOS 14+ — 经典的 hero animation:cell 展开成详情
struct HeroEpisode: View {
    @Namespace private var ns
    @State private var expanded: Episode?
    let items: [Episode] = .sample

    var body: some View {
        ZStack {
            // 列表态
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 160))], spacing: 16) {
                    ForEach(items) { ep in
                        if expanded?.id != ep.id {
                            EpisodeCard(ep: ep)
                                .matchedGeometryEffect(id: ep.id, in: ns)
                                .onTapGesture {
                                    withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
                                        expanded = ep
                                    }
                                }
                        } else {
                            // 占位,保持布局
                            Color.clear.frame(height: 200)
                        }
                    }
                }.padding()
            }

            // 详情态
            if let ep = expanded {
                EpisodeDetail(ep: ep)
                    .matchedGeometryEffect(id: ep.id, in: ns)
                    .transition(.asymmetric(
                        insertion: .opacity.animation(.linear(duration: 0.001)),
                        removal: .opacity.animation(.linear(duration: 0.2))
                    ))
                    .zIndex(1)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
                            expanded = nil
                        }
                    }
            }
        }
    }
}

这个套路的关键:列表 cell 在 expanded 时换成 Color.clear 占位(保留布局),详情态里给同 id 的 view 加 matchedGeometryEffect——SwiftUI 自动从 cell 的 frame 插值到详情 frame。zIndex(1) 防止动画过程中详情被遮挡。

3.8 iOS 18 NavigationTransition:.zoom + matchedTransitionSource

iOS 18 把 hero animation "官方化"——NavigationStack push 和 sheet/fullScreenCover 都支持 .navigationTransition(.zoom(sourceID:in:))。它内部基于 matchedGeometryEffect 但帮你处理了所有边界情况(zIndex、interactive dismiss、interruption)。

// iOS 18+ — 推荐用法
struct ZoomNavDemo: View {
    @Namespace private var ns
    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 140))]) {
                    ForEach(Episode.sample) { ep in
                        NavigationLink(value: ep) {
                            EpisodeCard(ep: ep)
                                .matchedTransitionSource(id: ep.id, in: ns)
                        }
                    }
                }
            }
            .navigationDestination(for: Episode.self) { ep in
                EpisodeDetail(ep: ep)
                    .navigationTransition(.zoom(sourceID: ep.id, in: ns))
            }
        }
    }
}

同样的 API 用在 sheet 上:

// iOS 18+
struct ZoomSheetDemo: View {
    @Namespace private var ns
    @State private var openedEp: Episode?
    var body: some View {
        ScrollView {
            ForEach(Episode.sample) { ep in
                Button { openedEp = ep } label: {
                    EpisodeCard(ep: ep)
                        .matchedTransitionSource(id: ep.id, in: ns)
                }
            }
        }
        .sheet(item: $openedEp) { ep in
            EpisodeDetail(ep: ep)
                .navigationTransition(.zoom(sourceID: ep.id, in: ns))
        }
    }
}

参考:WWDC24 Session 10145 "Enhance your UI animations and transitions"。Apple 的 official guidance 是 iOS 18+ 优先使用 zoom navigation transition,旧的 matchedGeometryEffect 路线只在需要更精细控制(比如 mid-animation transform)时才用。

3.9 自定义 Transition 协议实例

下面三个是常见的"高级"自定义。都基于 iOS 17+ Transition 协议:

// iOS 17+ — 旋转门效果
struct DoorTransition: Transition {
    var edge: HorizontalEdge = .leading
    func body(content: Content, phase: TransitionPhase) -> some View {
        let angle: Double = phase.isIdentity ? 0 : 90
        content
            .rotation3DEffect(
                .degrees(edge == .leading ? -angle : angle),
                axis: (x: 0, y: 1, z: 0),
                anchor: edge == .leading ? .leading : .trailing,
                perspective: 0.6
            )
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

// iOS 17+ — 3D 翻页(书页效果)
struct PageFlipTransition: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .rotation3DEffect(
                .degrees(phase == .willAppear ? -180 : (phase == .didDisappear ? 180 : 0)),
                axis: (x: 0, y: 1, z: 0),
                anchor: .leading,
                perspective: 0.5
            )
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

// iOS 17+ — 拉伸出现(橡皮筋)
struct StretchTransition: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .scaleEffect(
                x: phase.isIdentity ? 1 : 1.4,
                y: phase.isIdentity ? 1 : 0.3,
                anchor: .bottom
            )
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

3.10 多 view 同时 transition 的协调

当多个 view 在同一帧内被插入/移除时,SwiftUI 默认同时给它们应用 transition。要做错峰效果,最干净的工具是 iOS 17+ 的delay 在 animation 上叠加,或者用 PhaseAnimator 把单个动画拆成多 phase。如果是 ForEach 里多 row 同进同出,.animation(_, value:) 配合 enumerated() 给每行不同 delay 是经典写法:

// iOS 15+
struct StaggeredList: View {
    @State private var visible = false
    let items = (0..<8).map { "Row \($0)" }
    var body: some View {
        VStack(spacing: 8) {
            if visible {
                ForEach(Array(items.enumerated()), id: \.element) { idx, item in
                    Text(item)
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(.regularMaterial, in: .rect(cornerRadius: 12))
                        .transition(.move(edge: .leading).combined(with: .opacity))
                        .animation(
                            .spring(response: 0.5, dampingFraction: 0.8)
                                .delay(Double(idx) * 0.06),
                            value: visible
                        )
                }
            }
            Button("Toggle") { visible.toggle() }
        }
    }
}

3.11 transaction 与 transition 的关系

Transaction 是 SwiftUI 用来携带"本次 update 怎么动画"的容器。.transaction { $0.animation = ... } 可以在 view 链路上覆盖动画曲线——这在你想对单个 view 的 transition 单独换曲线,又不想让外层 withAnimation 影响其他兄弟节点时极其有用。

// iOS 13+
struct LayeredAnim: View {
    @State private var on = false
    var body: some View {
        ZStack {
            if on {
                BackgroundDim()
                    .transition(.opacity)
                    .transaction { $0.animation = .easeInOut(duration: 0.4) }

                Card()
                    .transition(.move(edge: .bottom))
                    .transaction { $0.animation = .spring(response: 0.45, dampingFraction: 0.75) }
            }
            Button("Toggle") { withAnimation { on.toggle() } }
        }
    }
}

iOS 17 还提供 .transaction(value:_:) 让 transaction 只在 value 变化时触发,避免误伤兄弟节点的隐式动画。

3.12 完整 hero animation 范例(matchedGeometryEffect 全套)

// iOS 17+ — 列表 → 详情,cover art 跨 view tree 传送
struct PodcastHero: View {
    @Namespace private var ns
    @State private var openedID: UUID?
    let shows: [Show] = .sample

    var body: some View {
        ZStack {
            // 列表
            ScrollView {
                LazyVStack(spacing: 12) {
                    ForEach(shows) { show in
                        HStack(spacing: 12) {
                            CoverArt(url: show.artwork)
                                .frame(width: 64, height: 64)
                                .matchedGeometryEffect(
                                    id: "cover-\(show.id)",
                                    in: ns,
                                    isSource: openedID != show.id
                                )
                            VStack(alignment: .leading) {
                                Text(show.title)
                                    .matchedGeometryEffect(
                                        id: "title-\(show.id)",
                                        in: ns,
                                        isSource: openedID != show.id
                                    )
                                Text(show.author).foregroundStyle(.secondary)
                            }
                            Spacer()
                        }
                        .padding(.horizontal)
                        .contentShape(.rect)
                        .onTapGesture {
                            withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
                                openedID = show.id
                            }
                        }
                    }
                }
            }
            .opacity(openedID == nil ? 1 : 0.3)

            // 详情
            if let id = openedID, let show = shows.first(where: { $0.id == id }) {
                ScrollView {
                    VStack(spacing: 20) {
                        CoverArt(url: show.artwork)
                            .frame(width: 280, height: 280)
                            .matchedGeometryEffect(id: "cover-\(show.id)", in: ns)

                        Text(show.title)
                            .font(.title.bold())
                            .matchedGeometryEffect(id: "title-\(show.id)", in: ns)

                        Text(show.summary)
                            .padding(.horizontal)
                            .transition(.opacity.animation(.easeIn.delay(0.15)))
                    }.padding(.top, 60)
                }
                .background(.regularMaterial)
                .ignoresSafeArea()
                .zIndex(1)
                .onTapGesture {
                    withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
                        openedID = nil
                    }
                }
            }
        }
    }
}

这里 isSource: openedID != show.id 是关键——同一时刻 namespace 内每个 id 只能有一个 source。当详情打开,列表 cell 让出 source 身份,几何由详情提供。

踩坑速查 / Pitfalls

  • zIndex 必须显式设:动画期间,新插入的 view 默认 zIndex 0,可能被同层 sibling 遮挡。hero animation 的 detail view 务必加 .zIndex(1),否则会看到"穿模"现象。
  • id 唯一性:同一 Namespace 内同一时刻不能有两个 view 同 id 且 isSource = true,否则 SwiftUI 行为未定义(通常表现为闪烁、不动画)。在过渡瞬间用 isSource 控制谁是真 source,或者保证旧 source 已从 view tree 移除。
  • namespace 必须同源@Namespace 在 view 重新创建时会重新生成 ID。如果父 view 因为状态变化被 rebuild,namespace 会变,所有 matchedGeometry 失效。所以 namespace 必须放在生命周期稳定的祖先上。
  • withAnimation 必须包状态写:触发 transition 的 @State 改动必须在 withAnimation { ... } 块里,而不是 transition view 本身被 .animation() 修饰——后者只动画属性变化,不动画 transition。
  • ForEach id 不稳定:用数组下标做 id(ForEach(items.indices))会让插入/删除时所有 row 都"重新认识自己",transition 错乱。永远用 Identifiable 或稳定的值。
  • NavigationStack push 时 matchedGeometryEffect 跨不过去:iOS 17 之前没有官方跨 navigation 的方式;iOS 18 必须用 navigationTransition(.zoom) + matchedTransitionSource,老的 matchedGeometryEffect 在 push 边界会失效。
  • sheet 默认动画会盖掉 transition:sheet/fullScreenCover 自带 presentation 动画。如果你又给 sheet 内容加 .transition(),两者会打架,常见结果是只看到 sheet 默认动画。要么用 .presentationBackgroundInteraction 系列调,要么改用 ZStack + transition 自己撸。
  • Transition 协议的 phase 容易判错.willAppear 是即将出现(动画起点),.didDisappear 是已经消失(动画终点),.identity 是稳定态。判断方向常见错误是用 phase != .identity 当成"动画进行中"——其实进出方向需要分别判断。
  • contentTransition 不动画 view 替换.contentTransition() 修饰的是同一个 view 的 content 变化(比如 Text 内文字、SF Symbol name),如果你换的是不同 view(if/else 切换),它不生效——那种情况要用 .transition()
  • numericText 需要 value.numericText(value:) 必须传当前数值,SwiftUI 用它判断滚动方向(增大向上滚、减小向下滚)。不传 value 退化成普通替换。
  • combined 顺序影响插值.scale.combined(with: .opacity).opacity.combined(with: .scale) 视觉上几乎一样,但当组合复杂修饰(带 anchor 的 scale + offset)时,先后顺序决定 transform 矩阵相乘顺序,结果会差。
  • Anycast 项目特别注意:本项目用 app.openedEpisode = ep 触发 sheet(item:),这是 sheet presentation 路径,不能直接套 matchedGeometryEffect;要做 hero animation 要么改成 ZStack + custom transition,要么 iOS 18 的 .navigationTransition(.zoom)(项目已 iOS 26+ 兼容)。

4. Gestures 全集 完成

4.1 概览:SwiftUI 手势体系的三层抽象

SwiftUI 的手势系统建立在三个层级之上:原子手势(primitive gestures)组合手势(composed gestures)自定义手势(custom Gesture)。所有手势都遵循 Gesture 协议,通过 .gesture(_:).simultaneousGesture(_:).highPriorityGesture(_:) 三个修饰符附加到 view 上。iOS 17+(WWDC 2023, Session 10168 "What's new in SwiftUI")引入了 SpatialTapGestureMagnifyGestureRotateGesture,并把 DragGesture.Valuevelocity 类型从隐式改为公开的 CGSize,使得 spring 接力变得稳定。

核心心智模型:手势的生命周期是 identify → updating → ended/cancelledonChangedupdating 在每一帧持续触发,onEnded 在抬手或取消时触发一次。理解这一点是后续所有冲突解决与状态管理的基础。

4.2 TapGesture:单击 / 双击 / 三击

TapGesture(count:) 通过 count 区分多击。注意:多击手势会延迟单击响应,因为系统必须等待第二次点击的判定窗口(约 250ms)。如果同时挂载单击和双击,二者会冲突——必须用 ExclusiveGesturesimultaneousGesture 显式表达优先级。

// iOS 13+
struct TapDemo: View {
    @State private var hits = 0
    var body: some View {
        Text("Hits: \(hits)")
            .padding(40)
            .background(AnycastColor.sand4)
            .gesture(
                TapGesture(count: 2)
                    .onEnded { hits += 2 }
            )
            .simultaneousGesture(
                TapGesture(count: 1)
                    .onEnded { hits += 1 }
            )
    }
}

简写 .onTapGesture(count:perform:) 等价于 .gesture(TapGesture(count:).onEnded(_:)),但简写不返回 Gesture 实例,因此无法参与组合。需要组合时必须用完整形式。

4.3 SpatialTapGesture(iOS 17+):拿到 hit-tested location

普通 TapGesture 不告诉你点哪了。SpatialTapGesture(WWDC 2023, Session 10160 "Discover Observation in SwiftUI" 周边发布)的 ValueCGPoint,坐标空间默认是被点击 view 的本地坐标,可通过 coordinateSpace: 指定 .global / .local / .named(_:)

// iOS 17+
struct RippleOnTap: View {
    @State private var ripple: CGPoint?
    var body: some View {
        Rectangle()
            .fill(AnycastColor.sand1)
            .overlay {
                if let p = ripple {
                    Circle().fill(AnycastColor.goldAlpha40)
                        .frame(width: 40, height: 40)
                        .position(p)
                }
            }
            .gesture(
                SpatialTapGesture(coordinateSpace: .local)
                    .onEnded { value in
                        ripple = value.location
                    }
            )
    }
}

4.4 LongPressGesture:minimumDuration 与 maximumDistance

两个关键参数:minimumDuration(默认 0.5s)和 maximumDistance(手指允许漂移的距离,默认 10pt)。超过 maximumDistance 时手势取消。onChanged识别成功的瞬间(达到 minimumDuration)触发一次,onEnded 在抬手时触发——这与 DragGesture 的"持续触发 onChanged"完全不同。

// iOS 13+
LongPressGesture(minimumDuration: 0.4, maximumDistance: 12)
    .onChanged { _ in
        UIImpactFeedbackGenerator(style: .light).impactOccurred()
    }
    .onEnded { _ in
        app.openedEpisode = ep   // 长按打开详情
    }

4.5 DragGesture:核心数据 7 件套

DragGesture 的 Value 暴露 7 个字段,全部基于指定的 coordinateSpace

字段类型含义
timeDate事件时间戳
locationCGPoint当前手指位置
startLocationCGPoint手势开始位置
translationCGSizelocation − startLocation
predictedEndTranslationCGSizeUIKit deceleration 模型预测的最终偏移
predictedEndLocationCGPoint预测最终位置
velocity (iOS 17+)CGSize当前速度,单位 pt/s

iOS 16 及以前没有公开 velocity,常见 hack 是用 predictedEndTranslation - translation 反推一个粗略速度。iOS 17 起 Apple 把 velocity 提升为公开 API(WWDC 2023 What's new in SwiftUI),这是 spring 接力能稳定工作的前提

// iOS 17+
DragGesture(minimumDistance: 0, coordinateSpace: .local)
    .onChanged { v in
        print("loc=\(v.location) t=\(v.translation) vel=\(v.velocity)")
    }
    .onEnded { v in
        print("predEnd=\(v.predictedEndTranslation)")
    }

4.6 MagnifyGesture / RotateGesture(iOS 17+)

iOS 17 把 MagnificationGesture 改名为 MagnifyGestureRotationGesture 改名为 RotateGesture,并在 Value 上新增 velocitystartAnchorstartLocation。旧名继续可用但标记 deprecated。

// iOS 17+
struct PinchZoom: View {
    @State private var scale: CGFloat = 1
    @GestureState private var pinch: CGFloat = 1
    var body: some View {
        Image("cover")
            .resizable().scaledToFit()
            .scaleEffect(scale * pinch)
            .gesture(
                MagnifyGesture()
                    .updating($pinch) { value, state, _ in
                        state = value.magnification
                    }
                    .onEnded { value in
                        scale *= value.magnification
                    }
            )
    }
}

4.7 @GestureState vs @State:自动重置的妙用

@GestureState 会在手势 ended/cancelled 时自动恢复初值,因此特别适合"临时位移"。它只能通过 updating(_:body:) 写入,不能直接赋值。@State 则保留最后的值,适合"提交后的最终状态"。

// iOS 13+
struct DragCard: View {
    @State private var offset: CGSize = .zero      // 累积位移
    @GestureState private var drag: CGSize = .zero  // 临时位移
    var body: some View {
        RoundedRectangle(cornerRadius: AnycastRadius.card)
            .fill(AnycastColor.sand4)
            .frame(width: 200, height: 120)
            .offset(x: offset.width + drag.width,
                    y: offset.height + drag.height)
            .gesture(
                DragGesture()
                    .updating($drag) { value, state, _ in
                        state = value.translation       // 自动 reset
                    }
                    .onEnded { value in
                        offset.width += value.translation.width
                        offset.height += value.translation.height
                    }
            )
    }
}

对比三种回调:

  • updating(_:body:):写入 @GestureState,手势结束自动 reset。不能触发普通 @State 的副作用。
  • onChanged:每帧触发,可任意写 @State,但需自己处理"中途取消"的回退。
  • onEnded:抬手/取消时触发一次,常用于"提交"。

4.8 优先级三选一:gesture / simultaneousGesture / highPriorityGesture

修饰符行为典型用例
.gesture(_:)默认优先级,子 view 已识别的手势会"赢"普通 tap/drag
.simultaneousGesture(_:)与子 view 手势并行识别tap 在 ScrollView 上
.highPriorityGesture(_:)父手势抢先,阻断子 view大 cell 想自己处理 tap,盖掉里头的 Button
// iOS 13+ — Cell 内有 Button,整体也想响应 tap
HStack {
    Button("Play") { play() }
    Spacer()
    Text("Episode title")
}
.contentShape(Rectangle())
.highPriorityGesture(
    TapGesture().onEnded { app.openedEpisode = ep }
)
// 注意:这样 Button 不会响应!要让 Button 工作改成 .simultaneousGesture

4.9 组合手势:Simultaneous / Sequence / Exclusive

三种组合器返回新的 Gesture,可继续叠加。运算符语法:g1.simultaneously(with: g2)g1.sequenced(before: g2)g1.exclusively(before: g2)

// iOS 13+ — 长按后才能拖动(典型 reorder)
struct LongPressThenDrag: View {
    @State private var offset: CGSize = .zero
    @GestureState private var dragState: DragState = .inactive
    enum DragState {
        case inactive, pressing, dragging(CGSize)
    }
    var body: some View {
        let press = LongPressGesture(minimumDuration: 0.3)
        let drag = DragGesture()
        let combined = press.sequenced(before: drag)
            .updating($dragState) { value, state, _ in
                switch value {
                case .first(true):
                    state = .pressing
                case .second(true, let drag?):
                    state = .dragging(drag.translation)
                default:
                    state = .inactive
                }
            }
            .onEnded { value in
                if case .second(true, let drag?) = value {
                    offset.width += drag.translation.width
                    offset.height += drag.translation.height
                }
            }
        return Circle().fill(AnycastColor.orangeAlpha60)
            .frame(width: 60, height: 60)
            .offset(x: offset.width, y: offset.height)
            .gesture(combined)
    }
}

SequenceGestureValue 是嵌套枚举 .first(_) | .second(_, _?),必须模式匹配解包。ExclusiveGesture 则是 .first(_) | .second(_),先识别成功的赢。

4.10 ScrollView × DragGesture 的冲突

这是日常最容易踩雷的地方。ScrollView 内部本身有一个 pan 手势用于滚动。如果在它的子 view 上挂 DragGesture()(默认 minimumDistance: 10),系统会在两个手势之间做一次"歧义解析",常见现象是滚不动拖不动

解法:

  • DragGesture(minimumDistance: 0):手势"立即"生效——但会完全吃掉滚动,仅在自定义 drawer / 全屏交互时这么干。
  • .simultaneousGesture(_:):scroll 与你的手势并行识别。
  • iOS 16+ 在 ScrollView 上配 .scrollDisabled(condition) 临时关掉滚动,常配合自定义 pull-to-reveal。
  • iOS 17+ 用 .scrollTargetBehavior(_:) 接管 paging,避免再用 DragGesture 模拟。

项目中已知技术债:pull-to-reveal Clear 用鼠标拖不出(iOS rubber-band 只对真触控响应)。这是 sim 的 mouse 事件不会触发 UIKit overscroll path 的限制,不是手势本身的 bug。

4.11 自定义 Gesture:遵守 Gesture 协议

大部分场景用组合就够了。需要"完全自定义识别逻辑"时,实现 Gesture 协议,把 body 委托给已有的手势组合:

// iOS 13+ — 把"水平 swipe ≥ 80pt 触发回调"封装成可复用 Gesture
struct SwipeAction: Gesture {
    var threshold: CGFloat = 80
    var onLeft: () -> Void
    var onRight: () -> Void
    var body: some Gesture {
        DragGesture(minimumDistance: 20)
            .onEnded { v in
                if v.translation.width > threshold { onRight() }
                else if v.translation.width < -threshold { onLeft() }
            }
    }
}

// 用法
.gesture(SwipeAction(onLeft: { delete() }, onRight: { archive() }))

4.12 完整实战:swipe-to-dismiss + velocity 接力 spring

以 Anycast 的 NowPlaying 全屏为例:下滑超过阈值 速度足够大时关闭,否则 spring 回弹。关键是把 velocity 喂给 .interactiveSpring(initialVelocity:),让动画有"延续感"而不是从零起步。

// iOS 17+
struct NowPlayingSheet: View {
    @EnvironmentObject var app: AppState
    @State private var dragY: CGFloat = 0
    @State private var dismissing = false

    private let dismissDistance: CGFloat = 140
    private let dismissVelocity: CGFloat = 800   // pt/s

    var body: some View {
        VStack { /* ... player UI ... */ }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(AnycastColor.sand1)
            .clipShape(RoundedRectangle(cornerRadius: AnycastRadius.cardLarge))
            .offset(y: max(0, dragY))
            .gesture(
                DragGesture(minimumDistance: 8, coordinateSpace: .local)
                    .onChanged { v in
                        dragY = v.translation.height
                    }
                    .onEnded { v in
                        let dy = v.translation.height
                        let vy = v.velocity.height          // iOS 17+
                        let shouldDismiss =
                            dy > dismissDistance || vy > dismissVelocity

                        if shouldDismiss {
                            withAnimation(.interactiveSpring(
                                response: 0.42,
                                dampingFraction: 0.86,
                                blendDuration: 0
                            )) {
                                dragY = UIScreen.main.bounds.height
                            }
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.42) {
                                app.presentNowPlaying = false
                                dragY = 0
                            }
                        } else {
                            withAnimation(.spring(
                                response: 0.38, dampingFraction: 0.78
                            )) {
                                dragY = 0
                            }
                        }
                    }
            )
    }
}

iOS 17 的 .interactiveSpring 与 iOS 18 的 Animation.spring(.snappy) presets 都接受 spring 物理参数。predictedEndLocation 的物理意义是"如果手指按当前速度滑行直到 UIKit 默认 deceleration 衰减到 0 时的终点"——它适合做 paging snap(判断该不该翻到下一页),不适合直接喂给 spring。

4.13 实战模式合集

Pinch-to-zoom 图片(pan + scale + rotate 联合,iOS 17+)

// iOS 17+
struct PhotoViewer: View {
    @State private var scale: CGFloat = 1
    @State private var offset: CGSize = .zero
    @State private var rotation: Angle = .zero
    @GestureState private var pinch: CGFloat = 1
    @GestureState private var pan: CGSize = .zero
    @GestureState private var spin: Angle = .zero

    var body: some View {
        let pinchG = MagnifyGesture()
            .updating($pinch) { v, s, _ in s = v.magnification }
            .onEnded { v in scale = max(1, scale * v.magnification) }
        let panG = DragGesture()
            .updating($pan) { v, s, _ in s = v.translation }
            .onEnded { v in
                offset.width += v.translation.width
                offset.height += v.translation.height
            }
        let spinG = RotateGesture()
            .updating($spin) { v, s, _ in s = v.rotation }
            .onEnded { v in rotation = rotation + v.rotation }

        return Image("hero")
            .resizable().scaledToFit()
            .scaleEffect(scale * pinch)
            .rotationEffect(rotation + spin)
            .offset(x: offset.width + pan.width,
                    y: offset.height + pan.height)
            .gesture(pinchG.simultaneously(with: spinG)
                     .simultaneously(with: panG))
    }
}

自定义 drawer(高度可拖、释放后 snap)

// iOS 17+
struct Drawer: View {
    @Binding var open: Bool
    @ViewBuilder var content: () -> Content
    @State private var dragY: CGFloat = 0

    var body: some View {
        GeometryReader { geo in
            content()
                .frame(maxWidth: .infinity)
                .frame(height: geo.size.height * 0.6)
                .background(AnycastColor.sand1)
                .clipShape(RoundedRectangle(cornerRadius: AnycastRadius.cardLarge))
                .offset(y: open ? max(0, dragY) : geo.size.height)
                .gesture(
                    DragGesture(minimumDistance: 0)
                        .onChanged { v in dragY = v.translation.height }
                        .onEnded { v in
                            let snapOpen =
                                v.translation.height < 100 && v.velocity.height < 600
                            withAnimation(.spring(response: 0.35,
                                                  dampingFraction: 0.82)) {
                                open = snapOpen
                                dragY = 0
                            }
                        }
                )
        }
    }
}

HoverGesture(iPad / Mac)

.onHover { hovering in ... } 是简写;更细可用 .onContinuousHover { phase in ... }(iOS 16+)拿到 active(CGPoint) / ended。这是 iPad pointer 与 Mac Catalyst 上获得 hover affordance 的唯一途径。

// iOS 16+
.onContinuousHover(coordinateSpace: .local) { phase in
    switch phase {
    case .active(let p): hoverPoint = p
    case .ended: hoverPoint = nil
    }
}

踩坑速查 / Pitfalls

  • Hit testing:透明背景的 view 不参与 hit test。给整个区域加 .contentShape(Rectangle()) 才能在空白处响应手势。Spacer() 撑出的空白默认也吃不到 tap。
  • ScrollView 冲突:在 ScrollView 子 view 上挂 DragGesture() 会和滚动打架。要么 simultaneousGesture,要么 iOS 16+ 用 .scrollDisabled 临时关闭,要么 iOS 17+ 直接换 .scrollTargetBehavior
  • @GestureState 重置时机:是在 下一帧 reset,不是同一帧。如果在 onEnded 里读 @GestureState 拿到的是即将被清掉的值;要保留最终位移必须用 onEndedvalue.translation 写到 @State
  • updating 不能写 @State:编译能过,运行时 SwiftUI 会触发 "Modifying state during view update" 警告甚至循环刷新。状态副作用一律放 onChanged
  • highPriorityGesture 会吃掉子 Button:父 view 的 highPriority tap 会让里层 ButtonNavigationLink 失效;想兼容必须改成 simultaneousGesture 并自己分流。
  • velocity 单位DragGesture.velocity(iOS 17+)是 pt/s,可以直接喂给 .interactiveSpring(initialVelocity:)predictedEndTranslation 是位移,不是速度,别搞混。
  • iOS 16 及以下没有公开 velocity:用 (predictedEndTranslation - translation) / 0.5 近似(UIKit deceleration 假设 0.5s 衰减),但精度差,能升 iOS 17 就升。
  • SpatialTapGesture 默认坐标空间是 local:跨 view 比较位置必须显式 .named(_:).global,否则数字毫无可比性。
  • LongPressGesture 的 onChanged 只触发一次:不要期待它像 DragGesture 那样持续刷新——想做"长按后跟手缩放"必须用 sequenced(before: MagnifyGesture())
  • Simulator 鼠标 ≠ 手指:rubber-band overscroll、3D Touch、双指捏合在 sim 上要么不触发要么需要按住 Option 键。功能验证以真机为准,sim 截图只能验视觉。
  • Anycast/Anycast-sources 镜像:手势相关代码改完别忘了 cpAnycast-sources/,否则下一次清理 build 缓存就回滚了。

5. CoreMotion + Haptics 完成

5.1 CoreMotion 总览:四个数据源 + 一个融合传感器

iOS 设备的运动子系统由 CMMotionManager 统一暴露,背后挂着四类原始/融合数据:

API来源典型用途是否融合
accelerometerData加速度计原始 g 力(含重力)、抖动检测
gyroData陀螺仪原始三轴角速度 (rad/s)
magnetometerData磁力计原始磁场 (μT)、未校准
deviceMotion传感器融合姿态、重力分离、用户加速度、heading是(推荐)

结论:除非做信号处理/校准研究,deviceMotion 几乎是唯一正确选择——CoreMotion 已经做完互补滤波/卡尔曼,把重力从加速度里剥离出来,并保证 attitude 数值稳定。直接吃 raw accelerometer 的代码 99% 都在重新造轮子。参考 WWDC 2012 #524 Understanding Core Motion、WWDC 2017 #704 What's New in CoreMotion

5.2 CMDeviceMotion 字段详解

  • attitude: CMAttitude——roll / pitch / yaw(弧度)+ rotationMatrix + quaternionmultiply(byInverseOf:) 可把姿态归零到任意参考点。
  • gravity: CMAcceleration——单位 g(≈9.81 m/s²),表示当前重力方向在设备坐标系的投影,atan2(gravity.x, gravity.y) 就是设备绕 z 轴的倾斜角。
  • userAcceleration: CMAcceleration——已减去重力的"用户施加的加速度",单位 g。摇一摇/计步基础信号。
  • rotationRate: CMRotationRate——已扣除偏置的角速度(vs raw gyroData)。
  • magneticField: CMCalibratedMagneticField——校准后的磁场 + accuracy 枚举。
  • heading: Double(iOS 11+)——0–360°,相对参考系。需要 reference frame 启用 magnetic/true north 才有意义。
  • sensorLocation(iOS 14+)——区分 iPhone 本机与外接(如 AirPods)传感器。

5.3 Reference Frames:选错北方就全错

Framex 轴是否需磁力计用法
xArbitraryZVertical启动瞬间设备朝向视差/摇杆,无地理需求
xArbitraryCorrectedZVertical同上 + 磁力计校漂长时间运行,需姿态稳定
xMagneticNorthZVertical磁北AR、罗盘
xTrueNorthZVertical真北(需 GPS)是 + Location 权限地图导航、星图

启动前先用 CMMotionManager.availableAttitudeReferenceFrames() 检查;选 true north 时若用户拒绝定位会静默降级到 magnetic north,需要 CMAttitude.referenceFrame 回读确认。

5.4 更新频率与电量

deviceMotionUpdateInterval 单位秒。常用档位:

  • 1.0 / 60 ≈ 0.0167s(60Hz)——UI 视差、滚动联动,绝大多数场景够用,与 ProMotion 之外的屏幕同步。
  • 1.0 / 120(120Hz)——ProMotion (iPhone 13 Pro+) 上游戏/精细 AR;同时记得把 CADisplayLink 也调到 120。
  • 0.1(10Hz)——计步/朝向类,省电。
  • 0.01(100Hz)——动作识别(挥拍、摇晃)。

实测 60Hz deviceMotion 在 iPhone 15 Pro 上约 1–2% 持续 CPU;120Hz 翻倍。后台运行须开 UIBackgroundModes,且仅 fitness 类 entitlement 通过。

5.5 生命周期与权限

  1. Info.plistNSMotionUsageDescription("用于实现倾斜视差与播放器交互")。iOS 13+ 用户可在隐私设置撤回,CMSensorRecorder.isAuthorizedForRecording() / CMMotionActivityManager.authorizationStatus() 可查。
  2. app 进 background 后 deviceMotion 被 OS 自动暂停,回 foreground 不需手动重启(updates 仍在排队),但 reference frame 可能漂移;建议在 scenePhase == .active 时 stop+start 重置。
  3. CMMotionManager 整个 app 应当只持有一个实例——多实例会让底层重复打开传感器,电量翻倍且数据可能不一致。Apple 文档明确警告。

5.6 桥接到 SwiftUI:三种模式对比

模式API优点缺点
Pull (Timer)startDeviceMotionUpdates() + TimerdeviceMotion简单抖动、容易掉帧
PushstartDeviceMotionUpdates(to:withHandler:)系统驱动,时序准handler 在指定 queue,注意 main hop
AsyncStream用 push 包装 AsyncStream结构化并发、自然 cancel需要桥接代码

5.7 标准 ObservableObject 封装(iOS 17+)

import CoreMotion
import SwiftUI

@MainActor
final class MotionStore: ObservableObject {
    @Published var roll: Double = 0
    @Published var pitch: Double = 0
    @Published var yaw: Double = 0
    @Published var gravity: CMAcceleration = .init(x: 0, y: -1, z: 0)

    private let manager = CMMotionManager()
    private let queue = OperationQueue()

    init() {
        queue.name = "com.anycast.motion"
        queue.qualityOfService = .userInteractive
        manager.deviceMotionUpdateInterval = 1.0 / 60.0
    }

    func start() {
        guard manager.isDeviceMotionAvailable, !manager.isDeviceMotionActive else { return }
        manager.startDeviceMotionUpdates(
            using: .xArbitraryCorrectedZVertical,
            to: queue
        ) { [weak self] motion, error in
            guard let self, let m = motion else { return }
            Task { @MainActor in
                self.roll = m.attitude.roll
                self.pitch = m.attitude.pitch
                self.yaw = m.attitude.yaw
                self.gravity = m.gravity
            }
        }
    }

    func stop() { manager.stopDeviceMotionUpdates() }
    deinit { manager.stopDeviceMotionUpdates() }
}

5.8 AsyncStream 现代封装(iOS 17+)

extension CMMotionManager {
    func deviceMotionStream(
        interval: TimeInterval = 1.0 / 60.0,
        frame: CMAttitudeReferenceFrame = .xArbitraryCorrectedZVertical
    ) -> AsyncStream {
        AsyncStream { continuation in
            self.deviceMotionUpdateInterval = interval
            let q = OperationQueue()
            q.qualityOfService = .userInteractive
            self.startDeviceMotionUpdates(using: frame, to: q) { motion, _ in
                if let motion { continuation.yield(motion) }
            }
            continuation.onTermination = { @Sendable _ in
                self.stopDeviceMotionUpdates()
            }
        }
    }
}

struct ParallaxCard: View {
    @State private var offset: CGSize = .zero
    let manager = CMMotionManager()
    var body: some View {
        Image("artwork").resizable().offset(offset)
            .task {
                for await m in manager.deviceMotionStream() {
                    offset = CGSize(width: m.attitude.roll * 18,
                                    height: -m.attitude.pitch * 18)
                }
            }
    }
}

5.9 实战:tilt-driven parallax 卡片(iOS 17+)

struct TiltParallax: View {
    @StateObject private var motion = MotionStore()
    let depth: CGFloat
    let content: Content

    init(depth: CGFloat = 24, @ViewBuilder content: () -> Content) {
        self.depth = depth
        self.content = content()
    }

    var body: some View {
        content
            .rotation3DEffect(
                .radians(motion.pitch * 0.35),
                axis: (x: 1, y: 0, z: 0),
                perspective: 0.5
            )
            .rotation3DEffect(
                .radians(-motion.roll * 0.35),
                axis: (x: 0, y: 1, z: 0),
                perspective: 0.5
            )
            .offset(
                x: CGFloat(motion.roll) * depth,
                y: -CGFloat(motion.pitch) * depth
            )
            .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.7), value: motion.roll)
            .onAppear { motion.start() }
            .onDisappear { motion.stop() }
    }
}

关键点:roll/pitch 已经被 CoreMotion 限制在 ±π,但用户实际持机区间一般在 ±0.3 rad,乘 0.35 后视差幅度 ~6°,舒适。interactiveSpring 把传感器毛刺打平,比直接绑定漂亮得多。

5.10 重力感应小球 + 360 全景

// 重力球:直接用 gravity 投影
let g = motion.gravity
ball.velocity.x += CGFloat(g.x) * 0.6
ball.velocity.y += CGFloat(-g.y) * 0.6  // SwiftUI y 朝下,重力 y 朝上需取反

// 360 全景:用 yaw 驱动横向偏移
let normalized = (motion.yaw + .pi) / (2 * .pi)   // 0..1
panoramaScrollOffset = normalized * panoramaWidth

5.11 AirPods 头部追踪:CMHeadphoneMotionManager(iOS 14+)

import CoreMotion

@MainActor
final class HeadphoneMotion: ObservableObject {
    @Published var headYaw: Double = 0
    private let mgr = CMHeadphoneMotionManager()

    func start() {
        guard mgr.isDeviceMotionAvailable else { return }
        mgr.startDeviceMotionUpdates(to: .main) { [weak self] m, _ in
            guard let m else { return }
            self?.headYaw = m.attitude.yaw
        }
    }
}

iOS 18 起 CMHeadphoneMotionManager 在 Spatial Audio + AirPods Pro/Max/4 上可用,配合 AVAudioEnginerenderingAlgorithm = .HRTFHQ 实现头追沉浸声。注意 AirPods 的传感器与 iPhone 的是独立两个 manager,不会互相影响。

5.12 CMPedometer 简介

import CoreMotion

let pedometer = CMPedometer()
if CMPedometer.isStepCountingAvailable() {
    pedometer.startUpdates(from: .now) { data, _ in
        guard let d = data else { return }
        print("步数:", d.numberOfSteps,
              "距离:", d.distance ?? 0,
              "配速:", d.currentPace ?? 0)
    }
}

需要 NSMotionUsageDescriptionqueryPedometerData(from:to:) 可以查历史 7 天(M 系列协处理器持久缓存)。

5.13 Haptics 全景:三条路

层级APIiOS定位
声明式(首选).sensoryFeedback()17+SwiftUI 状态变化触发
命令式简易UIImpactFeedbackGenerator10+UIKit/老代码
编程式自定义CHHapticEngine13+(A12+)自定义波形/AHAP/音画同步

参考 WWDC 2019 #520 Introducing Core Haptics、WWDC 2023 #10257 Bring rich haptic feedback to your app

5.14 .sensoryFeedback() 全部 case(iOS 17+)

  • 语义类.success / .warning / .error——对应通知反馈三档
  • 选择/导航.selection——picker 翻档、tab 切换
  • 冲击.impact(weight:.light/.medium/.heavy, intensity: 0–1),iOS 17.5+ 加 flexibility:.solid/.soft/.rigid
  • 数值变化.increase / .decrease——slider 加减
  • level.levelChange——音量条之类离散等级
  • 过程.start / .stop——录音/计时器开始结束
  • 对齐.alignment——拖拽对齐到 grid/snap point
  • 轨迹.pathComplete(iOS 17+ 部分版本)——完成手势路径

5.15 .sensoryFeedback trigger 三种写法

// 1. 任何变化都触发
.sensoryFeedback(.selection, trigger: pickerIndex)

// 2. 闭包按 old/new 决定要不要触发,并选择 case
.sensoryFeedback(trigger: scrollOffset) { old, new in
    let snap: CGFloat = 60
    return Int(old / snap) != Int(new / snap) ? .alignment : nil
}

// 3. 条件式:返回 Bool 决定固定 case 是否触发
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.7),
                 trigger: dragLocation) { old, new in
    abs(new.x - old.x) > 80
}

trigger 闭包内不要做重活——它在 main actor,频繁触发会卡渲染。

5.16 CoreHaptics 核心模型

  • CHHapticEngine:单 app 推荐复用一个实例。start() / stop(),监听 resetHandler / stoppedHandler 应对 audio session 中断、内存压力时引擎被 kill。
  • CHHapticPattern:一组 CHHapticEvent + CHHapticParameterCurve
  • CHHapticEvent
    • .hapticTransient——单击型(≤几十 ms)
    • .hapticContinuous——持续型,需 duration
    • 音频同步:.audioContinuous / .audioCustom
  • 关键 CHHapticEventParameter.hapticIntensity(0–1 强度)、.hapticSharpness(0–1,越高越"脆",类比"high pass")、.attackTime / .decayTime / .releaseTime.sustained
  • CHHapticDynamicParameter + CHHapticParameterCurve:在 pattern 播放过程中插值改变 intensity/sharpness。

5.17 CHHapticEngine 引擎封装(iOS 13+)

import CoreHaptics

@MainActor
final class HapticsEngine {
    static let shared = HapticsEngine()
    private var engine: CHHapticEngine?
    private var supportsHaptics: Bool {
        CHHapticEngine.capabilitiesForHardware().supportsHaptics
    }

    func prepare() {
        guard supportsHaptics, engine == nil else { return }
        do {
            let e = try CHHapticEngine()
            e.playsHapticsOnly = true
            e.isAutoShutdownEnabled = true

            e.resetHandler = { [weak self] in
                try? self?.engine?.start()
            }
            e.stoppedHandler = { reason in
                print("Haptic engine stopped:", reason.rawValue)
            }
            try e.start()
            engine = e
        } catch {
            print("Haptic engine error:", error)
        }
    }

    func play(_ pattern: CHHapticPattern) {
        guard let engine else { return }
        do {
            let player = try engine.makePlayer(with: pattern)
            try player.start(atTime: CHHapticTimeImmediate)
        } catch {
            print("Haptic play error:", error)
        }
    }
}

5.18 完整自定义 Pattern:充电式 ramp("长按蓄力")

func chargeUpPattern(duration: TimeInterval = 1.2) throws -> CHHapticPattern {
    let intensityCurve = CHHapticParameterCurve(
        parameterID: .hapticIntensityControl,
        controlPoints: [
            .init(relativeTime: 0,        value: 0.2),
            .init(relativeTime: duration * 0.5, value: 0.6),
            .init(relativeTime: duration,  value: 1.0)
        ],
        relativeTime: 0
    )
    let sharpnessCurve = CHHapticParameterCurve(
        parameterID: .hapticSharpnessControl,
        controlPoints: [
            .init(relativeTime: 0,        value: 0.1),
            .init(relativeTime: duration,  value: 0.9)
        ],
        relativeTime: 0
    )
    let continuous = CHHapticEvent(
        eventType: .hapticContinuous,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4)
        ],
        relativeTime: 0,
        duration: duration
    )
    let click = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
        ],
        relativeTime: duration
    )
    return try CHHapticPattern(
        events: [continuous, click],
        parameterCurves: [intensityCurve, sharpnessCurve]
    )
}

HapticsEngine.shared.prepare()
if let p = try? chargeUpPattern() { HapticsEngine.shared.play(p) }

5.19 AHAP JSON 加载

AHAP(Apple Haptic and Audio Pattern)是 JSON 描述,方便设计师/音效人员独立产出。示例 tap.ahap

// {
//   "Version": 1,
//   "Pattern": [
//     { "Event": { "Time": 0, "EventType":"HapticTransient",
//       "EventParameters": [
//         {"ParameterID":"HapticIntensity","ParameterValue":0.9},
//         {"ParameterID":"HapticSharpness","ParameterValue":0.6}
//       ] } }
//   ]
// }

func playAHAP(named name: String) throws {
    guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else { return }
    try HapticsEngine.shared.engine?.playPattern(from: url)
}

AHAP 还可以同时声明 AudioCustom 事件,引用 bundle 内 wav,把震动与音频采样级同步——这是 Taptic Engine 区别于一般马达的关键能力。

5.20 与音频同步:CHHapticEngine 同步播放

let resID = try engine.registerAudioResource(
    Bundle.main.url(forResource: "click", withExtension: "wav")!
)
let audio = CHHapticEvent(audioResourceID: resID, parameters: [], relativeTime: 0)
let haptic = CHHapticEvent(eventType: .hapticTransient,
                           parameters: [], relativeTime: 0)
let pattern = try CHHapticPattern(events: [audio, haptic], parameters: [])
try engine.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate)

5.21 UIKit 路线(仍受支持)

let n = UINotificationFeedbackGenerator()
n.prepare()
n.notificationOccurred(.success)

let s = UISelectionFeedbackGenerator()
s.prepare()
s.selectionChanged()

let i = UIImpactFeedbackGenerator(style: .medium)
i.prepare()
i.impactOccurred(intensity: 0.6)

prepare() 提前唤醒 Taptic Engine,把延迟从 ~150ms 压到 ~10ms,长按场景务必调用。

5.22 实战:播放器 scrubber 轻触觉 + 对齐震动

struct ScrubSlider: View {
    @State private var progress: Double = 0
    @State private var lastTickBucket: Int = 0
    var body: some View {
        GeometryReader { geo in
            Capsule().fill(.tertiary)
                .overlay(alignment: .leading) {
                    Capsule().fill(.tint)
                        .frame(width: geo.size.width * progress)
                }
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { v in
                        progress = min(max(0, v.location.x / geo.size.width), 1)
                    }
                )
        }
        .frame(height: 6)
        .sensoryFeedback(trigger: progress) { old, new in
            let bucket = Int(new * 30)
            return bucket != Int(old * 30) ? .selection : nil
        }
        .sensoryFeedback(.alignment, trigger: progress) { _, new in
            [0.0, 0.5, 1.0].contains { abs($0 - new) < 0.005 }
        }
    }
}

5.23 实战:拖卡片对齐到 snap 点(结合 CoreMotion 的 gravity 偏置)

struct SnappingCard: View {
    @StateObject private var motion = MotionStore()
    @State private var offset: CGSize = .zero
    let snaps: [CGFloat] = [-120, 0, 120]

    var body: some View {
        RoundedRectangle(cornerRadius: 24).fill(.regularMaterial)
            .frame(width: 220, height: 320)
            .offset(x: offset.width + CGFloat(motion.gravity.x) * 6, y: offset.height)
            .gesture(
                DragGesture()
                    .onChanged { offset = $0.translation }
                    .onEnded { _ in
                        let nearest = snaps.min(by: { abs($0 - offset.width) < abs($1 - offset.width) }) ?? 0
                        withAnimation(.spring) { offset = CGSize(width: nearest, height: 0) }
                    }
            )
            .sensoryFeedback(.alignment, trigger: offset.width) { _, new in
                snaps.contains { abs($0 - new) < 2 }
            }
            .sensoryFeedback(.impact(weight: .light, intensity: 0.5),
                             trigger: offset == .zero)
            .onAppear { motion.start() }
    }
}

踩坑速查 / Pitfalls

  • 权限NSMotionUsageDescription 必填,否则 iOS 14+ 直接 crash;CoreHaptics 不需要权限但需检查 capabilitiesForHardware().supportsHaptics(iPad、iPhone 7 之前不支持)。
  • 单例CMMotionManager 整个 app 只允许一个;CHHapticEngine 也建议单例复用,避免 audio session 抢占。
  • main actorstartDeviceMotionUpdates(to:) 的 handler 跑在你给的 queue 上,禁止直接修改 @Published——必须 Task { @MainActor in ... }OperationQueue.main,否则编译警告 + 数据竞争。
  • 引擎被 reset:来电、Siri、长时间 idle 都会触发 resetHandler,必须在里面 try engine.start(),否则之后所有 play 静默失败。
  • 电量:deviceMotion 60Hz ≈ 1–2% CPU,120Hz 翻倍;离开页面务必 stop()onDisappear 不够(sheet 盖住不会触发),用 scenePhase 双保险。
  • 低电量模式:用户开 Low Power Mode 时 Taptic Engine 仍工作但部分高频 sharpness 会被压缩,自定义 pattern 设计要在 0.4–0.8 sharpness 区间为主。
  • 静音开关:CoreHaptics 默认受静音开关影响(与 UIKit generator 一致);但如果 pattern 里包含 audioCustom,audio 部分会被静音,单独震动正常——这是常见错觉来源。
  • simulator:simulator 不出震动也不出 deviceMotion 真实数据(可手动模拟),所有触觉/姿态调试必须真机。
  • reference frame 漂移xArbitraryZVertical 长时间运行 yaw 会缓慢漂;改用 xArbitraryCorrectedZVertical(要磁力计可用)。
  • .sensoryFeedback 节流:trigger 每帧变化(如手势)时,闭包返回 nil 才能不触发;返回非 nil 但内容相同也会震,自己用 bucket/阈值过滤。
  • AirPods MotionCMHeadphoneMotionManager 与机身 manager 互不影响,但同一时间只允许一个 app 接收,被 FaceTime/电话占用时 isDeviceMotionActive 仍 true 但 handler 不再触发。

6. Metal / Shader 集成 完成

6.1 SwiftUI Shader 三大入口概览

iOS 17 起,SwiftUI 通过三个 ViewModifier 直接把 Metal Shading Language(MSL)函数挂到任意 View 的渲染管线上,无需 MTKView、无需 CADisplayLink、无需自己写 RenderPass。这套 API 在 WWDC23 Session 10115 "Wonders of shader effects in SwiftUI" 中首次亮相,本质是把 SwiftUI 的离屏纹理交给一段 fragment-style 的 MSL 函数处理后再合成到屏幕。

Modifier用途MSL 输入MSL 输出
.colorEffect(_:isEnabled:)逐像素重新着色,不改位置float2 position, half4 colorhalf4
.distortionEffect(_:maxSampleOffset:isEnabled:)UV 重映射,移动像素float2 positionfloat2(采样源坐标)
.layerEffect(_:maxSampleOffset:isEnabled:)访问整张已渲染 layer,可任意采样float2 position, SwiftUI::Layer layerhalf4

三者代价递增:colorEffect 不需要 SwiftUI 给你纹理(每像素只看自己的颜色),最便宜;distortionEffect 输出新 UV、由 SwiftUI 用 linear sampler 取颜色,中等;layerEffect 把整张 view 离屏栅格化为 SwiftUI::Layer 后传入,可在 shader 内做模糊、卷积、镜像、毛玻璃,最贵。

6.2 Shader / ShaderFunction / ShaderLibrary

Shader 是「函数 + 参数列表」的结构体,由 ShaderFunction(指向 .metal 里某个具名函数)和一组 Shader.Argument 组成。ShaderLibrary.default 暴露 main bundle 编译进去的所有 MSL 函数,dynamicMember 让你可以直接 ShaderLibrary.default.myShader 拿到 ShaderFunction;动态函数名时用 library[dynamicMember:]library[function: ShaderFunction(name:library:)]

import SwiftUI

struct GoldTint: View {
    var body: some View {
        Image("episode-art")
            .resizable()
            .scaledToFit()
            .colorEffect(
                ShaderLibrary.default.goldTint(
                    .float(0.85),
                    .color(AnycastColor.goldAlpha60)
                )
            )
    }
}
// AnycastShaders.metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]] half4 goldTint(float2 position, half4 color,
                                float strength, half4 tint) {
    half3 mixed = mix(color.rgb, tint.rgb, half(strength) * color.a);
    return half4(mixed, color.a);
}

关键属性 [[ stitchable ]] 是必须的——它告诉 Metal 编译器把这函数纳入 SwiftUI 在运行时拼接出的渲染管线(function stitching)。没有这个标记,ShaderLibrary 找不到符号,运行期会静默 fallback 到原图。

6.3 Shader.Argument 全谱

SwiftMSL用途
.float(0.5)float标量(time、strength)
.float2(.init(x,y))float2UV、中心点
.float3 / .float4同名向量(颜色除外)
.floatArray([Float])device const float*直方图、波形数组
.color(Color)half4SwiftUI 颜色,自动转 sRGB→Metal
.image(Image)texture2d<half>额外纹理(mask/lookup)
.data(Data)device const uint8_t*任意二进制 buffer
.boundingRectfloat4 (x,y,w,h)view 自身 bounds,做 normalize
语义注意.color 在 Swift 侧是 sRGB(display P3 在 iOS 17+),到 MSL 已经是 display-referredhalf4。如果你又在 shader 里做 pow(color.rgb, 2.2) 等于二次 gamma 校正,画面会发暗。

6.4 时间动画:TimelineView 注入 time

SwiftUI 没有「shader uniform 自动 tick」机制,要自己用 TimelineView(.animation) 每帧把 Date 转成 Float 喂进去。.animation 默认 60fps(ProMotion 设备 120fps),按 schedule 调整。

struct ShimmerCard: View {
    let start = Date()
    var body: some View {
        TimelineView(.animation) { ctx in
            let t = Float(ctx.date.timeIntervalSince(start))
            AnycastColor.sand4
                .frame(height: 120)
                .clipShape(RoundedRectangle(cornerRadius: AnycastRadius.card))
                .colorEffect(
                    ShaderLibrary.default.shimmer(
                        .float(t),
                        .boundingRect
                    )
                )
        }
    }
}
[[ stitchable ]] half4 shimmer(float2 pos, half4 color,
                               float time, float4 bounds) {
    float u = (pos.x - bounds.x) / bounds.z;
    float wave = 0.5 + 0.5 * sin((u - time * 0.6) * 6.2831);
    half3 sheen = half3(1.0, 0.92, 0.78);
    return half4(mix(color.rgb, sheen, half(wave) * 0.35h), color.a);
}

6.5 完整示例:Ripple Distortion(点击涟漪)

这是最经典的 distortionEffect 用例:用户点击一个点,从中心向外发出一圈正弦涟漪,几百毫秒后衰减消失。

import SwiftUI

struct Ripple: ViewModifier {
    var origin: CGPoint
    var elapsedTime: TimeInterval

    let duration: TimeInterval = 1.2
    let amplitude: Float = 12
    let frequency: Float = 18
    let decay: Float = 4
    let speed: Float = 800

    func body(content: Content) -> some View {
        let shader = ShaderLibrary.default.ripple(
            .float2(.init(x: origin.x, y: origin.y)),
            .float(Float(elapsedTime)),
            .float(amplitude),
            .float(frequency),
            .float(decay),
            .float(speed)
        )
        content.distortionEffect(
            shader,
            maxSampleOffset: CGSize(width: CGFloat(amplitude),
                                    height: CGFloat(amplitude)),
            isEnabled: elapsedTime < duration
        )
    }
}

struct RippleContainer<Content: View>: View {
    @ViewBuilder var content: () -> Content
    @State private var origin: CGPoint = .zero
    @State private var trigger = 0

    var body: some View {
        content()
            .onTapGesture { loc in
                origin = loc
                trigger += 1
            }
            .modifier(RippleEffectModifier(origin: origin, trigger: trigger))
    }
}

private struct RippleEffectModifier: ViewModifier {
    var origin: CGPoint
    var trigger: Int

    func body(content: Content) -> some View {
        content.keyframeAnimator(
            initialValue: 0.0,
            trigger: trigger
        ) { view, elapsed in
            view.modifier(Ripple(origin: origin, elapsedTime: elapsed))
        } keyframes: { _ in
            MoveKeyframe(0)
            LinearKeyframe(1.2, duration: 1.2)
        }
    }
}
// Ripple.metal
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]] float2 ripple(float2 position,
                               float2 origin,
                               float time,
                               float amplitude,
                               float frequency,
                               float decay,
                               float speed) {
    float2 d = position - origin;
    float r = length(d);

    float front = time * speed;
    float delta = r - front;

    float wave = sin(frequency * delta * 0.05) *
                 exp(-decay * time) *
                 exp(-abs(delta) * 0.01);

    float2 dir = r > 0.0001 ? d / r : float2(0);
    return position + dir * wave * amplitude;
}

调用:RippleContainer { Image("artwork").resizable().scaledToFit() },点哪儿哪儿涟漪。maxSampleOffset 必须给出 ≥ amplitude 的范围,否则边缘像素采样越界会被 clamp 成黑边——SwiftUI 用这个值决定离屏纹理的 padding。

6.6 layerEffect:访问整张 layer 做卷积

SwiftUI::Layer 提供 sample(float2),等同于 GLSL 的 texture2D。它已经过 SwiftUI 的离屏栅格化,所以你拿到的是「这一帧 view 的最终纹理」,可以做高斯模糊、菱形抖动、油画、glitch。

struct ChromaticAberration: ViewModifier {
    var amount: Float
    func body(content: Content) -> some View {
        content.layerEffect(
            ShaderLibrary.default.chromaticAberration(.float(amount)),
            maxSampleOffset: CGSize(width: CGFloat(amount),
                                    height: CGFloat(amount))
        )
    }
}
[[ stitchable ]] half4 chromaticAberration(float2 pos,
                                           SwiftUI::Layer layer,
                                           float amount) {
    half r = layer.sample(pos + float2( amount, 0)).r;
    half g = layer.sample(pos).g;
    half b = layer.sample(pos + float2(-amount, 0)).b;
    half a = layer.sample(pos).a;
    return half4(r, g, b, a);
}

6.7 噪声 / 颗粒 / Vignette(colorEffect)

colorEffect 因为不需要重采样、SwiftUI 不需要离屏,是性能最低的入口。Anycast 卡片的 sand grain 颗粒就用这个:

extension View {
    func sandGrain(intensity: Float = 0.04, time: Float) -> some View {
        colorEffect(
            ShaderLibrary.default.filmGrain(
                .float(intensity),
                .float(time),
                .boundingRect
            )
        )
    }
}
inline float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

[[ stitchable ]] half4 filmGrain(float2 pos, half4 color,
                                 float intensity, float time, float4 bounds) {
    float2 uv = (pos - bounds.xy) / bounds.zw;
    float n  = hash21(uv * 800.0 + time * 60.0) - 0.5;
    half3 grained = color.rgb + half(n * intensity);
    float2 c = uv - 0.5;
    float v = 1.0 - dot(c, c) * 0.6;
    return half4(grained * half(v), color.a);
}

6.8 Shader 与 Animatable / 物理插值

Shader 本身不是 Animatable。要让 amplitude / hue / threshold 等参数被 SwiftUI 动画系统插值,把外层 ViewModifier 标记为 Animatable,由 SwiftUI 每帧重建 Shader:

struct PulseHue: ViewModifier, Animatable {
    var hue: Double
    var animatableData: Double {
        get { hue } set { hue = newValue }
    }
    func body(content: Content) -> some View {
        content.colorEffect(
            ShaderLibrary.default.hueShift(.float(Float(hue)))
        )
    }
}

.modifier(PulseHue(hue: isPlaying ? 0.15 : 0))
.animation(.easeInOut(duration: 1.2).repeatForever(), value: isPlaying)

这样每一帧 SwiftUI 都拿到一个新的 hue,重建 Shader 实例。Shader 结构体是 cheap 的——它只是参数包,真正的 GPU pipeline 状态被 Metal 缓存。

6.9 MetalKit 路线(MTKView via UIViewRepresentable)

什么时候离开 SwiftUI shader API、改用 MTKView?

  • 需要多 pass 渲染(feedback、流体模拟、GPU 粒子)
  • 需要 compute kernel + draw 混合
  • 需要自定义 vertex shader(SwiftUI 只给 fragment-style)
  • 帧率严格保 120fps,不能容忍 SwiftUI 离屏带来的合成开销
  • 需要直接读 CVMetalTexture(视频纹理)
struct FluidView: UIViewRepresentable {
    func makeUIView(context: Context) -> MTKView {
        let v = MTKView()
        v.device = MTLCreateSystemDefaultDevice()
        v.colorPixelFormat = .bgra8Unorm_srgb
        v.framebufferOnly = false
        v.delegate = context.coordinator
        v.preferredFramesPerSecond = 120
        return v
    }
    func updateUIView(_ uiView: MTKView, context: Context) {}
    func makeCoordinator() -> Renderer { Renderer() }
}

性能对比:SwiftUI shader 的 overhead 主要来自 distortion/layerEffect 的离屏栅格化(一张 view 走一次 render pass 才能交给 shader)。简单 colorEffect 实测和直接 Metal pipeline 几乎一致。复杂多 pass 场景,MTKView 仍然完胜。

6.10 Compute Shader 桥接

SwiftUI 三大入口都是 fragment-style,没法直接挂 kernel 函数。两条路:

  1. 把 compute pass 包进 CIFilterCIKernel(functionName:fromMetalLibraryData:)),再用 Image(uiImage:) 显示
  2. 离屏跑 compute → 写到 MTLTexture → 包成 CIImage / UIImage → SwiftUI Image

6.11 性能:每帧重建 Shader 与 maxSampleOffset

TimelineView(.animation) 每帧 invalidate 整个子树。Shader 实例本身是值类型——重建无压力,但若每帧 view body 还做了昂贵的视图重组,会在 main thread 上掉帧。建议把 Shader 局部限定,外层包 .drawingGroup() 让 Metal 一次性合成(避免合成层数过多)。

maxSampleOffset 决定 SwiftUI 给你的离屏纹理 padding:值越大,离屏纹理越大,带宽越高。给个比真实最大偏移略大的 ceil 即可,不要无脑写 CGSize(width: 100, height: 100)

6.12 iOS 18 / 26 更新

  • iOS 18:Shader API 稳定,新增对 Color 在 P3 / extended sRGB 下的更准确转换;SwiftUI::Layer 支持 sample() 的 mipmap 提示
  • iOS 26(Liquid Glass):Apple 重写 Material 系统,.glassEffect() 内部就是一组 layerEffect + Gaussian + 边缘光,但 API 不暴露 shader 给 user code;用户自定义 shader 仍走 iOS 17 三入口
  • iOS 26 引入 MeshGradient,部分原本要 shader 实现的渐变可以直接用 mesh 完成,性能更好

6.13 调试技巧

  • shader 写错(如缺 [[ stitchable ]]),SwiftUI 不报错、不闪退,画面静默 fallback 为原图——养成第一次接入就在 shader 里返回 half4(1,0,1,1) 验通路的习惯
  • Xcode → Product → Scheme → Run → Options → 勾选 Metal API Validation + GPU Frame Capture: Metal,可在 SwiftUI render pass 之间抓帧
  • Simulator 在 Apple Silicon Mac 用真 GPU,行为接近真机;Intel Mac 模拟器走软栅格,复杂 shader 可能黑屏——只信真机 / Apple Silicon sim
踩坑速查 / Pitfalls
  • 忘加 [[ stitchable ]]:shader 静默 no-op,画面是原图。任何新 shader 第一步先输出 magenta 验通路
  • 函数名拼错ShaderLibrary.default.ripple 找不到 ripple,同样静默 fallback
  • 参数顺序错位:MSL 签名前两个固定(position [+ color/layer]),之后必须严格按 Swift 侧 .float / .color 出现顺序
  • maxSampleOffset 太小:distortion / layer effect 边缘出黑边或被 clamp,按最大偏移 ceil 给
  • sRGB 二次 gamma.color 进 shader 已经是 display-referred,别再 pow(c, 2.2)
  • 每帧 TimelineView 重建上层 view:把 TimelineView 放在尽可能内层,避免触发整页重组
  • colorEffect 想读邻居像素:做不到——colorEffect 输入只有自己这个像素的颜色。要读邻居用 layerEffect
  • Image 类参数频繁变化.image(Image) 每次新建 Image 实例可能触发纹理重传,缓存住 Image 引用
  • Animatable 不生效:必须把 animatableData 暴露在 ViewModifier 上,Shader 自己不参与插值
  • Intel Mac Simulator 黑屏:换 Apple Silicon 或真机;本项目专用 iPhone 17 Pro sim 没问题
  • Liquid Glass 与自定义 shader 叠加:把 .glassEffect() 放在 shader 之上,否则 shader 输出会被 glass 重新模糊覆盖
  • shader 改完没生效:.metal 文件加进 target 但 build phase 是 "Sources" 而不是 "Compile Sources",Xcode 不重编 metallib,需要 clean build

7. Canvas + TimelineView 完成

7.1 Canvas 入门:完整签名与三大要素

Canvas 是 iOS 15 引入的低层级绘图视图,绕过 SwiftUI 的视图树直接调用 Core Graphics,性能远胜 ZStack 堆叠几百个 Shape。完整签名(iOS 15+):

// iOS 15+
public init(
    opaque: Bool = false,
    colorMode: ColorRenderingMode = .nonLinear,
    rendersAsynchronously: Bool = false,
    renderer: @escaping (inout GraphicsContext, CGSize) -> Void,
    @ViewBuilder symbols: () -> Symbols
)
参数含义 / 何时改
opaquetrue 时跳过 alpha 合成,背景必须自己填满。深色全屏粒子背景设为 true,节省 ~10% GPU。
colorMode.nonLinear(sRGB,默认)/ .linear(线性混合,做 HDR 或正确叠加发光时用)/ .extendedLinear(HDR 显示器)。
rendersAsynchronouslytrue 时把 renderer 闭包丢到后台线程跑,主线程只 commit。代价:闭包内不能捕获 @MainActor 状态。复杂粒子系统应开。
renderer每帧调用,inout GraphicsContext 是绘图状态,CGSize 是 Canvas 当前尺寸。
symbols预先声明的 SwiftUI 视图集合,每个用 .tag(_) 标识,renderer 内通过 context.resolveSymbol(id:) 取出绘制(详见 7.4)。

7.2 GraphicsContext:可变绘图状态

GraphicsContext值类型(struct),所有变换都是状态拷贝——这是它能像栈一样压入/弹出 transform 的关键:

// iOS 15+ — GraphicsContext 状态是值语义
Canvas { context, size in
    var inner = context              // 拷贝一份
    inner.translateBy(x: 100, y: 100)
    inner.rotate(by: .degrees(45))
    inner.fill(Path(CGRect(x: -20, y: -20, width: 40, height: 40)),
               with: .color(.orange))
    // 出闭包后 inner 释放,外层 context 不受影响
    context.fill(Path(CGRect(x: 0, y: 0, width: 10, height: 10)),
                 with: .color(.red))
}

7.2.1 几何绘制 API

  • stroke(_ path: Path, with: Shading, lineWidth: CGFloat = 1) — 描边
  • fill(_ path: Path, with: Shading, style: FillStyle = FillStyle()) — 填充
  • draw(_ image: Image, in: CGRect) / draw(_ image: Image, at: CGPoint, anchor: UnitPoint = .center)
  • draw(_ text: Text, in: CGRect) / draw(_ text: Text, at: CGPoint)
  • draw(_ symbol: ResolvedSymbol, at:) / draw(_ symbol: ResolvedSymbol, in:)
  • resolve(_ image: Image) / resolve(_ text: Text) / resolveSymbol(id: AnyHashable) — 把 SwiftUI 视图烘焙成可复用的 Resolved*,避免每帧重排版

核心优化Text 排版很贵。如果文字内容不变,每帧调 context.draw(Text("...")) 会重新做一次 typography 排版;改用 let resolved = context.resolve(Text("...")) 缓存。

7.2.2 Shading:填充/描边的颜色源

// iOS 15+
.color(.orange)
.color(in: .sRGB, red: 1, green: 0.5, blue: 0)
.linearGradient(Gradient(colors: [.red, .yellow]),
                startPoint: .zero, endPoint: CGPoint(x: 100, y: 100))
.radialGradient(_, center:, startRadius:, endRadius:)
.conicGradient(_, center:, angle:)
.tiledImage(_ image: Image, origin: .zero, sourceRect: nil, scale: 1)
.style(_ style: any ShapeStyle)

7.2.3 状态变换

// iOS 15+
context.translateBy(x: CGFloat, y: CGFloat)
context.scaleBy(x: CGFloat, y: CGFloat)
context.rotate(by: Angle)
context.concatenate(_ matrix: CGAffineTransform)
context.transform = CGAffineTransform.identity

context.clip(to path: Path, style: FillStyle = .init(), options: ClipOptions = [])
context.clipToLayer(opacity: 1, options: [], content: { ctx in /* ... */ })

context.opacity = 0.5
context.blendMode = .screen
context.addFilter(.blur(radius: 8))

7.2.4 GraphicsContext.Filter 全集

Filter用途
.blur(radius:options:)高斯模糊
.shadow(color:radius:x:y:blendMode:options:)阴影,可设 .shadowAbove / .shadowOnly / .invertsAlpha
.colorMatrix(_)4×5 色彩矩阵
.hueRotation(_)色相旋转
.saturation(_)饱和度
.brightness(_) / .contrast(_)亮度/对比度
.colorMultiply(_)每像素与给定颜色相乘
.colorInvert(amount:)反色
.luminanceToAlpha把亮度转成 alpha(做发光蒙版利器)
.alphaThreshold(min:max:color:)alpha 阈值,做"金属液体球"融合效果
.projectionTransform(_)3D 投影

Filter 的作用域addFilter 只影响 之后 同一个 GraphicsContext 上的绘制。要把 filter 限定在一组绘制内,用 drawLayer

// iOS 15+ — 局部 filter,融合粒子做"金属球"
Canvas { context, size in
    context.drawLayer { layer in
        layer.addFilter(.alphaThreshold(min: 0.5, color: .orange))
        layer.addFilter(.blur(radius: 12))
        for p in particles {
            let path = Path(ellipseIn: CGRect(x: p.x - 20, y: p.y - 20,
                                              width: 40, height: 40))
            layer.fill(path, with: .color(.white))
        }
    }
}

7.3 Resolved* 与 symbols ViewBuilder

symbols: 闭包让你在 Canvas 外把 SwiftUI 视图(图标、按钮、复杂渐变)作为"模版"声明出来,每个用 .tag(_) 标识。renderer 内 context.resolveSymbol(id:) 拿到 ResolvedSymbol,可重复绘制——比每帧 resolve(Image) 快得多。

// iOS 15+ — 用 symbols 让 Canvas 高效绘制带 SwiftUI 修饰的视图
struct EpisodeArtBurst: View {
    let positions: [CGPoint]
    var body: some View {
        Canvas { ctx, size in
            guard let art = ctx.resolveSymbol(id: 0) else { return }
            for p in positions {
                ctx.draw(art, at: p, anchor: .center)
            }
        } symbols: {
            Image(systemName: "waveform.circle.fill")
                .symbolRenderingMode(.palette)
                .foregroundStyle(AnycastColor.gold9, AnycastColor.sand4)
                .font(.system(size: 32))
                .tag(0)
        }
    }
}

7.4 TimelineView:时间驱动的视图刷新

TimelineView(iOS 15+)订阅一个 TimelineSchedule,按 schedule 给定的 Date 序列重建 body。它本身不做动画,只是"在这个时刻请你刷新一次"。

// iOS 15+
TimelineView(_ schedule: some TimelineSchedule) { context in
    // context.date — 本帧应当渲染的时刻
    // context.cadence — .live / .seconds / .minutes
}

内置 schedule

Schedule触发频率典型场景
.everyMinute每分钟整点时钟显示
.periodic(from: Date, by: TimeInterval)固定间隔倒计时、轮询
.explicit(_)显式日期序列动画关键帧
.animation(minimumInterval:paused:)display link 频率(60/120Hz)每帧重绘的 Canvas / 粒子

cadence:iOS 在低电量 / 后台时会把 cadence 降为 .seconds.minutes.animation schedule 也不例外。

7.5 实战:完整的粒子系统(烟花 / 庆祝订阅)

// iOS 17+(用 Observation;iOS 15-16 改 ObservableObject)
import SwiftUI
import Observation

struct Particle: Identifiable {
    let id = UUID()
    var position: CGPoint
    var velocity: CGVector
    var birth: TimeInterval
    var lifespan: TimeInterval
    var hue: Double
    var size: CGFloat
}

@Observable
final class FireworksModel {
    var particles: [Particle] = []
    private var lastEmit: TimeInterval = 0

    func tick(now: TimeInterval, bounds: CGSize) {
        particles.removeAll { now - $0.birth > $0.lifespan }
        let gravity = CGVector(dx: 0, dy: 220)
        for i in particles.indices {
            let dt: TimeInterval = 1.0 / 60.0
            particles[i].velocity.dy += gravity.dy * dt
            particles[i].position.x  += particles[i].velocity.dx * dt
            particles[i].position.y  += particles[i].velocity.dy * dt
        }
        if now - lastEmit > 0.6 {
            lastEmit = now
            burst(at: CGPoint(x: .random(in: 60...bounds.width - 60),
                              y: .random(in: 100...bounds.height - 200)),
                  now: now)
        }
    }

    private func burst(at p: CGPoint, now: TimeInterval) {
        let count = 60
        let baseHue = Double.random(in: 0...1)
        for i in 0..<count {
            let angle = (Double(i) / Double(count)) * .pi * 2
            let speed = Double.random(in: 80...200)
            particles.append(Particle(
                position: p,
                velocity: CGVector(dx: cos(angle) * speed,
                                   dy: sin(angle) * speed),
                birth: now,
                lifespan: .random(in: 0.9...1.6),
                hue: (baseHue + Double.random(in: -0.05...0.05))
                    .truncatingRemainder(dividingBy: 1),
                size: .random(in: 2...4)
            ))
        }
    }
}

struct FireworksView: View {
    @State private var model = FireworksModel()

    var body: some View {
        GeometryReader { geo in
            TimelineView(.animation(minimumInterval: 1.0 / 60)) { timeline in
                Canvas(opaque: true,
                       colorMode: .linear,
                       rendersAsynchronously: true) { ctx, size in
                    ctx.fill(Path(CGRect(origin: .zero, size: size)),
                             with: .color(.black))
                    let now = timeline.date.timeIntervalSinceReferenceDate
                    model.tick(now: now, bounds: size)
                    ctx.drawLayer { layer in
                        layer.addFilter(.blur(radius: 3))
                        layer.blendMode = .plusLighter
                        for p in model.particles {
                            let age = now - p.birth
                            let life = max(0, 1 - age / p.lifespan)
                            let color = Color(hue: p.hue,
                                              saturation: 0.9,
                                              brightness: 1)
                            let r = p.size * (0.6 + life)
                            let rect = CGRect(x: p.position.x - r,
                                              y: p.position.y - r,
                                              width: r * 2, height: r * 2)
                            var c = layer
                            c.opacity = life
                            c.fill(Path(ellipseIn: rect),
                                   with: .color(color))
                        }
                    }
                }
                .accessibilityRepresentation {
                    Text("订阅成功烟花动画")
                }
            }
        }
        .ignoresSafeArea()
    }
}

7.6 经典模式 2:实时音频波形可视化

// iOS 15+
struct WaveformBars: View {
    @ObservedObject var player: PlaybackService
    var body: some View {
        TimelineView(.animation) { _ in
            Canvas { ctx, size in
                let bins = player.fftBins
                let barWidth = size.width / CGFloat(bins.count) - 2
                for (i, mag) in bins.enumerated() {
                    let h = CGFloat(mag) * size.height
                    let rect = CGRect(
                        x: CGFloat(i) * (barWidth + 2),
                        y: size.height - h,
                        width: barWidth,
                        height: h)
                    let path = Path(roundedRect: rect, cornerRadius: 2)
                    ctx.fill(path, with: .linearGradient(
                        Gradient(colors: [AnycastColor.gold9,
                                          AnycastColor.orangeAlpha80]),
                        startPoint: CGPoint(x: 0, y: rect.maxY),
                        endPoint: CGPoint(x: 0, y: rect.minY)))
                }
            }
        }
        .accessibilityLabel("音频频谱")
    }
}

7.7 经典模式 3:环形 progress + 飞舞文字

// iOS 15+
struct LoadingPulse: View {
    let label: String

    var body: some View {
        TimelineView(.animation(minimumInterval: 1.0 / 60)) { timeline in
            Canvas { ctx, size in
                let t = timeline.date.timeIntervalSinceReferenceDate
                let center = CGPoint(x: size.width / 2, y: size.height / 2)
                let radius = min(size.width, size.height) / 2 - 20

                var ring = Path()
                ring.addArc(center: center, radius: radius,
                            startAngle: .degrees(0),
                            endAngle: .degrees(360),
                            clockwise: false)
                var rotated = ctx
                rotated.translateBy(x: center.x, y: center.y)
                rotated.rotate(by: .radians(t.truncatingRemainder(dividingBy: 2 * .pi)))
                rotated.translateBy(x: -center.x, y: -center.y)
                rotated.stroke(ring,
                               with: .color(AnycastColor.goldAlpha60),
                               style: StrokeStyle(lineWidth: 3,
                                                  dash: [4, 8]))

                let resolved = ctx.resolve(Text(label)
                    .font(AnycastFont.display(20))
                    .foregroundColor(AnycastColor.sand12))
                ctx.draw(resolved, at: center, anchor: .center)
            }
        }
        .frame(width: 160, height: 160)
        .accessibilityLabel(label)
    }
}

7.8 Canvas + Shader 边界(iOS 17+)

iOS 17 的 .colorEffect / .layerEffect / .distortionEffect 是 SwiftUI 视图修饰器不能GraphicsContext 内部对单个 path/image 加 shader。两条路:

  • Canvas 在外,shader 套整个 CanvasCanvas { ... }.colorEffect(ShaderLibrary.myEffect())
  • 把单个粒子换成 SwiftUI View 走 symbols 路径,然后在外层视图 .colorEffect

7.9 性能优化清单

  • rendersAsynchronously: true:复杂场景必开,闭包内严禁 MainActor 状态
  • 缓存 Path:圆形/矩形等不变形几何放 let,避免每帧分配
  • 缓存 ResolvedText / ResolvedImage
  • 预算 transform:连续旋转用单个 CGAffineTransform concatenate
  • opaque + 自填背景
  • limitFps.animation(minimumInterval: 1/60) 显式封顶
  • 低电量降级context.cadence != .live 时跳过物理积分

7.10 Accessibility

Canvas 默认对 VoiceOver 完全不可见——它只是一块位图。

// iOS 15+
Canvas { ... }
    .accessibilityRepresentation {
        VStack {
            Text("当前进度 \(Int(progress * 100))%")
            Text("剩余时间 \(remaining)")
        }
    }

Canvas { ... }
    .accessibilityElement()
    .accessibilityLabel("音频频谱可视化")
    .accessibilityValue("当前响度 \(Int(level * 100)) 分贝")

7.11 Canvas vs Animatable Shape

选 Canvas 的场景选 Shape + animatableData 的场景
50+ 个独立元素同时动1-10 个元素
需要 blendMode / filter 组合简单形变(描边动画、波浪)
每帧物理模拟、生成式SwiftUI 隐式动画驱动(withAnimation
不需要触摸命中测试单个元素需要 .onTapGesture 命中单个元素
不需要被 VoiceOver 单独读出需要每个元素是独立 accessibility node

Anycast 的封面墙、章节进度条、单个剧集卡片用 Shape;播放页粒子背景、波形、订阅成功烟花用 Canvas

7.12 iOS 18 / iOS 26 新增

  • iOS 17MeshGradient 不在 Canvas 内,但可作为 SwiftUI 背景
  • iOS 18MeshGradient 视图原生,Canvas 内可通过 symbols 嵌入
  • iOS 26(Liquid Glass)GraphicsContext 新增 addFilter(.glass(...)) 系列;TimelineView 增加 .frameDuration(_) 显式帧间隔

踩坑速查 / Pitfalls

  • Async renderer 捕获主线程状态会崩rendersAsynchronously: true 时闭包跑在后台,捕获 @ObservableObject 属性 → "Modifying state during view update" 或随机 EXC_BAD_ACCESS
  • opaque=true 忘填背景 → 黑屏闪烁:opaque 模式下未绘制区域是未定义内存
  • Filter 顺序影响结果blur 后加 alphaThreshold 是"金属球融合",反过来是"模糊的硬边"
  • TimelineView 后台不停.animation schedule 在 app 进后台后仍会被偶发调用,看 context.cadence
  • Text resolve 只在 renderer 内有效ctx.resolve(Text(...)) 不能跨帧存到 @State
  • SF Symbol palette 模式必须走 symbols ViewBuilder:直接 ctx.draw(Image(systemName:).symbolRenderingMode(.palette)) 会被忽略
  • Canvas + .layerEffect 不可混用单元素:shader 只能套整个 Canvas 或外层包装视图
  • VoiceOver 完全看不到 Canvas 内容:忘加 accessibilityRepresentation = 视障用户面对一块空白
  • 低电量模式 cadence 降级:粒子物理积分必须乘真实 dt(now - lastFrame),不能写死 1/60
  • linear colorMode 看起来会变暗:UI 设计稿对色得选 nonLinear
  • ResolvedSymbol id 必须 Hashable 且稳定:用 0, 1, 2 Int 最快
  • drawLayer 内的 transform 不会泄漏到外层

8. ScrollView 动效 完成

8. ScrollView 动效:iOS 17 新 API + visualEffect 全景

iOS 17(WWDC23 Session 10148 Beyond scroll views)一次性补齐了 SwiftUI ScrollView 长期缺失的能力:滚动驱动的转场、目标对齐、容器相对尺寸、可读取 GeometryProxy 的 visualEffect。iOS 18(WWDC24 Session 10148 SwiftUI essentials + 10160 What's new in SwiftUI)又补上 onScrollGeometryChange / onScrollPhaseChange / onScrollVisibilityChange 这三个观察类 API。

8.1 ScrollTransition:滚动相位驱动的转场

.scrollTransition(_:axis:transition:)(iOS 17+)让每一个 child view 拿到自己的 ScrollTransitionPhase

  • .topLeading — 还没进 visible region(在顶/左)
  • .identity — 完全可见,处于 transition 不施加任何变化的"基准态"
  • .bottomTrailing — 已经离开 visible region(在底/右)

phase 提供 isIdentity 布尔与 value: Double(-1...1,topLeading=-1, identity=0, bottomTrailing=1),适合做插值。

// iOS 17+
ScrollView(.horizontal) {
    LazyHStack(spacing: 16) {
        ForEach(episodes) { ep in
            EpisodeCard(ep: ep)
                .scrollTransition(.interactive, axis: .horizontal) { content, phase in
                    content
                        .opacity(phase.isIdentity ? 1 : 0.4)
                        .scaleEffect(phase.isIdentity ? 1 : 0.85)
                        .rotation3DEffect(
                            .degrees(phase.value * -20),
                            axis: (x: 0, y: 1, z: 0),
                            perspective: 0.5
                        )
                }
        }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)

8.2 .interactive vs .animated 配置

Configuration语义典型用途
.interactive跟随手指实时插值carousel 缩放、视差、模糊渐变
.animated(默认)phase 切换时走 spring 动画淡入淡出、单次 pop-in
.identity不做任何效果(占位/调试用)条件 disable

threshold:默认 .visible,可改 .visible(0.5)(50% 可见)或 .centered(中心进入)。

8.3 containerRelativeFrame:让 child 占容器比例

// iOS 17+ — 一屏显示 1.2 张卡片,spacing 12
ScrollView(.horizontal) {
    LazyHStack(spacing: 12) {
        ForEach(shows) { show in
            ShowCard(show: show)
                .containerRelativeFrame(
                    .horizontal,
                    count: 1, span: 1, spacing: 12,
                    alignment: .center
                )
        }
    }
    .scrollTargetLayout()
}
.contentMargins(.horizontal, 24, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)

count / span:把容器分 count 份,child 占 span 份。count: 5, span: 2 表示一屏显示 2.5 张。

8.4 scrollTargetLayout + scrollTargetBehavior

Behavior行为
.viewAligned对齐到 layout 内的 view 边界
.viewAligned(limitBehavior: .alwaysByOne)每次 swipe 最多前进一格(iOS 17.4+)
.paging整屏分页
自定义实现 ScrollTargetBehavior 协议
// iOS 17+ — 自定义 snap:永远停在最近的 80pt 倍数
struct GridSnap: ScrollTargetBehavior {
    let step: CGFloat = 80
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        let snapped = (target.rect.minX / step).rounded() * step
        target.rect.origin.x = snapped
    }
}

ScrollView(.horizontal) { /* ... */ }
    .scrollTargetBehavior(GridSnap())

8.5 scrollPosition:编程式滚动到 id

// iOS 17+
@State private var visibleEpisodeID: Episode.ID?

ScrollView(.horizontal) {
    LazyHStack { ForEach(episodes) { EpisodeCard(ep: $0).id($0.id) } }
        .scrollTargetLayout()
}
.scrollPosition(id: $visibleEpisodeID)
.scrollTargetBehavior(.viewAligned)

Button("Jump to last") {
    withAnimation { visibleEpisodeID = episodes.last?.id }
}

iOS 18 升级为 .scrollPosition(_:anchor:),参数是 ScrollPosition struct,可表达"id / edge / offset"三种位置且可读可写:

// iOS 18+
@State private var position = ScrollPosition(edge: .top)

ScrollView { /* ... */ }
    .scrollPosition($position)

Button("Bottom") { position.scrollTo(edge: .bottom) }
Button("Offset 500") { position.scrollTo(y: 500) }

if let offset = position.point?.y { /* ... */ }

8.6 visualEffect:拿到 GeometryProxy 但不改 layout

.visualEffect { content, geometryProxy in ... }(iOS 17+)是过去三年用 GeometryReader 套 ZStack 的终结者。它在渲染阶段执行,不参与 layout pass,因此不会触发布局抖动。

// iOS 17+ — sticky shrink header
ScrollView {
    Color.clear.frame(height: 0)
        .background(alignment: .top) { Header() }
        .visualEffect { content, proxy in
            let y = proxy.frame(in: .scrollView(axis: .vertical)).minY
            return content
                .scaleEffect(y > 0 ? 1 + y / 800 : 1, anchor: .top)
                .offset(y: y > 0 ? -y : 0)
        }
    LazyVStack { /* episodes */ }
}

关键:visualEffect 返回的必须是 some VisualEffect,可链式叠 .scaleEffect / .offset / .blur / .opacity / .rotationEffect / .brightness / .colorMultiply / .grayscale不能调用任意 modifier。

8.7 iOS 18+ 三个观察 API

// iOS 18+ — 监听任意 scroll metric
@State private var offsetY: CGFloat = 0

ScrollView { /* ... */ }
    .onScrollGeometryChange(for: CGFloat.self) { geo in
        geo.contentOffset.y + geo.contentInsets.top
    } action: { _, newValue in
        offsetY = newValue
    }

onScrollGeometryChange 的第一个参数是要观测的 Equatable 类型——闭包从 ScrollGeometry(含 contentOffset / contentSize / containerSize / contentInsets / visibleRect / bounds)抽出关心的部分;只有该值变化才触发 action,自带 dedupe。

// iOS 18+ — phase change
.onScrollPhaseChange { oldPhase, newPhase in
    switch newPhase {
    case .idle:          haptics.idle()
    case .tracking:      hideChrome()
    case .interacting:   break
    case .decelerating:  break
    case .animating:     break
    @unknown default:    break
    }
}

// iOS 18+ — child 进入/离开 viewport
.onScrollVisibilityChange(threshold: 0.5) { isVisible in
    if isVisible { analytics.log("card_seen") }
}

8.8 边距 / clip / 滚动条

ModifieriOS说明
.contentMargins(edges:length:for:)17+给 scrollContent / scrollIndicators 加 margin
.scrollClipDisabled()17+关掉 ScrollView 默认裁剪
.safeAreaPadding(_:)17+给 safeArea 加 padding
.scrollIndicators(.hidden)16+隐藏滚动条
.scrollDisabled(_)16+条件禁用滚动
.scrollBounceBehavior(.basedOnSize)16.4+内容不超 viewport 时不弹簧

8.9 经典模式 1:Carousel 卡片

// iOS 17+ — Anycast Inbox 横向卡片
struct EpisodeCarousel: View {
    let episodes: [Episode]
    @State private var snappedID: Episode.ID?

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: AnycastSpacing.gap) {
                ForEach(episodes) { ep in
                    EpisodeCard(ep: ep)
                        .containerRelativeFrame(
                            .horizontal,
                            count: 10, span: 8, spacing: AnycastSpacing.gap
                        )
                        .scrollTransition(.interactive, axis: .horizontal) { content, phase in
                            content
                                .scaleEffect(1 - abs(phase.value) * 0.08)
                                .opacity(1 - abs(phase.value) * 0.4)
                        }
                        .id(ep.id)
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, AnycastSpacing.pageH, for: .scrollContent)
        .scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
        .scrollPosition(id: $snappedID)
        .scrollIndicators(.hidden)
    }
}

8.10 经典模式 2:Parallax Header

// iOS 17+ — 节目详情顶图视差 + 下拉放大
struct ShowDetailParallax: View {
    let show: Show
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                Image(show.artwork)
                    .resizable().scaledToFill()
                    .frame(height: 320)
                    .clipped()
                    .visualEffect { content, proxy in
                        let y = proxy.frame(in: .scrollView(axis: .vertical)).minY
                        return content
                            .scaleEffect(
                                y > 0 ? 1 + y / 320 : 1,
                                anchor: .bottom
                            )
                            .offset(y: y > 0 ? -y / 2 : -y / 3)
                    }

                EpisodeListSection(show: show)
                    .padding(.top, AnycastSpacing.sectionGap)
            }
        }
        .ignoresSafeArea(edges: .top)
    }
}

8.11 经典模式 3:Scroll-Driven Blur Nav Bar

// iOS 18+ — 滚动到一定 offset 后导航栏从透明变模糊
struct LibraryView: View {
    @State private var blurAmount: CGFloat = 0

    var body: some View {
        ScrollView {
            LazyVStack(spacing: AnycastSpacing.gap) {
                ForEach(subscriptions) { ShowRow(show: $0) }
            }
            .padding(AnycastSpacing.pageH)
        }
        .onScrollGeometryChange(for: CGFloat.self) { geo in
            geo.contentOffset.y + geo.contentInsets.top
        } action: { _, y in
            blurAmount = min(max(y / 80, 0), 1)
        }
        .overlay(alignment: .top) {
            NavBar(title: "Library")
                .background(.ultraThinMaterial.opacity(blurAmount))
                .background(AnycastColor.sand1.opacity(blurAmount * 0.6))
                .animation(.smooth(duration: 0.2), value: blurAmount)
        }
    }
}

8.12 老 API:ScrollViewReader

iOS 14 起的 ScrollViewReader { proxy in ... proxy.scrollTo(id, anchor: .top) } 仍然有效,但只能写不能读,且必须包裹在 ScrollView 外面。iOS 17 起优先用 .scrollPosition

8.13 性能:和 LazyVStack / LazyHGrid 配合

  • Lazy 容器是必需的:scrollTransition 给每个 child 加 effect,非 lazy 一次实例化几百个会卡
  • id 稳定ForEach 用 stable Identifiable.id
  • visualEffect 内部不要分配:closure 每帧都跑,不要 let formatter = DateFormatter() 这类
  • onScrollGeometryChange dedupe:observed type 一定是 Equatable 且粒度合适
  • scrollTransition 的 threshold 不影响性能但影响节奏感
  • ContainerRelativeFrame 不要嵌套

8.14 iOS 26 新增

  • Liquid Glass 与 ScrollView 的天然耦合.glassEffect() 套在 overlay nav bar 上,配合 onScrollGeometryChange 驱动 tint / intensity
  • ScrollEdgeEffectStyle(iOS 26):.scrollEdgeEffectStyle(.soft, for: .top)
  • scrollInputBehavior(_:for:)(iOS 26)
  • 视觉刷新.scrollIndicators(.automatic) 在 iOS 26 自动跟随 Liquid Glass 折射

踩坑速查 / Pitfalls

  • scrollTargetLayout 一定要写:少了它 .scrollTargetBehavior(.viewAligned) 会无声失效
  • containerRelativeFrame 的 spacing 必须 = LazyHStack spacing
  • scrollClipDisabled 不配 contentMargins 等于自杀:边缘 child 会画到 ScrollView 外面
  • visualEffect 里读 frame 的 CoordinateSpace:用 .scrollView(axis:) 而不是 .global
  • visualEffect 不能改 layout:里面调 .frame(width:) 不会报错但完全不生效
  • scrollPosition 双向绑定的写入要在 withAnimation 内
  • onScrollPhaseChange 的 .animating 包含 scrollPosition 触发的程序滚动
  • onScrollGeometryChange 不要 observe CGFloat 全精度:先 round / 量化再返回
  • scrollTransition + drag gesture 冲突
  • iOS 17.0 vs 17.4 行为差异.viewAligned(limitBehavior: .alwaysByOne) 是 17.4+
  • ScrollViewReader 与 .scrollPosition 不要混用
  • contentMargins for: 参数选错.scrollContent 影响实际内容布局;.scrollIndicators 只缩进滚动条

9. Symbol & Content Transitions 完成

9.1 SF Symbols 动画体系总览

iOS 17(SF Symbols 5)引入了 universal animations,让所有 SF Symbol 都能以一致的方式播放 Bounce、Pulse、Variable Color、Scale、Appear/Disappear、Replace 共 6 大类动画。iOS 18(SF Symbols 6)再补 Breathe、Wiggle、Rotate;iOS 26(SF Symbols 7)继续在已有动画家族上扩展 layer 级控制。整个体系建立在三个 modifier 上:

  • .symbolEffect(_:options:value:isActive:)——播放动画
  • .symbolRenderingMode(_:) + .foregroundStyle(_:)——决定 symbol 用什么颜色策略渲染
  • .symbolVariant(_:).contentTransition(.symbolEffect(...))——切换 symbol 名称或变体时的过渡

9.2 .symbolEffect 完整签名

// 1. 简单触发型
func symbolEffect<T: SymbolEffect>(
    _ effect: T,
    options: SymbolEffectOptions = .default,
    isActive: Bool = true
) -> some View

// 2. 值驱动型
func symbolEffect<T: DiscreteSymbolEffect & SymbolEffect, V: Equatable>(
    _ effect: T,
    options: SymbolEffectOptions = .default,
    value: V
) -> some View

关键差异:

  • Indefinite effects(如 .pulse / .variableColor / .breathe)支持 isActive 形态,循环到 false
  • Discrete effects(如 .bounce / .replace / .wiggle / .rotate)支持 value: 形态,每次值变播一次
  • 有些 effect 同时实现两种 protocol(如 .pulse

9.3 SymbolEffect 全家桶速查

Case类型iOS说明
.bounceDiscrete17默认向上弹一次
.bounce.up / .bounce.downDiscrete17指定方向
.bounce.byLayer / .bounce.wholeSymbolDiscrete17分层逐个弹 vs 整体弹
.pulseBoth17透明度循环呼吸
.scale.up / .scale.downIndefinite17静态放大/缩小到目标态
.variableColorBoth17沿 variable layers 顺序点亮
.variableColor.iterativeBoth17逐层依次亮
.variableColor.cumulativeBoth17叠加亮(亮过的不熄)
.variableColor.reversingBoth17到顶后反向
.replaceDiscrete17切换 symbol 默认动画
.replace.downUpDiscrete17旧的下去 → 新的上来
.replace.upUpDiscrete17都向上
.replace.offUpDiscrete17旧消失 → 新上来
.replace.magic(fallback:)Discrete18"Magic Replace"——共享 strokes 时形变
.appear / .disappearIndefinite17isActive 切换淡入淡出
.breatheBoth18柔和缩放呼吸——比 pulse 更温和
.wiggleBoth18左右抖动;.wiggle.left/.right/.up/.down/.forward/.backward/.clockwise
.rotateBoth18整体旋转
.drawOn / .drawOffDiscrete26SF Symbols 7:路径 stroke 绘入/绘出

9.4 SymbolEffectOptions

// 单次(默认)
.symbolEffect(.bounce, options: .default, value: tapCount)

// 重复 N 次
.symbolEffect(.pulse, options: .repeat(.continuous), isActive: isLoading)
.symbolEffect(.bounce, options: .repeat(3), value: tapCount)

// iOS 18+ 推荐
.symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 1.5)), value: shake)

// 调速
.symbolEffect(.variableColor, options: .speed(2.0).repeat(.continuous), isActive: isDownloading)

// 显式不重复
.symbolEffect(.bounce, options: .nonRepeating, value: count)

9.5 Rendering Mode 与 Palette

// 4 种模式(iOS 15+)
Image(systemName: "speaker.wave.3.fill")
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(AnycastColor.gold)

Image(systemName: "exclamationmark.triangle.fill")
    .symbolRenderingMode(.palette)
    .foregroundStyle(.white, AnycastColor.orangeAlpha9, .black)

Image(systemName: "applelogo")
    .symbolRenderingMode(.multicolor)

Image(systemName: "heart")
    .symbolRenderingMode(.monochrome)

注意:.bounce.byLayer / .pulse.byLayer 的视觉差异要 render mode 是 .hierarchical.palette 才看得出。

9.6 .symbolVariant

Image(systemName: "heart")
    .symbolVariant(.fill)           // 等价于 "heart.fill"

Label("Subscribed", systemImage: "checkmark")
    .symbolVariant(.circle.fill)    // checkmark.circle.fill

9.7 Content Transitions 全集

// iOS 16+
.contentTransition(.identity)
.contentTransition(.opacity)
.contentTransition(.interpolate)
.contentTransition(.numericText())
.contentTransition(.numericText(value: Double(count)))
.contentTransition(.numericText(countsDown: true))

// iOS 17+
.contentTransition(.symbolEffect(.replace))
.contentTransition(.symbolEffect(.replace.downUp))

关键.contentTransition 必须包在一个 withAnimation { ... } 里改变内容才会触发。

// iOS 17 — 数字滚动
@State private var unread = 12

Text("\(unread)")
    .font(AnycastFont.display(28))
    .foregroundStyle(AnycastColor.sand12)
    .contentTransition(.numericText(value: Double(unread)))
    .animation(.snappy, value: unread)

Button("+1") {
    withAnimation { unread += 1 }
}

9.8 实战:播放器 play / pause 切换

// iOS 17 — 推荐写法
struct PlayPauseButton: View {
    @ObservedObject var player: PlaybackService
    var body: some View {
        Button {
            withAnimation(.snappy) { player.togglePlayPause() }
        } label: {
            Image(systemName: player.isPlaying ? "pause.fill" : "play.fill")
                .font(.system(size: 32, weight: .semibold))
                .foregroundStyle(AnycastColor.sand12)
                .contentTransition(.symbolEffect(.replace.downUp))
        }
        .buttonStyle(.plain)
    }
}

9.9 实战:心形点赞 bounce + fill 切换

// iOS 17
struct LikeButton: View {
    @State private var liked = false
    var body: some View {
        Button {
            withAnimation(.bouncy) { liked.toggle() }
        } label: {
            Image(systemName: liked ? "heart.fill" : "heart")
                .font(.system(size: 22, weight: .semibold))
                .foregroundStyle(liked ? AnycastColor.gold : AnycastColor.sand9)
                .symbolEffect(.bounce.up.byLayer, value: liked)
                .contentTransition(.symbolEffect(.replace))
        }
        .buttonStyle(.plain)
    }
}

9.10 实战:未读 badge 数字滚动

// iOS 17 — Inbox 未读数
struct UnreadBadge: View {
    let count: Int
    var body: some View {
        Text("\(count)")
            .font(.system(size: 13, weight: .semibold, design: .rounded))
            .monospacedDigit()
            .foregroundStyle(.white)
            .padding(.horizontal, 7)
            .padding(.vertical, 2)
            .background(AnycastColor.orangeAlpha9, in: Capsule())
            .contentTransition(.numericText(value: Double(count)))
            .animation(.snappy(duration: 0.35), value: count)
    }
}

提示:.monospacedDigit() 是配 .numericText 的黄金搭档——避免数字宽度抖动。

9.11 实战:下载 / 加载 indicator

// iOS 17 — 下载中循环 variableColor
Image(systemName: "arrow.down.circle")
    .font(.system(size: 24))
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(AnycastColor.gold)
    .symbolEffect(
        .variableColor.iterative.reversing,
        options: .repeat(.continuous).speed(0.9),
        isActive: download.isInProgress
    )

// iOS 18+ — 同样场景换 breathe,更轻
Image(systemName: "icloud.and.arrow.down")
    .symbolEffect(.breathe, options: .repeat(.continuous), isActive: isSyncing)

9.12 实战:设置 toggle 状态切换 + 收藏星星

// iOS 17 — toggle 图标 hierarchical 切色
struct StateIcon: View {
    let on: Bool
    var body: some View {
        Image(systemName: on ? "bell.badge.fill" : "bell.slash.fill")
            .symbolRenderingMode(.palette)
            .foregroundStyle(
                on ? AnycastColor.gold : AnycastColor.sand9,
                AnycastColor.sand4
            )
            .contentTransition(.symbolEffect(.replace.downUp))
            .symbolEffect(.bounce, value: on)
    }
}

// 收藏星星——fill 形态切换 + iOS 18 wiggle
struct StarButton: View {
    @State private var saved = false
    var body: some View {
        Button {
            withAnimation { saved.toggle() }
        } label: {
            Image(systemName: "star")
                .symbolVariant(saved ? .fill : .none)
                .font(.system(size: 20, weight: .semibold))
                .foregroundStyle(saved ? AnycastColor.gold : AnycastColor.sand9)
                .contentTransition(.symbolEffect(.replace))
                .symbolEffect(.wiggle, value: saved)
        }
    }
}

9.13 实战:呼吸感 loading + Magic Replace(iOS 18)

// iOS 18 — Magic Replace
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.3.fill")
    .contentTransition(.symbolEffect(.replace.magic(fallback: .downUp)))

// 呼吸 loader
Image(systemName: "circle.dotted")
    .font(.system(size: 28))
    .foregroundStyle(AnycastColor.sand9)
    .symbolEffect(.breathe.pulse.byLayer,
                  options: .repeat(.continuous),
                  isActive: isLoading)

9.14 触发模式 cheat sheet

需求API 形态
tap 一下弹一下.symbolEffect(.bounce, value: tapCount) + tapCount += 1
切换 symbol 名字.contentTransition(.symbolEffect(.replace)) + withAnimation { name = ... }
持续动画到状态变化.symbolEffect(.pulse, isActive: isOn)
状态变化触发一次 + 持续同时挂两个 .symbolEffect(顺序无关,SwiftUI 会合并)
数字内容.contentTransition(.numericText(value:)) + .animation(_, value:)

9.15 iOS 26 / SF Symbols 7 增量

  • Draw On / Draw Off.symbolEffect(.drawOn, value: ...) 让支持的 symbol 沿其 stroke path 绘入
  • Wiggle / Rotate / Breathe 的 layer 控制更细
  • Replace 系列接受更多 fallback 组合,.replace.magic 在更多 symbol 上有 path morph 效果
  • SymbolEffectOptions 完善了与 .speed 的组合行为

踩坑速查 / Pitfalls

  • Discrete effect 的 trigger 是值变化而不是 boolean 真值.symbolEffect(.bounce, value: isOn)isOntruefalse 也会触发
  • 不要把 .bounce.byLayer / .pulse.byLayer.monochrome,看起来和 wholeSymbol 完全一样
  • .contentTransition(.symbolEffect(.replace)) 必须配合 withAnimation,直接 state.toggle() 不会有动画
  • 切换 Image(systemName:) 的字符串要在同一个 Image 节点上发生if/else 写两个 Image 视为不同 view,contentTransition 不生效
  • .appear / .disappear 不等于把 view 删掉
  • .scale.up / .scale.down 不会自己循环,要"一直 pulse 缩放"用 .breathe(iOS 18+)或 .pulse
  • .numericText 要给 value:,否则方向判错
  • SymbolEffectOptions 是 value type,.repeat(...) 返回新 options
  • iOS 18 .wiggle 在某些 symbol 上方向参数被忽略
  • 不要在 List row 重用环境里挂 isActive: true 的 indefinite effect——电量开销大
  • .symbolVariant(.fill) 不会触发 contentTransition——明确写两个 systemName 字符串三元
  • 不要在同一个 Image 上挂超过 3 个 .symbolEffect

10. iOS 26 Liquid Glass 完成

10.1 Liquid Glass 概览:从 Material 到 Glass 的范式跃迁

iOS 26 推出的 Liquid Glass 是 Apple 自 iOS 7 扁平化以来最大的视觉语言重构。与 iOS 15 引入的 Material(半透明高斯模糊)不同,Liquid Glass 是实时折射的 3D 物理材质:它会根据下层内容的颜色、亮度自适应地反射、折射、产生镜面高光,并在元素移动时模拟"液体表面"的形变。SwiftUI 把它封装成 .glassEffect(_:in:isEnabled:) 修饰符,仅在 iOS 26+ / iPadOS 26+ / macOS 26+ / visionOS 26+ 可用。

对 Anycast 而言,Liquid Glass 已经是设计基线:浮动播放器(FloatingPlayer)、Inbox 顶部的 category chips、Episode Detail 的 sticky toolbar、Now Playing 的 transport controls 都已迁移。

10.2 .glassEffect 完整 API

// iOS 26+
extension View {
    func glassEffect(
        _ glass: Glass = .regular,
        in shape: some Shape = Capsule(),
        isEnabled: Bool = true
    ) -> some View
}

三个参数都是 optional,最简写法 .glassEffect() 等价于 .glassEffect(.regular, in: Capsule())Glass 是 struct(不是 enum),通过链式修饰符构造:

Glass 工厂 / 修饰符效果使用场景
.regular默认材质,含柔和高光 + 折射 + 内阴影大多数 UI 元素
.clear更透明、几乎无背景填充覆盖在富色彩内容上
.tint(Color)叠加色调(半透明染色)品牌色按钮,selected state
.interactive(Bool = true)响应触摸:按下时形变、隆起所有可点击元素
// iOS 26 — 标准浮动按钮
Button {
    app.openInbox()
} label: {
    Image(systemName: "tray.fill")
        .font(.system(size: 20, weight: .semibold))
        .frame(width: 56, height: 56)
}
.glassEffect(.regular.tint(AnycastColor.gold).interactive(),
             in: Circle())

注意 .tint 的颜色不会"覆盖"下层,而是与折射结果做乘法混合。所以传 .gold 在白底上偏黄、在黑底上偏暗金。

10.3 GlassEffectContainer 与多元素融合

Liquid Glass 的精髓不是单个元素好看,而是多个 glass 元素彼此靠近时"流体融合"(metaball 效应)。这必须放在 GlassEffectContainer 内:

// iOS 26 — Anycast Now Playing 的 transport row
GlassEffectContainer(spacing: 8) {
    HStack(spacing: 8) {
        ForEach(transportActions) { action in
            Button(action: action.handler) {
                Image(systemName: action.icon)
                    .frame(width: 44, height: 44)
            }
            .glassEffect(.regular.interactive(), in: Circle())
            .glassEffectID(action.id, in: glassNamespace)
        }
    }
}

当两个 .glassEffect 元素的 frame 间距 ≤ container 的 spacing 时,它们会无缝融合成一个连续的玻璃形状。spacing 默认 20

glassEffectUnion(id:namespace:) — 强制把多个非邻近元素绑定为同一融合组:

// iOS 26
@Namespace private var glass

GlassEffectContainer(spacing: 40) {
    HStack(spacing: 60) {
        capsule(label: "Subscribed").glassEffectUnion(id: "filter", namespace: glass)
        capsule(label: "Played").glassEffectUnion(id: "filter", namespace: glass)
        capsule(label: "Saved").glassEffectUnion(id: "filter", namespace: glass)
    }
}
// → 三个 capsule 仍被一根细玻璃"管道"连接

glassEffectID(_:in:) — 在容器内做 matchedGeometryEffect 风格的 morphing:

// iOS 26 — toggle 状态切换时玻璃流动
@Namespace private var ns
@State private var expanded = false

GlassEffectContainer {
    if expanded {
        VStack { ... }
            .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 24))
            .glassEffectID("player", in: ns)
    } else {
        Capsule().fill(.clear).frame(width: 200, height: 56)
            .glassEffect(.regular, in: Capsule())
            .glassEffectID("player", in: ns)
    }
}
.animation(.spring(duration: 0.45, bounce: 0.25), value: expanded)

10.4 .glassEffectTransition — 进出场动画

// iOS 26
extension View {
    func glassEffectTransition(_ transition: GlassEffectTransition = .matchedGeometry,
                               isEnabled: Bool = true) -> some View
}

// .matchedGeometry — 默认,跟随 glassEffectID
// .identity         — 关闭过渡(直接淡入淡出)

10.5 Toolbar / NavigationBar / TabBar 的 glass 默认

iOS 26 起,所有系统 bar 的 background 默认就是 Liquid Glass,无需手动加。这意味着以下旧写法应被移除:

// iOS 18 老写法 — iOS 26 不再需要
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)

// iOS 26 — 仅在你想强制透明 / 强制隐藏时才用
.toolbarBackgroundVisibility(.hidden, for: .navigationBar)

ToolbarItem 内的按钮自动获得 glass 容器:

// iOS 26
.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        Button("Edit") { isEditing.toggle() }
    }
    ToolbarSpacer(.fixed, placement: .topBarTrailing)
    ToolbarItem(placement: .topBarTrailing) {
        Menu("More") { ... }
    }
}

10.6 buttonStyle(.glass) / .glassProminent

// iOS 26
Button("Subscribe") { ... }
    .buttonStyle(.glass)             // 中性

Button("Play Episode") { ... }
    .buttonStyle(.glassProminent)    // 强调
    .tint(AnycastColor.gold)

Button("Save") { ... }
    .buttonStyle(.glass)
    .controlSize(.large)

这两个 style 自动包含 .interactive()、自动响应 dynamic type、自动支持 disabled 状态淡化。

10.7 Material vs Liquid Glass 对照表

维度Material(iOS 15+)Liquid Glass(iOS 26+)
渲染模型2D 高斯模糊 + 半透明色3D 折射 + 镜面高光 + 边缘内阴影
触摸响应.interactive() 提供形变
多元素融合不支持GlassEffectContainer 自动 metaball 融合
形状跟随 .background(_:in:) 的 shape同左,但额外渲染折射边缘
动态 tint需要叠 overlay.tint(Color) 物理混合
自适应亮 / 暗背景颜色固定实时采样下层颜色,自动调整 tint 与高光
性能高斯模糊Metal 着色器,比 Material 略贵但 GPU 已优化
API.thinMaterial / .ultraThin / .regular / .thick / .ultraThick / .barGlass.regular / .clear + 修饰符
SwiftUI 接入.background(.thinMaterial).glassEffect(.regular, in: shape)
仍然适用iOS 15–25 兼容、Lock Screen widget 背景iOS 26+ 所有交互式悬浮元素

结论:新写 iOS 26+ 代码默认 Glass;要兼容 iOS 18 用 Material;Lock Screen / Widget / 深色 modal sheet 内容区仍可用 Material。

10.8 .background(_:in:) 的统一接口

// 通用统一签名
.background(Color.red, in: Capsule())
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24))
.background(LinearGradient(...), in: Circle())

// iOS 26 — Glass 不走 .background(_:in:),必须用专用 .glassEffect(_:in:)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 24))

10.9 Vibrancy:foregroundStyle 在 glass 上的表现

// iOS 26 — 自动 vibrancy
HStack {
    Image(systemName: "play.fill")
        .foregroundStyle(.primary)
    Text("Now Playing")
        .foregroundStyle(.secondary)
    Text("3:24")
        .foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.glassEffect(.regular, in: Capsule())

关键:不要在 glass 上用具体 Color。优先 .primary / .secondary / .tertiary / .quaternary。如果必须 brand 色,用 .tint(AnycastColor.gold)

10.10 .scrollEdgeEffectStyle — 滚动边缘融化

// iOS 26
ScrollView { ... }
    .scrollEdgeEffectStyle(.soft, for: .top)
    .scrollEdgeEffectStyle(.hard, for: .bottom)

10.11 自适应:glass 如何感知背景

Glass 内部对其下方约 20pt 范围做实时采样,决定:

  • 表层 tint 浓度
  • 镜面高光位置
  • vibrancy 反相强度

10.12 sheet / popover 的 glass 默认

// iOS 26
.sheet(isPresented: $showSettings) {
    SettingsView()
        .presentationDetents([.medium, .large])
        .presentationBackground(.thinMaterial)  // 想退回 Material 时显式指定
}

10.13 性能:offscreen pass 与 glass 合并

Liquid Glass 单个 glass 元素的 GPU 成本约为单 Material 的 1.3–1.6 倍。但有两点利好:

  1. 同一 GlassEffectContainer 内的多个 glass 共享一次 pass
  2. 静止状态下 glass 会被 cached

实战建议:

  • Toolbar 自动在系统 container 里
  • 自定义浮动 UI 显式包 GlassEffectContainer
  • 列表 cell 内独立 glass 元素 → 性能陷阱

10.14 兼容性:iOS 18 fallback

extension View {
    @ViewBuilder
    func anycastGlass<S: Shape>(_ shape: S, tint: Color? = nil) -> some View {
        if #available(iOS 26.0, *) {
            if let tint {
                self.glassEffect(.regular.tint(tint).interactive(), in: shape)
            } else {
                self.glassEffect(.regular.interactive(), in: shape)
            }
        } else {
            self.background(.thinMaterial, in: shape)
                .overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5))
        }
    }
}

10.15 实战:Anycast 四个典型场景

(1) 浮动 Mini Player(底部悬浮 capsule)

// iOS 26
struct MiniPlayer: View {
    @EnvironmentObject var app: AppState
    @Namespace private var glass

    var body: some View {
        GlassEffectContainer(spacing: 8) {
            HStack(spacing: 12) {
                AsyncImage(url: app.currentArtwork) { $0.resizable() } placeholder: { Color.gray }
                    .frame(width: 40, height: 40)
                    .clipShape(RoundedRectangle(cornerRadius: 8))

                VStack(alignment: .leading, spacing: 2) {
                    Text(app.currentTitle).font(.system(size: 14, weight: .semibold))
                        .foregroundStyle(.primary).lineLimit(1)
                    Text(app.currentShow).font(.system(size: 12))
                        .foregroundStyle(.secondary).lineLimit(1)
                }
                Spacer(minLength: 8)

                Button { app.togglePlay() } label: {
                    Image(systemName: app.isPlaying ? "pause.fill" : "play.fill")
                        .font(.system(size: 18, weight: .semibold))
                        .frame(width: 36, height: 36)
                }
                .glassEffect(.regular.interactive(), in: Circle())
                .glassEffectID("playBtn", in: glass)
            }
            .padding(.horizontal, 12).padding(.vertical, 8)
            .glassEffect(.regular, in: Capsule())
            .glassEffectID("miniBg", in: glass)
        }
        .padding(.horizontal, AnycastSpacing.pageH)
    }
}

(2) 浮动 Search Bar

// iOS 26
HStack(spacing: 8) {
    Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
    TextField("Search episodes", text: $query)
        .textFieldStyle(.plain)
    if !query.isEmpty {
        Button { query = "" } label: { Image(systemName: "xmark.circle.fill") }
            .foregroundStyle(.tertiary)
    }
}
.padding(.horizontal, 14).padding(.vertical, 10)
.glassEffect(.regular, in: Capsule())
.padding(.horizontal, AnycastSpacing.pageH)
.scrollEdgeEffectStyle(.soft, for: .top)

(3) Toolbar 多 action 自动融合

// iOS 26 — 系统自动包 GlassEffectContainer
.toolbar {
    ToolbarItemGroup(placement: .topBarTrailing) {
        Button { share() } label: { Image(systemName: "square.and.arrow.up") }
        Button { save() }  label: { Image(systemName: "bookmark") }
        Menu { /* ... */ } label: { Image(systemName: "ellipsis") }
    }
}

(4) 通知 Capsule

// iOS 26
struct ToastCapsule: View {
    let text: String
    @State private var visible = false

    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: "checkmark.circle.fill")
                .foregroundStyle(.tint)
            Text(text).foregroundStyle(.primary)
        }
        .font(.system(size: 14, weight: .medium))
        .padding(.horizontal, 16).padding(.vertical, 10)
        .glassEffect(.regular.tint(AnycastColor.gold), in: Capsule())
        .glassEffectTransition(.matchedGeometry)
        .opacity(visible ? 1 : 0)
        .offset(y: visible ? 0 : -20)
        .onAppear {
            withAnimation(.spring(duration: 0.4, bounce: 0.3)) { visible = true }
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
                withAnimation(.easeOut(duration: 0.25)) { visible = false }
            }
        }
    }
}
踩坑速查 / Pitfalls
  • Deployment target 必须 iOS 26+.glassEffect 在 iOS 25 及以下会编译警告或运行时崩溃
  • 过度堆叠 glass 会变浑浊。规则:一个层级路径上最多一层 glass
  • tint contrast.tint 是物理混合,深色 tint 放在亮底上几乎看不见
  • 不要在 glass 上写硬编码 Color 文字。用 .primary/.secondary/.tertiary 才能拿到 vibrancy
  • GlassEffectContainer 必须包裹直接子视图,跨 NavigationStack / sheet 的 glass 不会融合
  • 列表 cell 每行一个 .glassEffect = 性能炸弹
  • 不要 .toolbarBackground(.thinMaterial, ...) 覆盖 iOS 26 的默认 glass
  • SwiftUI Preview 渲染 glass 不准。Xcode 26 Preview 仍用 Material approximation
  • 动画化 glass 形状必须在 GlassEffectContainer 内
  • Accessibility / Reduce Transparency:用户开启 "降低透明度" 时,glass 自动退化为不透明纯色

11. Layout 动画 / Custom Layout 完成

11.1 Layout 协议总览

SwiftUI 在 iOS 16 引入 Layout 协议,让开发者可以编写完全自定义的容器,且可以 参与 SwiftUI 的动画与几何系统。区别于早期 GeometryReader + offset 的"假布局",Layout 协议让自定义容器获得与 HStack 一等公民地位:

  • 容器告诉父级"我需要多大"——通过 sizeThatFits
  • 容器告诉子级"你被放在哪里、给你多大空间"——通过 placeSubviews
  • 整个过程是 纯函数式
  • 因为 frame 是 SwiftUI 算出来的,所以 matchedGeometryEffect.animationgeometryGroup 全部生效

核心 API 签名(iOS 16+,WWDC22 Session 10056):

// iOS 16+
public protocol Layout: Animatable {
    associatedtype Cache = Void
    static var layoutProperties: LayoutProperties { get }

    func makeCache(subviews: Subviews) -> Cache
    func updateCache(_ cache: inout Cache, subviews: Subviews)

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    )

    func explicitAlignment(...) -> CGFloat?
    func spacing(subviews: Subviews, cache: inout Cache) -> ViewSpacing
}

ProposedViewSize 三态语义

含义响应策略
nil"理想大小"测量子内容总尺寸返回
.zero"最小可压缩到多少"返回 minimum bound
.infinity"最大可扩张到多少"返回 ideal 或截断到内容
有限值"建议你用这个尺寸"用它布局,返回实际占用
常见错误:直接 proposal.replacingUnspecifiedDimensions() 而忽略 infinity 的语义。FlowLayout / Masonry 这类 width-driven 布局,至少要把 宽度 当作硬约束处理。

11.2 LayoutSubview 与 LayoutValueKey

给子 view 附加 metadata 的官方方式是 LayoutValueKey

// iOS 16+
private struct RankKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func flowRank(_ value: Int) -> some View {
        layoutValue(key: RankKey.self, value: value)
    }
}

// 容器内读取:subview[RankKey.self]

11.3 makeCache / updateCache 正确姿势

SwiftUI 在一次 layout pass 里会多次调用 sizeThatFits(不同 proposal 探测)+ 一次 placeSubviews。如果每次都重新测量子 view,O(n²)。

// iOS 16+
struct FlowLayout: Layout {
    struct Cache {
        var rows: [Row] = []
        var lastProposalWidth: CGFloat = .nan
        var totalSize: CGSize = .zero
    }

    func makeCache(subviews: Subviews) -> Cache { Cache() }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.lastProposalWidth = .nan
    }

    private func ensureCache(
        _ cache: inout Cache,
        subviews: Subviews,
        proposalWidth: CGFloat
    ) {
        guard cache.lastProposalWidth != proposalWidth else { return }
        let result = computeRows(subviews: subviews, maxWidth: proposalWidth)
        cache.rows = result.rows
        cache.totalSize = result.size
        cache.lastProposalWidth = proposalWidth
    }
}
cache 不是状态。它必须是"输入相同则输出相同"的 memoization。

11.4 完整实例:FlowLayout(标签流换行)

// iOS 16+
struct FlowLayout: Layout {
    var hSpacing: CGFloat = 8
    var vSpacing: CGFloat = 8
    var alignment: HorizontalAlignment = .leading

    struct Row {
        var range: Range<Int>
        var sizes: [CGSize]
        var width: CGFloat
        var height: CGFloat
    }

    struct Cache {
        var width: CGFloat = .nan
        var rows: [Row] = []
        var totalSize: CGSize = .zero
    }

    func makeCache(subviews: Subviews) -> Cache { Cache() }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.width = .nan
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize {
        let maxWidth = proposal.width ?? .infinity
        compute(maxWidth: maxWidth, subviews: subviews, cache: &cache)
        return CGSize(
            width: proposal.width ?? cache.totalSize.width,
            height: cache.totalSize.height
        )
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) {
        let maxWidth = bounds.width
        compute(maxWidth: maxWidth, subviews: subviews, cache: &cache)

        var y = bounds.minY
        for row in cache.rows {
            let leftover = maxWidth - row.width
            var x: CGFloat
            switch alignment {
            case .center:   x = bounds.minX + leftover / 2
            case .trailing: x = bounds.minX + leftover
            default:        x = bounds.minX
            }
            for (offset, idx) in row.range.enumerated() {
                let size = row.sizes[offset]
                subviews[idx].place(
                    at: CGPoint(x: x, y: y + (row.height - size.height) / 2),
                    anchor: .topLeading,
                    proposal: ProposedViewSize(size)
                )
                x += size.width + hSpacing
            }
            y += row.height + vSpacing
        }
    }

    private func compute(
        maxWidth: CGFloat,
        subviews: Subviews,
        cache: inout Cache
    ) {
        guard cache.width != maxWidth else { return }
        cache.width = maxWidth
        cache.rows.removeAll(keepingCapacity: true)

        var rowStart = 0
        var rowSizes: [CGSize] = []
        var rowWidth: CGFloat = 0
        var rowHeight: CGFloat = 0
        var totalHeight: CGFloat = 0
        var totalWidth: CGFloat = 0

        for i in subviews.indices {
            let size = subviews[i].sizeThatFits(.unspecified)
            let needed = rowSizes.isEmpty ? size.width : rowWidth + hSpacing + size.width
            if !rowSizes.isEmpty && needed > maxWidth {
                cache.rows.append(.init(
                    range: rowStart..<i,
                    sizes: rowSizes,
                    width: rowWidth,
                    height: rowHeight
                ))
                totalHeight += rowHeight + vSpacing
                totalWidth = max(totalWidth, rowWidth)
                rowStart = i
                rowSizes = [size]
                rowWidth = size.width
                rowHeight = size.height
            } else {
                rowSizes.append(size)
                rowWidth = needed
                rowHeight = max(rowHeight, size.height)
            }
        }
        if !rowSizes.isEmpty {
            cache.rows.append(.init(
                range: rowStart..<subviews.endIndex,
                sizes: rowSizes,
                width: rowWidth,
                height: rowHeight
            ))
            totalHeight += rowHeight
            totalWidth = max(totalWidth, rowWidth)
        }
        cache.totalSize = CGSize(width: totalWidth, height: totalHeight)
    }
}

11.5 完整实例:RadialLayout(圆形菜单)

// iOS 16+
struct RadialLayout: Layout {
    var startAngle: Angle = .degrees(-90)
    var clockwise: Bool = true

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let side = min(
            proposal.width ?? 200,
            proposal.height ?? 200
        )
        return CGSize(width: side, height: side)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        guard !subviews.isEmpty else { return }
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let maxChild = subviews
            .map { $0.sizeThatFits(.unspecified) }
            .reduce(CGFloat(0)) { max($0, max($1.width, $1.height)) }
        let radius = (min(bounds.width, bounds.height) - maxChild) / 2

        let step = 2 * .pi / Double(subviews.count)
        let dir: Double = clockwise ? 1 : -1

        for (i, subview) in subviews.enumerated() {
            let angle = startAngle.radians + dir * step * Double(i)
            let pt = CGPoint(
                x: center.x + radius * CGFloat(cos(angle)),
                y: center.y + radius * CGFloat(sin(angle))
            )
            subview.place(at: pt, anchor: .center, proposal: .unspecified)
        }
    }
}

使用:

RadialLayout(startAngle: .degrees(-90)) {
    ForEach(speeds, id: \.self) { speed in
        SpeedChip(speed: speed)
    }
}
.frame(width: 240, height: 240)
.animation(.spring(duration: 0.45, bounce: 0.3), value: speeds.count)

11.6 AnyLayout 切换 + Spring 动画

AnyLayout(iOS 16+)类型擦除,让你在 同一组子 view 上切换布局策略。条件:

  1. 子 view 的 identity 必须保持稳定(用 ForEach(id:) 或显式 .id()
  2. AnyLayout 实例直接当 view builder 调用,不要用 if/else 分别写两个容器
// iOS 16+
struct EpisodeGalleryView: View {
    enum Mode: Hashable { case list, grid, radial }
    @State private var mode: Mode = .list
    let episodes: [Episode]

    private var layout: AnyLayout {
        switch mode {
        case .list:   return AnyLayout(VStackLayout(spacing: 12))
        case .grid:   return AnyLayout(FlowLayout(hSpacing: 12, vSpacing: 12))
        case .radial: return AnyLayout(RadialLayout())
        }
    }

    var body: some View {
        layout {
            ForEach(episodes) { ep in
                EpisodeCard(episode: ep)
            }
        }
        .animation(.spring(duration: 0.55, bounce: 0.28), value: mode)
        .padding(AnycastSpacing.pageH)
    }
}

11.7 .geometryGroup() 与 matchedGeometryEffect

iOS 17 引入 .geometryGroup()。问题场景:父 view 在动画期间改变自己的 frame,子 view 又用 matchedGeometryEffect 绑定到另一棵子树——SwiftUI 默认会把"父位移 + 子位移"展平成单次插值,导致 jitter。

// iOS 17+
ZStack {
    if expanded {
        DetailCard(episode: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
    } else {
        MiniCard(episode: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
    }
}
.geometryGroup()
.animation(.spring(duration: 0.5, bounce: 0.25), value: expanded)

11.8 explicitAlignment / ViewSpacing / anchor

explicitAlignment(of:in:...) 让你的 Layout 暴露给外层 alignment guide 系统。

spacing(subviews:cache:) 返回 ViewSpacing,告诉 父级"我和你之间四条边各应留多少"。

place(at:anchor:proposal:)anchor 控制"point 指向 subview 的哪个角"。常用:.topLeading.center.firstTextBaseline

11.9 性能与 ScrollView 边界

场景能用 Layout 吗替代
10–200 个 chip 一次性显示OK,FlowLayout + cache
1000+ 元素瀑布流不要,全量 sizeThatFits 会卡LazyVGrid + 自定义 GridItem
水平无限滚动 carousel不要LazyHStack in ScrollView
Sheet detent 内的小型自适应栅格OK

本质:Layouteager 的——它对所有 subviews 调用 sizeThatFitsLazyHStack/LazyVGridlazy 的——只测量可见。

11.10 在 Layout 内做 spring 动画

// iOS 16+
struct RadialLayout: Layout, Animatable {
    var startAngle: Angle
    var radius: CGFloat

    var animatableData: AnimatablePair<Double, CGFloat> {
        get { AnimatablePair(startAngle.radians, radius) }
        set {
            startAngle = .radians(newValue.first)
            radius = newValue.second
        }
    }
}

RadialLayout(startAngle: .degrees(spin), radius: r) { ... }
    .animation(.spring(duration: 0.7, bounce: 0.3), value: spin)

11.11 iOS 18 / iOS 26 增量

  • iOS 18Layout 协议本身 API 没新增,但 .animation 引入了 CustomAnimation 协议
  • iOS 18ScrollView 新增 .containerRelativeFrame
  • iOS 26(Liquid Glass)Layout.glassEffectContainer 的合作
  • iOS 26ProposedViewSize 新增了对 contentMargins 的隐式传递
踩坑速查 / Pitfalls
  • cache 不是 state:只放可由输入推导的 memoization
  • proposal 三态必须区分:直接 proposal.width ?? 100 当默认值会塌成 100pt
  • .unspecified 提案传递subviews[i].sizeThatFits(.unspecified) 测出的是子的"理想大小"
  • place 必须给所有 subviews 落位:漏掉一个会有鬼影
  • AnyLayout 切换无动画:99% 是 ForEach 没带 stable id
  • RadialLayout 在 List/Form row 里塌成 0
  • Layout 内嵌 GeometryReader:GeometryReader 会向父级要 infinity,污染 sizing
  • cache invalidation:用 proposal width 做 cache key,宽度变了就重算
  • geometryGroup vs drawingGroup:前者影响坐标系合成;后者把子树光栅化
  • Liquid Glass 容器内 Layout 的 frame 不能跳变:iOS 26 的 glass morph 需要连续插值

12. 性能 + 高级模式 完成

12.1 渲染管线基础:SwiftUI 如何把 View 变成像素

SwiftUI 的渲染分四个相对独立的阶段:body 求值(构造 View 值树)→ Diff(与上次树对比)→ Layout(自顶向下 propose / 自底向上 return)→ Display(合成到 Render Server)。WWDC 2023 "Demystify SwiftUI performance" 与 2024 "Explore SwiftUI performance" 反复强调:性能问题几乎都在前两步——body 求值过频identity 不稳定

调试时第一步永远是问:这个 body 一秒被调多少次?let _ = Self._printChanges() 插到 body 顶部。

struct EpisodeRow: View {
    let episode: Episode
    var body: some View {
        let _ = Self._printChanges()
        HStack { /* ... */ }
    }
}

12.2 三种合成 modifier:drawingGroup / compositingGroup / geometryGroup

drawingGroup(opaque:colorMode:) — 离屏 Metal 栅格化

把整个子树先用 Metal 栅格化到一张离屏 texture,再贴回去。真正适合的场景非常窄

  • 子树包含大量 Path / Shape / Canvas,每帧重画很贵,但内容静态
  • 子树要做整体的 blur / colorMultiply 等 expensive filter

反过来不该用:UIKit-style 普通布局;任何尺寸/内容会逐帧变;包含 text 的子树。

// 差:静态复杂矢量没有缓存,每帧重画
struct WaveformView: View {
    let samples: [Float]
    var body: some View {
        Canvas { ctx, size in /* 上千段 path */ }
            .frame(height: 60)
    }
}

// 优:内容相对静态,整体只做 offset 滚动
struct WaveformView: View {
    let samples: [Float]
    var body: some View {
        Canvas { ctx, size in /* 上千段 path */ }
            .frame(height: 60)
            .drawingGroup()
    }
}

compositingGroup() — 强制中间合成层

// 差:两个重叠 circle 各自 0.5,重叠处实际 ≈ 0.75
ZStack {
    Circle().fill(.orange)
    Circle().fill(.gold).offset(x: 20)
}
.opacity(0.5)

// 优:先合成成一张图再整体 0.5
ZStack {
    Circle().fill(.orange)
    Circle().fill(.gold).offset(x: 20)
}
.compositingGroup()
.opacity(0.5)

geometryGroup() (iOS 17+) — 坐标快照原子化

// 差:matched + 父级 frame 同时动,子里的 image 会"先跳后拉"
ZStack {
    if expanded {
        ArtworkLarge(ep: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
    } else {
        ArtworkSmall(ep: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
    }
}

// 优:子树几何先打包再插值,过渡丝滑
ZStack {
    if expanded {
        ArtworkLarge(ep: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
            .geometryGroup()
    } else {
        ArtworkSmall(ep: ep)
            .matchedGeometryEffect(id: ep.id, in: ns)
            .geometryGroup()
    }
}

12.3 命中测试与 .allowsHitTesting

// 差:装饰用的渐变光晕也要参与 hit-test
ZStack {
    LinearGradient(...).blur(radius: 80)
    EpisodeContent(...)
}

// 优:装饰排除在 hit-test 之外
ZStack {
    LinearGradient(...).blur(radius: 80)
        .allowsHitTesting(false)
    EpisodeContent(...)
}

12.4 View Identity 与稳定性

SwiftUI 决定"这个 view 是上一帧那个还是新的"靠 identity。Identity 一变 → 整个子树 teardown + recreate → @State 重置、动画重启、onAppear 再触发。

// 差:ForEach 用 indices,插入/删除时 index 漂移
ForEach(episodes.indices, id: \.self) { i in
    EpisodeRow(episode: episodes[i])
}

// 优:用稳定业务 id
ForEach(episodes, id: \.id) { ep in
    EpisodeRow(episode: ep)
}

// 差:刷新数据时强行重建整个 ScrollView
ScrollView { LazyVStack { ForEach(items) { ... } } }
    .id(refreshCounter)

// 优:让 LazyVStack 自己 diff
ScrollView { LazyVStack { ForEach(items) { ... } } }

12.5 状态系统:@State / @Binding / @ObservableObject / @Observable / @Environment 失效边界

Property Wrapper失效粒度iOS 版本典型坑
@State当前 view 自身13+放进容易重建的子树会丢值
@Binding读写 → 父级 invalidate13+不缓存身份
@ObservedObject对象任何 @Published 变 → 持有方 invalidate13+"全有或全无"
@StateObject同上,但生命周期绑 view14+放进 if 分支里会被反复创建
@Observable只读哪个属性才订阅哪个17+必须 class
@Environment该 key 变 → invalidate13+注入 ObservableObject 仍是粗粒度

iOS 17 Observation framework 的优化原理

// 差(iOS 16 风格):读 player.currentTime 但 isPlaying / volume 任一变都重算
final class PlayerStore: ObservableObject {
    @Published var currentTime: Double = 0
    @Published var isPlaying = false
    @Published var volume: Float = 1
}
struct ProgressLabel: View {
    @ObservedObject var store: PlayerStore
    var body: some View { Text(store.currentTime.formatted()) }
}

// 优(iOS 17+):仅 currentTime 变才重算
@Observable final class PlayerStore {
    var currentTime: Double = 0
    var isPlaying = false
    var volume: Float = 1
}
struct ProgressLabel: View {
    let store: PlayerStore   // 普通 let,不需要 wrapper
    var body: some View { Text(store.currentTime.formatted()) }
}
项目内 PlaybackService 仍用 @Published,timer closure 60Hz 更新进度,导致整张 NowPlaying view 每秒重算 60 次。迁移到 @Observable 后,仅 ScrubSliderText(time) 命中失效。

12.6 EquatableView / View: Equatable / .equatable()

struct EpisodeRow: View, Equatable {
    let episode: Episode
    let isPlaying: Bool
    let now: Date

    static func == (l: Self, r: Self) -> Bool {
        l.episode.id == r.episode.id && l.isPlaying == r.isPlaying
    }
    var body: some View { /* ... */ }
}

EpisodeRow(episode: ep, isPlaying: playing, now: now).equatable()

12.7 PreferenceKey 的成本

PreferenceKey 是子→父反向通信通道,但 reduce 在每次 layout 都跑一次,且会把消息冒泡到祖先链上。长列表里给每行加 .preference(key:) 收集 frame 是常见性能黑洞。能用 onGeometryChange (iOS 18+) 或者直接 GeometryReader 局部测量就别冒泡。

12.8 Lazy 容器 cell reuse 与 onAppear

LazyVStack / LazyHStack / LazyVGrid 只在 cell 进入可视 + prefetch 区时才求值 body。两个推论:

  • onAppear 在 cell 滚回来时会再次触发
  • cell 内 @State 滚出去再滚回来会丢值
// 差:用 VStack(非 Lazy),1000 行全部立即求值
ScrollView {
    VStack {
        ForEach(episodes) { EpisodeRow(episode: $0) }
    }
}

// 优:LazyVStack 仅渲染可视附近
ScrollView {
    LazyVStack(spacing: AnycastSpacing.gap) {
        ForEach(episodes) { EpisodeRow(episode: $0) }
    }
}

12.9 动画 Animatable:选错插值字段的代价

SwiftUI 的动画通过 Animatable protocol 的 animatableData 在每个 display frame 重算 view。插 frame.width / height 等几何字段 → 触发整子树 layout invalidation插 scale / offset / rotation → 走 transform 路径,layout 不变

// 差:动 width 触发每帧 layout
ArtworkView(ep: ep)
    .frame(width: expanded ? 320 : 64,
           height: expanded ? 320 : 64)
    .animation(.spring, value: expanded)

// 优:固定 frame,用 scaleEffect 走 transform
ArtworkView(ep: ep)
    .frame(width: 64, height: 64)
    .scaleEffect(expanded ? 5 : 1, anchor: .topLeading)
    .animation(.spring, value: expanded)

基准(iPhone 17 Pro / iOS 26 / Release / 50 行 ScrollView):frame 插值平均 ≈ 18ms(持续掉帧 ~55fps),scaleEffect 平均 ≈ 5.4ms(稳 120fps)。

blur(radius:)shadow 走 GPU 模糊 pass,半径越大成本越高,动画 blur 半径几乎一定掉帧;改用预渲两份 + opacity 切换。

12.10 transaction / withAnimation 的失效边界

// 局部禁动画
List(episodes) { ep in
    EpisodeRow(episode: ep)
}
.transaction { $0.animation = nil }   // reload 整列不动画

12.11 Canvas / Shader:GPU vs CPU 权衡

Canvas 在 main thread 跑 closure 把命令录入 displayList,再交给 Metal 上 GPU 画。优势是 path 数量大时仍只有一次 draw call。colorEffect / layerEffect / distortionEffect(iOS 17+)走真正的 Metal Shading Language fragment shader,能做实时变形/调色。简单视觉效果优先 SwiftUI 原生 modifier;复杂矢量大量绘制选 Canvas;像素级实时滤镜才考虑 Shader。

12.12 60Hz vs ProMotion 120Hz:TimelineView 与 CADisplayLink

iPhone 13 Pro 起的 ProMotion 屏幕 1–120Hz 自适应。要让动画真跑 120Hz 必须满足两个条件:(1) 屏幕被驱动到 120Hz;(2) 你的渲染 closure 也按 120Hz 节奏更新 state。

TimelineView(.animation) 默认绑 display refresh,会在每个 vsync 重算 closure。播客 waveform 这种"每帧推进"的场景用它而不是 Timer

// 优:vsync-aligned,ProMotion 自动 120Hz
TimelineView(.animation) { ctx in
    Canvas { gc, size in
        drawWave(at: ctx.date, in: gc, size: size)
    }
}

12.13 Instruments:SwiftUI / Animation Hitches / Time Profiler

  1. SwiftUI template(Xcode 15+):含 "View Body" 与 "View Properties" track
  2. Animation Hitches template:以 hitch ratio 标红
  3. Time Profiler:传统 sampling profiler
  4. Core Animation template:看 commit、prepare、render-server 三阶段时间

命令行 profiling 流程(搭配本项目 swiftc 构建)

// 1. Release build (build_anycast.sh 加 -O -whole-module-optimization)
// 2. Boot + install 同 CLAUDE.md
// 3. 启动并 attach Instruments
//    xcrun xctrace record \
//      --template 'SwiftUI' \
//      --device 7DBDB4C8-B748-4693-B7C9-2A4E2E046E54 \
//      --launch -- com.anycast.app \
//      --output /tmp/anycast.trace
// 4. open /tmp/anycast.trace

12.14 减少 ViewBody 计算的工程手段

  • 拆 struct,不要拆函数@ViewBuilder 子函数返回的 view 仍属于父 view 的 body
  • 把"频繁变 + 视觉无关"的 state 从 view 里移出去
  • computed property 避免在 body 里跑 O(n) 工作
  • 避免在 body 里 Date() / UUID() / 立即执行闭包
// 差:closure 里 capture 大对象 + 在 body 里建
struct EpisodeList: View {
    let episodes: [Episode]
    let store: PlayerStore
    var body: some View {
        let formatter = DateFormatter()      // 每帧新建
        formatter.dateStyle = .medium
        return ForEach(episodes) { ep in
            Button { store.play(ep) } label: {
                Text(formatter.string(from: ep.publishedAt))
            }
        }
    }
}

// 优:拆子 struct + static formatter
private let episodeDateFormatter: DateFormatter = {
    let f = DateFormatter(); f.dateStyle = .medium; return f
}()

struct EpisodeList: View {
    let episodes: [Episode]
    let store: PlayerStore
    var body: some View {
        ForEach(episodes) { ep in
            EpisodeRowButton(episode: ep, store: store)
        }
    }
}
private struct EpisodeRowButton: View {
    let episode: Episode
    let store: PlayerStore
    var body: some View {
        Button { store.play(episode) } label: {
            Text(episodeDateFormatter.string(from: episode.publishedAt))
        }
    }
}

12.15 Memory:ImageCache、Drawable、Canvas

AsyncImage 不缓存,每次 view 出现重下;项目里 EpisodeArtwork 用了自建 ImageCacheCanvasctx.resolve(Image(...)) 返回的 ResolvedImage 持有 GPU drawable,长列表对每行 resolve 大图会迅速吃满 IOSurface。

  • 大图先 downsample 到展示尺寸再 cache
  • NSCache.totalCostLimit 设到设备 RAM 1/8
  • 大型 CanvasdrawingGroup 会吃大块 IOSurface

12.16 反模式合集

// 反模式 1:在 body 里直接 await
struct Bad: View {
    var body: some View {
        Text(await loadTitle())   // 编译错;正确:.task { ... }
    }
}

// 反模式 2:ScrollView 加非必要 .id() 强制 rebuild
ScrollView { ... }.id(refreshCounter)

// 反模式 3:ForEach 用 indices 当 id
ForEach(items.indices, id: \.self) { ... }

// 反模式 4:在 body 里 capture 大对象的 closure
Button { let copy = bigArray; process(copy) } label: { ... }

// 反模式 5:每个 cell 都加 .preference(key:) 冒泡 frame
ForEach(items) { $0.background(GeometryReader { ... .preference(...) }) }

// 反模式 6:动 frame.width 而非 scaleEffect
.frame(width: w).animation(.spring, value: w)

// 反模式 7:把 @StateObject 放进 if 分支
if shown { Foo(store: StateObject(wrappedValue: Store())) }
踩坑速查 / Pitfalls
  • drawingGroup 不是性能开关:动态内容用它每帧重 rasterize,比不用还慢;含 text 子树用了会失去字体 metrics
  • ForEach id 永远用业务 stable idindices / \.self 在数据可变时引发整列 rebuild
  • animate frame.width / height = 必掉帧,用 .scaleEffect / .offset / .rotationEffect
  • blur 半径不能动画,每帧重跑 GPU 模糊 pass
  • @ObservedObject 是粗粒度订阅,能升 iOS 17 就用 @Observable
  • @ViewBuilder 子函数 ≠ 独立 view identity,要复用必须拆子 struct
  • 不要在 body 里 Date() / DateFormatter() / 立即执行闭包
  • ScrollView 上加 .id() 让 contentOffset 归零、cell onAppear 全部重跑
  • PreferenceKey 在长列表逐 cell 上报 = 性能黑洞
  • compositingGroup 与 drawingGroup 都会建中间层 IOSurface
  • Debug build 的 SwiftUI runtime 多埋点,profile 必须 Release + -O
  • TimelineView(.animation) 与 Timer 行为不同,前者绑 vsync,后者按 wall clock