mcpField
A
axiom-swiftui-animation-ref

axiom-swiftui-animation-ref

github.com/charleswiltgen设计与 UI1.2k212 次安装今天更新
代码仓库

关于 axiom-swiftui-animation-ref

面向开发者的 SwiftUI 动画系统学习技能,涵盖插值、动画类型、事务和自定义动画。还介绍 iOS 26 的 `@Animatable` 宏,以及内置效果与自定义实现的取舍。

主要功能

  1. 01Animatable 与 VectorArithmetic 基础
  2. 02iOS 26 `@Animatable` 宏
  3. 03时间曲线和弹簧动画
  4. 04作用域动画与事务
  5. 05自定义动画算法
  6. 06自定义协议实现的性能指导

适用场景

  1. 01为自定义形状和布局添加动画
  2. 02选择弹簧或时间曲线效果
  3. 03将动画限定到特定视图属性
  4. 04动画化数值和进度显示

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-animation-ref

安装后在 Claude Code 中输入/axiom-swiftui-animation-ref确认已加载。

详细介绍

SwiftUI 动画

概述

SwiftUI 动画系统全面指南,涵盖从基础概念到高级技巧。本指南介绍 Animatable 协议、iOS 26 的 @Animatable 宏、动画类型和 Transaction 系统。

核心原则 SwiftUI 中的动画是随时间进行的数学插值,由 VectorArithmetic 协议提供支持。理解这一基础,便能充分发挥 SwiftUI 声明式动画系统的强大功能。

系统要求

  • iOS 13+:Animatable 协议、时序动画和弹簧动画
  • iOS 17+:默认弹簧动画、作用域动画、PhaseAnimator、KeyframeAnimator
  • iOS 18+:缩放转场、UIKit/AppKit 动画桥接
  • iOS 26+:@Animatable 宏

第 1 部分:理解动画

什么是插值

动画是生成起始状态与结束状态之间中间值的过程。

示例:透明度动画
.opacity(0) → .opacity(1)

动画运行期间,SwiftUI 会计算中间值:

0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0

值的分布方式

  • 由动画的时序曲线或速度函数决定
  • 弹簧动画使用物理模拟
  • 时序曲线使用贝塞尔曲线
  • 每种动画类型的值计算方式各不相同
VectorArithmetic 协议

SwiftUI 要求动画数据遵循 VectorArithmetic,提供减法、缩放、加法和零值。这使 SwiftUI 能够在任意两个值之间进行插值。

内置遵循该协议的类型:CGFloat、Double、Float、Angle(1D)、CGPoint、CGSize(2D)、CGRect(4D)。

关键见解 向量运算抽象了维数差异。SwiftUI 使用单一泛型实现为所有这些类型添加动画。

为什么 Int 无法添加动画

Int 不遵循 VectorArithmetic——3 和 4 之间不存在小数中间值。SwiftUI 会直接跳变该值。

解决方案:使用 Float/Double,并显示为 Int:

@State private var count: Float = 0
// ...
Text("\(Int(count))")
    .animation(.spring, value: count)
模型值与呈现值

从概念上讲,可动画属性有两个值:

模型值
  • 由代码设置的目标值
  • 状态变化时立即更新
  • 在视图的 body 中编写的值
呈现值
  • 当前正在渲染的插值结果
  • 动画期间逐帧更新
  • 用户实际看到的值

示例

.scaleEffect(selected ? 1.5 : 1.0)

当 selected 变为 true 时:

  • 模型值:立即变为 1.5
  • 呈现值:随时间插值,从 1.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5

第 2 部分:Animatable 协议

概述

Animatable 协议允许视图定义哪些数据应进行插值,从而为其属性添加动画。

protocol Animatable {
    associatedtype AnimatableData: VectorArithmetic

    var animatableData: AnimatableData { get set }
}

SwiftUI 会为遵循此协议的任意视图构建可动画属性。

内置可动画视图

许多 SwiftUI 修饰符都遵循 Animatable:

视觉效果
  • .scaleEffect() — 为缩放变换添加动画
  • .rotationEffect() — 为旋转添加动画
  • .offset() — 为位置偏移添加动画
  • .opacity() — 为透明度添加动画
  • .blur() — 为模糊半径添加动画
  • .shadow() — 为阴影属性添加动画
所有 Shape 类型
  • Circle、Rectangle、RoundedRectangle
  • Capsule、Ellipse、Path
  • 自定义 Shape 实现
用于多维数据的 AnimatablePair

为多个属性添加动画时,可使用 AnimatablePair 组合向量。例如,scaleEffect 通过 AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData> 将 CGSize(2D)和 UnitPoint(2D)组合成 4D 向量。使用 .first 和 .second 访问各个分量。@Animatable 宏(iOS 26+)完全免除了这些样板代码。

自定义 Animatable 遵循实现
适用场景
  • 为自定义布局添加动画(例如 RadialLayout)
  • 为自定义绘图代码添加动画
  • 为影响形状路径的属性添加动画
示例:动画数字视图
struct AnimatableNumberView: View, Animatable {
    var number: Double

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

    var body: some View {
        Text("\(Int(number))")
            .font(.largeTitle)
    }
}

// Usage
AnimatableNumberView(number: value)
    .animation(.spring, value: value)

工作原理

  1. number 从 0 变为 100
  2. SwiftUI 会为动画的每一帧调用 body
  3. 每一帧都会获得新的 number 值:0 → 5 → 15 → 30 → 55 → 80 → 100
  4. 文本会更新,以显示插值后的整数
性能警告

自定义 Animatable 一致性开销很大 —— SwiftUI 会在主线程上为每一帧调用 body。内置效果(.scaleEffect()、.opacity())会在主线程之外运行,且不会调用 body。只有在内置修饰符无法实现所需效果时,才使用自定义一致性(例如,创建一个会在每一帧重新定位子视图的自定义 Layout)。


第 3 部分:@Animatable 宏(iOS 26+)

概述

@Animatable 宏无需手动遵循 Animatable 协议,也无需编写样板代码。

iOS 26 之前,你必须:

  1. 手动遵循 Animatable
  2. 编写 animatableData getter 和 setter
  3. 为多个属性使用 AnimatablePair
  4. 手动排除不可动画的属性

iOS 26+,只需添加 @Animatable:

@MainActor
@Animatable
struct MyView: View {
    var scale: CGFloat
    var opacity: Double

    var body: some View {
        // ...
    }
}

该宏会自动:

  • 生成 Animatable 一致性
  • 检查所有存储属性
  • 根据遵循 VectorArithmetic 的属性创建 animatableData
  • 使用 AnimatablePair 处理多维数据
前后对比
使用 @Animatable 宏之前
struct HikingRouteShape: Shape {
    var startPoint: CGPoint
    var endPoint: CGPoint
    var elevation: Double
    var drawingDirection: Bool // Don't want to animate this

    // Tedious manual animatableData declaration
    var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
                        AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
        get {
            AnimatablePair(
                AnimatablePair(startPoint.x, startPoint.y),
                AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
            )
        }
        set {
            startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
            elevation = newValue.second.first
            endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
        }
    }

    func path(in rect: CGRect) -> Path {
        // Drawing code
    }
}
使用 @Animatable 宏之后
@Animatable
struct HikingRouteShape: Shape {
    var startPoint: CGPoint
    var endPoint: CGPoint
    var elevation: Double

    @AnimatableIgnored
    var drawingDirection: Bool // Excluded from animation

    func path(in rect: CGRect) -> Path {
        // Drawing code
    }
}

代码行数:20 → 12(减少 40%)

@AnimatableIgnored

使用 @AnimatableIgnored 可将属性排除在动画之外。

适用场景
  • 调试值 —— 仅用于开发的标志
  • ID —— 不应参与动画的标识符
  • 时间戳 —— 视图的创建或更新时间
  • 内部状态 —— 非视觉用途的记录状态
  • 非 VectorArithmetic 类型 —— 颜色、字符串、布尔值
示例
@MainActor
@Animatable
struct ProgressView: View {
    var progress: Double // Animated
    var totalItems: Int // Animated (if Float, not if Int)

    @AnimatableIgnored
    var title: String // Not animated

    @AnimatableIgnored
    var startTime: Date // Not animated

    @AnimatableIgnored
    var debugEnabled: Bool // Not animated

    var body: some View {
        VStack {
            Text(title)
            ProgressBar(value: progress)
            if debugEnabled {
                Text("Started: \(startTime.formatted())")
            }
        }
    }
}
实际应用场景

@Animatable 适用于任何数值显示场景——股价、心率、分数、计时器、进度条:

@MainActor
@Animatable
struct AnimatedValueView: View {
    var value: Double
    var changePercent: Double

    @AnimatableIgnored
    var label: String

    var body: some View {
        VStack(alignment: .trailing) {
            Text("\(value, format: .number.precision(.fractionLength(2)))")
                .font(.title)
            Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
                .foregroundStyle(changePercent > 0 ? .green : .red)
        }
    }
}

// Usage
AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price")
    .animation(.spring(duration: 0.8), value: currentPrice)

第 4 部分:动画类型

时间曲线动画

时间曲线动画使用贝塞尔曲线控制动画随时间变化的速度。

内置预设
.animation(.linear)          // Constant speed
.animation(.easeIn)          // Starts slow, ends fast
.animation(.easeOut)         // Starts fast, ends slow
.animation(.easeInOut)       // Slow start and end, fast middle
自定义时间曲线
let customCurve = UnitCurve(
    startControlPoint: CGPoint(x: 0.2, y: 0),
    endControlPoint: CGPoint(x: 0.8, y: 1)
)

.animation(.timingCurve(customCurve, duration: 0.5))
时长

所有时间曲线动画都接受可选的时长:

.animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0))

默认值:0.35 秒

弹簧动画

弹簧动画使用物理模拟,营造自然、流畅的运动效果。

内置预设
.animation(.smooth)     // No bounce (default since iOS 17)
.animation(.snappy)     // Small amount of bounce
.animation(.bouncy)     // Larger amount of bounce
自定义弹簧
.animation(.spring(duration: 0.6, bounce: 0.3))

参数

  • duration —— 感知到的动画时长
  • bounce —— 弹跳程度(0 = 不弹跳,1 = 弹跳幅度很大)

比传统弹簧参数(质量、刚度、阻尼)直观得多。

高阶动画

修改基础动画以创建复杂效果。

延迟
.animation(.spring.delay(0.5))

等待 0.5 秒后开始动画。

重复
.animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false))

重复播放动画多次或无限次。

速度
.animation(.spring.speed(2.0))  // 2x faster
.animation(.spring.speed(0.5))  // 2x slower

按倍数调整动画速度。

默认动画变更(iOS 17+)

iOS 17 之前

withAnimation {
    // Used timing curve by default
}

iOS 17+

withAnimation {
    // Uses .smooth spring by default
}

变更原因:弹簧动画感觉更自然,并且在动画被中断时能保留速度。

建议:采用弹簧动画。它们能让 UI 响应更灵敏、质感更出色。


第 5 部分:事务系统

withAnimation

触发动画最常见的方式。

Button("Scale Up") {
    withAnimation(.spring) {
        scale = 1.5
    }
}

工作原理

  1. withAnimation 开启一个事务
  2. 在事务字典中设置动画
  3. 执行闭包(状态发生变化)
  4. 事务沿视图层级向下传递
  5. 可动画属性会检查是否存在动画,并进行插值
显式动画
withAnimation(.spring(duration: 0.6, bounce: 0.4)) {
    isExpanded.toggle()
}
无动画
withAnimation(nil) {
    // Changes happen immediately, no animation
    resetState()
}
animation() 视图修饰符

将动画应用于视图中的特定值。

基本用法
Circle()
    .fill(isActive ? .blue : .gray)
    .animation(.spring, value: isActive)

工作原理:只有在 isActive 发生变化时,动画才会生效。其他状态变化不会触发此动画。

同一视图上的多个动画
Circle()
    .scaleEffect(scale)
    .animation(.bouncy, value: scale)
    .opacity(opacity)
    .animation(.easeInOut, value: opacity)

为不同属性设置不同的动画。

作用域动画(iOS 17+)

将动画限定到特定的可动画属性。

旧方法的问题
struct AvatarView: View {
    var selected: Bool

    var body: some View {
        Image("avatar")
            .scaleEffect(selected ? 1.5 : 1.0)
            .animation(.spring, value: selected)
            // ⚠️ If image also changes when selected changes,
            //    image transition gets animated too (accidental)
    }
}
解决方案:作用域动画
struct AvatarView: View {
    var selected: Bool

    var body: some View {
        Image("avatar")
            .animation(.spring, value: selected) {
                $0.scaleEffect(selected ? 1.5 : 1.0)
            }
            // ✅ Only scaleEffect animates, image transition doesn't
    }
}

工作原理

  • 动画仅应用于闭包中的属性
  • 其他属性不受影响
  • 防止意外触发动画
自定义 Transaction 键

定义自定义 TransactionKey 类型,以便通过事务系统传递上下文。使用 withTransaction 设置值,并使用 .transaction 修饰符读取值。这样可以根据状态变化的触发方式(轻点或编程触发)应用不同的动画。


第 6 部分:高级主题

CustomAnimation 协议

实现自己的动画算法。

protocol CustomAnimation {
    // Calculate current value
    func animate<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V?

    // Optional: Should this animation merge with previous?
    func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool

    // Optional: Current velocity
    func velocity<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: AnimationContext<V>
    ) -> V?
}
示例:线性时间曲线
struct LinearAnimation: CustomAnimation {
    let duration: TimeInterval

    func animate<V: VectorArithmetic>(
        value: V,              // Delta vector: target - current
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V? {
        if time >= duration { return nil }
        return value.scaled(by: time / duration)
    }
}

关键理解:value 是增量向量(目标值 - 当前值),而不是目标值。完成后返回 nil。SwiftUI 会自动将缩放后的增量加到当前值上。

动画合并行为

如果在上一个动画结束前启动新动画,会发生什么?

时间曲线动画(默认:不合并)
func shouldMerge(...) -> Bool {
    return false // Default implementation
}

行为:两个动画同时运行,结果以加法方式合并。

示例

  • 第一次轻点:将 1.0 动画到 1.5(正在运行)
  • 第二次轻点(结束前):将 1.5 动画到 1.0
  • 结果:两个动画同时运行,数值合并
弹簧动画(合并并重新设定目标)
func shouldMerge(...) -> Bool {
    return true // Springs override this
}

行为:新动画会纳入上一个动画的状态,并保留速度。

示例

  • 第一次轻点:以速度 V 将 1.0 动画到 1.5
  • 第二次轻点(结束前):将目标改为 1.0,同时保留当前速度 V
  • 结果:平滑过渡,速度不会突变

弹簧动画感觉更自然的原因:中断时会保留动量。


第 7 部分:多步动画(iOS 17+)

PhaseAnimator

循环播放一系列阶段,并在每个阶段应用不同的修饰符。每次阶段转换都会单独进行动画处理。

PhaseAnimator([false, true]) { phase in
    Image(systemName: "star.fill")
        .scaleEffect(phase ? 1.5 : 1.0)
        .opacity(phase ? 1.0 : 0.5)
        .rotationEffect(.degrees(phase ? 360 : 0))
} animation: { phase in
    phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4)
}

工作原理:从第一个阶段开始,动画过渡到第二个阶段,然后循环播放。animation 闭包会返回用于过渡到该阶段的动画。阶段可以是任意 Equatable 类型——对于复杂的多步序列,请使用枚举:

enum PulsePhase: CaseIterable { case idle, expand, contract }

PhaseAnimator(PulsePhase.allCases) { phase in
    Circle()
        .scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0)
}

触发方式:添加 trigger 参数,仅在值变化时运行动画(而不是持续循环)。

KeyframeAnimator

为每个属性提供关键帧轨道,实现精确的、基于时间线的动画。比 PhaseAnimator 提供更精细的控制。

struct AnimationValues {
    var scale: Double = 1.0
    var rotation: Angle = .zero
    var yOffset: Double = 0
}

KeyframeAnimator(initialValue: AnimationValues()) { values in
    Image(systemName: "heart.fill")
        .scaleEffect(values.scale)
        .rotationEffect(values.rotation)
        .offset(y: values.yOffset)
} keyframes: { _ in
    KeyframeTrack(\.scale) {
        SpringKeyframe(1.5, duration: 0.3)
        SpringKeyframe(1.0, duration: 0.3)
    }
    KeyframeTrack(\.rotation) {
        LinearKeyframe(.degrees(15), duration: 0.15)
        LinearKeyframe(.degrees(-15), duration: 0.3)
        LinearKeyframe(.zero, duration: 0.15)
    }
    KeyframeTrack(\.yOffset) {
        CubicKeyframe(-20, duration: 0.3)
        CubicKeyframe(0, duration: 0.3)
    }
}

关键帧类型:LinearKeyframe(恒定速度)、SpringKeyframe(弹簧物理效果)、CubicKeyframe(贝塞尔曲线)、MoveKeyframe(瞬间跳变,不进行插值)。

与 PhaseAnimator 的区别:简单的状态循环使用 PhaseAnimator。不同属性需要独立计时时使用 KeyframeAnimator。

.transition()

定义视图插入视图层级或从中移除时的动画方式。

if showDetail {
    DetailView()
        .transition(.slide)                          // Slide in/out
        .transition(.scale.combined(with: .opacity)) // Combine transitions
        .transition(.move(edge: .bottom))            // Move from edge
        .transition(.asymmetric(                     // Different in/out
            insertion: .scale.combined(with: .opacity),
            removal: .opacity
        ))
}

需要动画上下文——使用 withAnimation 包裹状态变化,或使用 .animation() 修饰符。没有动画时,视图会立即出现或消失。

matchedGeometryEffect

在视图层级中的两个位置之间平滑地动画过渡视图的框架。常用于主视觉过渡和共享元素动画。

@Namespace private var animation

// Source
if !isExpanded {
    RoundedRectangle(cornerRadius: 10)
        .matchedGeometryEffect(id: "card", in: animation)
        .frame(width: 100, height: 100)
}

// Destination
if isExpanded {
    RoundedRectangle(cornerRadius: 20)
        .matchedGeometryEffect(id: "card", in: animation)
        .frame(width: 300, height: 400)
}

关键规则:相同的 id + 相同的 Namespace = 匹配的一对视图。同一时间,具有给定 ID 的视图中只能有一个设置为 isSource: true(默认值)。使用 withAnimation 包裹状态变化,以实现平滑插值。

contentTransition

为视图中的文本和符号内容变化添加动画效果(iOS 16+)。

Text(value, format: .number)
    .contentTransition(.numericText(countsDown: value < previous))

Image(systemName: isFavorite ? "heart.fill" : "heart")
    .contentTransition(.symbolEffect(.replace))

第 8 部分:缩放过渡(iOS 18+)

概述

iOS 18 引入了缩放转场,点击的单元格会变形为进入的视图。此转场始终可交互——用户可以在转场开始期间或之后抓住视图并拖动。

主要优势 在从大型单元格进行转场的应用部分中,缩放转场会让相同的 UI 元素在转场过程中始终显示在屏幕上,从而增强视觉连贯性。

SwiftUI 实现

采用缩放转场分为两步:

第 1 步:在目标视图上声明转场样式
NavigationLink {
    BraceletEditor(bracelet)
        .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
    BraceletPreview(bracelet)
}
第 2 步:标记源视图
BraceletPreview(bracelet)
    .matchedTransitionSource(id: bracelet.id, in: namespace)
完整示例
struct BraceletListView: View {
    @Namespace private var braceletList
    let bracelets: [Bracelet]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
                    ForEach(bracelets) { bracelet in
                        NavigationLink {
                            BraceletEditor(bracelet: bracelet)
                                .navigationTransition(
                                    .zoom(sourceID: bracelet.id, in: braceletList)
                                )
                        } label: {
                            BraceletPreview(bracelet: bracelet)
                        }
                        .matchedTransitionSource(id: bracelet.id, in: braceletList)
                    }
                }
            }
        }
    }
}
UIKit 实现

在被推送的视图控制器上设置 preferredTransition = .zoom { context in ... }。闭包会返回源视图,并且在放大和缩小时都会被调用——请捕获稳定的标识符(模型对象),而不是直接捕获视图。

展示方式

缩放转场也适用于 fullScreenCover 和 sheet:

.fullScreenCover(item: $selectedBracelet) { bracelet in
    BraceletEditor(bracelet: bracelet)
        .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}
设置源视图样式
.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
    source.cornerRadius(8.0).shadow(radius: 4)
}
流畅的转场生命周期

推送转场无法取消——被中断时会转换为弹出转场。视图控制器始终会到达 Appeared 状态。不要针对重叠转场添加保护逻辑;让系统处理即可。


第 9 部分:UIKit/AppKit 动画桥接(iOS 18+)

概述

iOS 18 支持使用 SwiftUI Animation 类型为 UIKit 和 AppKit 视图添加动画。这让你可以使用完整的 SwiftUI 动画套件,包括自定义动画。

基本用法
// Old way
UIView.animate(withDuration: 0.5, delay: 0,
               usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
    bead.center = endOfBracelet
}

// New way: Use SwiftUI Animation type
UIView.animate(.spring(duration: 0.5)) {
    bead.center = endOfBracelet
}

所有 SwiftUI 动画都可用:.linear、.easeIn/Out、.spring、.smooth、.snappy、.bouncy、.repeatForever(),以及自定义动画。

架构说明:与旧版 UIKit API 不同,不会生成 CAAnimation——而是直接为呈现值添加动画。


第 10 部分:UIViewRepresentable 动画桥接(iOS 18+)

问题

将 UIKit 视图包装在 SwiftUI 中时,动画不会自动桥接:

struct BeadBoxWrapper: UIViewRepresentable {
    @Binding var isOpen: Bool

    func updateUIView(_ box: BeadBox, context: Context) {
        // ❌ Animation on binding doesn't affect UIKit
        box.lid.center.y = isOpen ? -100 : 100
    }
}

// Usage
BeadBoxWrapper(isOpen: $isOpen)
    .animation(.spring, value: isOpen)  // No effect on UIKit view
解决方案:context.animate()

使用 context.animate() 桥接 SwiftUI 动画:

struct BeadBoxWrapper: UIViewRepresentable {
    @Binding var isOpen: Bool

    func makeUIView(context: Context) -> BeadBox {
        BeadBox()
    }

    func updateUIView(_ box: BeadBox, context: Context) {
        // ✅ Bridges animation from Transaction to UIKit
        context.animate {
            box.lid.center.y = isOpen ? -100 : 100
        }
    }
}
工作原理
  1. SwiftUI 将动画信息存储在当前 Transaction 中
  2. context.animate() 读取 Transaction 的动画
  3. 将该动画应用于闭包中的 UIView 更改
  4. 如果 Transaction 中没有动画,更改会立即生效(无动画)
关键行为
context.animate {
    // Changes here
} completion: {
    // Called when animation completes
    // If not animated, called immediately inline
}

无论是否启用动画都适用——始终使用此模式都是安全的。

完美同步

在 SwiftUI 视图和 UIView 之间运行的单个动画能够完美同步。这让混合层级结构也能流畅衔接。


第 11 部分:手势驱动的动画(iOS 18+)

自动保留速度

SwiftUI 动画会通过动画合并自动保留速度——无需手动计算速度:

// UIKit with SwiftUI animations
func handlePan(_ gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .changed:
        UIView.animate(.interactiveSpring) {
            bead.center = gesture.location(in: view)
        }
    case .ended:
        UIView.animate(.spring) {  // Inherits velocity automatically
            bead.center = endOfBracelet
        }
    default: break
    }
}

// Pure SwiftUI equivalent
DragGesture()
    .onChanged { value in
        withAnimation(.interactiveSpring) { position = value.location }
    }
    .onEnded { _ in
        withAnimation(.spring) { position = targetPosition }
    }

每个 .interactiveSpring 都会重新设定前一个动画的目标,而最终的 .spring 会继承累积的速度,实现平滑减速。



故障排除

属性未添加动画

依次检查:

  1. 类型是否遵循 VectorArithmetic? — Int 无法添加动画;请使用 Double/Float
  2. 是否存在动画修饰符? — 需要 .animation(.spring, value: x) 或 withAnimation
  3. 跟踪的值是否正确? — 应使用 .animation(.spring, value: progress),而不是 .animation(.spring, value: title)
  4. 视图是否遵循 Animatable? — 自定义视图需要使用 @Animatable(iOS 26+)或手动实现 animatableData
动画卡顿

自定义的 Animatable 遵循会在主线程上每帧调用一次 body。尽可能使用内置效果(.opacity()、.scaleEffect())——它们会在主线程之外运行。对于复杂情况,请使用 Instruments 进行性能分析。

意外的动画合并

弹簧动画默认会合并,并保留速度。如果不希望出现合并行为,请使用计时曲线动画(.easeInOut)。请参阅上文的动画合并行为部分。

资源

WWDC:2023-10156、2023-10157、2023-10158、2024-10145、2025-256

文档:/swiftui/animatable、/swiftui/animation、/swiftui/vectorarithmetic、/swiftui/transaction、/swiftui/view/navigationtransition(:)、/swiftui/view/matchedtransitionsource(id:in:configuration:)、/uikit/uiview/animate(:changes:completion:)

技能:axiom-swiftui-26-ref、axiom-swiftui-nav-ref、axiom-swiftui-performance、axiom-swiftui-debugging、axiom-sf-symbols-ref

评论

支持 Markdown