🚀 快速安装

复制以下命令并运行,立即安装此 Skill:

npx skills add https://skills.sh/dpearson2699/swift-ios-skills/swiftui-animation

💡 提示:需要 Node.js 和 NPM

SwiftUI 动画 (iOS 26+)

审查、编写和修复 SwiftUI 动画。使用 Swift 6.2 模式,应用具有正确时序、过渡和无障碍处理的现代动画 API。

目录

分类工作流程

步骤 1:识别动画类别

类别 API 使用时机
状态驱动 withAnimation.animation(_:body:).animation(_:value:) 显式状态更改、选择性修饰符动画或简单的值绑定更改
多阶段 PhaseAnimator 序列化的多步骤动画
关键帧 KeyframeAnimator 复杂的多属性编排
共享元素 matchedGeometryEffect 布局驱动的英雄过渡
导航 matchedTransitionSource + .navigationTransition(.zoom) NavigationStack 推入/弹出缩放
视图生命周期 .transition() 插入和移除
文本内容 .contentTransition() 原地文本/数字更改
符号 .symbolEffect() SF Symbol 动画
自定义 CustomAnimation 协议 新颖的时序曲线

步骤 2:选择动画曲线

// 时序曲线
.linear                              // 恒定速度
.easeIn(duration: 0.3)              // 慢启动
.easeOut(duration: 0.3)             // 慢结束
.easeInOut(duration: 0.3)           // 慢启动和结束

// 弹簧预设 (自然运动首选)
.smooth                              // 无弹跳,流畅
.smooth(duration: 0.5, extraBounce: 0.0)
.snappy                              // 小弹跳,响应迅速
.snappy(duration: 0.4, extraBounce: 0.1)
.bouncy                              // 明显弹跳,活泼
.bouncy(duration: 0.5, extraBounce: 0.2)

// 自定义弹簧
.spring(duration: 0.5, bounce: 0.3, blendDuration: 0.0)
.spring(Spring(duration: 0.6, bounce: 0.2), blendDuration: 0.0)
.interactiveSpring(response: 0.15, dampingFraction: 0.86)

步骤 3:应用并验证

  • 确认动画在正确的状态更改时触发。
  • 在启用“辅助功能 > 减弱动态效果”的情况下进行测试。
  • 验证没有昂贵的操作在动画内容闭包内运行。

withAnimation (显式动画)

withAnimation(.spring) { isExpanded.toggle() }

// 带完成处理 (iOS 17+)
withAnimation(.smooth(duration: 0.35), completionCriteria: .logicallyComplete) {
    isExpanded = true
} completion: { loadContent() }

隐式动画

当只有特定修饰符需要动画时,优先使用 .animation(_:body:)
对于可以一起动画视图可动画修饰符的简单值绑定更改,使用 .animation(_:value:)

Badge()
    .foregroundStyle(isActive ? .green : .secondary)
    .animation(.snappy) { content in
        content
            .scaleEffect(isActive ? 1.15 : 1.0)
            .opacity(isActive ? 1.0 : 0.7)
    }
Circle()
    .scaleEffect(isActive ? 1.2 : 1.0)
    .opacity(isActive ? 1.0 : 0.6)
    .animation(.bouncy, value: isActive)

Spring 类型 (iOS 17+)

四种初始化形式对应不同的心智模型。

// 感知型 (首选)
Spring(duration: 0.5, bounce: 0.3)

// 物理型
Spring(mass: 1.0, stiffness: 100.0, damping: 10.0)

// 基于响应
Spring(response: 0.5, dampingRatio: 0.7)

// 基于稳定时间
Spring(settlingDuration: 1.0, dampingRatio: 0.8)

三个预设对应 Animation 预设:.smooth.snappy.bouncy

PhaseAnimator (iOS 17+)

循环遍历离散阶段,每个阶段具有不同的动画曲线。

enum PulsePhase: CaseIterable {
    case idle, grow, shrink
}

struct PulsingDot: View {
    var body: some View {
        PhaseAnimator(PulsePhase.allCases) { phase in
            Circle()
                .frame(width: 40, height: 40)
                .scaleEffect(phase == .grow ? 1.4 : 1.0)
                .opacity(phase == .shrink ? 0.5 : 1.0)
        } animation: { phase in
            switch phase {
            case .idle: .easeIn(duration: 0.2)
            case .grow: .spring(duration: 0.4, bounce: 0.3)
            case .shrink: .easeOut(duration: 0.3)
            }
        }
    }
}

基于触发的变体在每次触发更改时运行一个周期:

PhaseAnimator(PulsePhase.allCases, trigger: tapCount) { phase in
    // ...
} animation: { _ in .spring(duration: 0.4) }

KeyframeAnimator (iOS 17+)

沿着独立时间线动画多个属性。

struct AnimValues {
    var scale: Double = 1.0
    var yOffset: Double = 0.0
    var opacity: Double = 1.0
}

struct BounceView: View {
    @State private var trigger = false

    var body: some View {
        Image(systemName: "star.fill")
            .font(.largeTitle)
            .keyframeAnimator(
                initialValue: AnimValues(),
                trigger: trigger
            ) { content, value in
                content
                    .scaleEffect(value.scale)
                    .offset(y: value.yOffset)
                    .opacity(value.opacity)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.5, duration: 0.3)
                    CubicKeyframe(1.0, duration: 0.4)
                }
                KeyframeTrack(\.yOffset) {
                    CubicKeyframe(-30, duration: 0.2)
                    CubicKeyframe(0, duration: 0.4)
                }
                KeyframeTrack(\.opacity) {
                    LinearKeyframe(0.6, duration: 0.15)
                    LinearKeyframe(1.0, duration: 0.25)
                }
            }
            .onTapGesture { trigger.toggle() }
    }
}

关键帧类型:LinearKeyframe (线性)、CubicKeyframe (平滑曲线)、
SpringKeyframe (弹簧物理)、MoveKeyframe (即时跳转)。

使用 repeating: true 实现循环关键帧动画。

@Animatable 宏

取代手动编写 AnimatableData 样板。附加到任何具有可动画存储属性的类型上。

// 错误:手动实现 AnimatableData (冗长,易错)
struct WaveShape: Shape, Animatable {
    var frequency: Double
    var amplitude: Double
    var phase: Double

    var animatableData: AnimatablePair<Double, AnimatablePair<Double, Double>> {
        get { AnimatablePair(frequency, AnimatablePair(amplitude, phase)) }
        set {
            frequency = newValue.first
            amplitude = newValue.second.first
            phase = newValue.second.second
        }
    }
    // ...
}

// 正确:@Animatable 宏自动合成 animatableData
@Animatable
struct WaveShape: Shape {
    var frequency: Double
    var amplitude: Double
    var phase: Double
    @AnimatableIgnored var lineWidth: CGFloat

    func path(in rect: CGRect) -> Path {
        // 使用 frequency, amplitude, phase 绘制波形
    }
}

规则:

  • 存储属性必须符合 VectorArithmetic
  • 使用 @AnimatableIgnored 排除不可动画的属性。
  • 计算属性永远不会被包含。

matchedGeometryEffect (iOS 14+)

在视图之间同步几何形状,用于共享元素动画。

struct HeroView: View {
    @Namespace private var heroSpace
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            DetailCard()
                .matchedGeometryEffect(id: "card", in: heroSpace)
                .onTapGesture {
                    withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
                        isExpanded = false
                    }
                }
        } else {
            ThumbnailCard()
                .matchedGeometryEffect(id: "card", in: heroSpace)
                .onTapGesture {
                    withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
                        isExpanded = true
                    }
                }
        }
    }
}

要使插值正常工作,每个 ID 在同一时间必须只有一个视图可见。

导航缩放过渡 (iOS 18+)

将源视图上的 matchedTransitionSource 与目标视图上的
.navigationTransition(.zoom(...)) 配对使用。

struct GalleryView: View {
    @Namespace private var zoomSpace
    let items: [GalleryItem]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                    ForEach(items) { item in
                        NavigationLink {
                            GalleryDetail(item: item)
                                .navigationTransition(
                                    .zoom(sourceID: item.id, in: zoomSpace)
                                )
                        } label: {
                            ItemThumbnail(item: item)
                                .matchedTransitionSource(
                                    id: item.id, in: zoomSpace
                                )
                        }
                    }
                }
            }
        }
    }
}

在目标视图上应用 .navigationTransition,而不是在内部容器上。

过渡 (iOS 17+)

控制视图在插入和移除时的动画方式。

if showBanner {
    BannerView()
        .transition(.move(edge: .top).combined(with: .opacity))
}

内置类型:.opacity.slide.scale.scale(_:anchor:)
.move(edge:).push(from:).offset(x:y:).identity
.blurReplace.blurReplace(_:).symbolEffect
.symbolEffect(_:options:)

非对称过渡:

.transition(.asymmetric(
    insertion: .push(from: .bottom),
    removal: .opacity
))

ContentTransition (iOS 16+)

在不插入/移除的情况下,为原地内容更改添加动画。

Text("\(score)")
    .contentTransition(.numericText(countsDown: false))
    .animation(.snappy, value: score)

// 对于 SF Symbols
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
    .contentTransition(.symbolEffect(.replace.downUp))

类型:.identity.interpolate.opacity
.numericText(countsDown:).numericText(value:).symbolEffect

符号效果 (iOS 17+)

使用语义效果为 SF Symbols 添加动画。

// 离散 (在值变化时触发)
Image(systemName: "bell.fill")
    .symbolEffect(.bounce, value: notificationCount)

Image(systemName: "arrow.clockwise")
    .symbolEffect(.wiggle.clockwise, value: refreshCount)

// 持续 (当条件成立时持续)
Image(systemName: "wifi")
    .symbolEffect(.pulse, isActive: isSearching)

Image(systemName: "mic.fill")
    .symbolEffect(.breathe, isActive: isRecording)

// 可变颜色与链式调用
Image(systemName: "speaker.wave.3.fill")
    .symbolEffect(
        .variableColor.iterative.reversing.dimInactiveLayers,
        options: .repeating,
        isActive: isPlaying
    )

所有效果:.bounce.pulse.variableColor.scale.appear
.disappear.replace.breathe.rotate.wiggle

作用域:.byLayer.wholeSymbol。方向因效果而异。

符号渲染模式

使用 .symbolRenderingMode(_:) 控制 SF Symbol 图层的着色方式。

模式 效果 使用时机
.monochrome 均匀应用单一颜色 (默认) 工具栏、与文本匹配的简单图标
.hierarchical 单一颜色,通过不透明度图层增加深度 无需多种颜色的细微深度效果
.multicolor 每层使用系统定义的固定颜色 天气、文件类型 — Apple 预设的调色板
.palette 通过 .foregroundStyle 为每层指定自定义颜色 品牌色、自定义多色图标
// Hierarchical — 单一色调,通过不透明度图层增加深度
Image(systemName: "speaker.wave.3.fill")
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(.blue)

// Palette — 每层自定义颜色
Image(systemName: "person.crop.circle.badge.plus")
    .symbolRenderingMode(.palette)
    .foregroundStyle(.blue, .green)

// Multicolor — 系统定义的颜色
Image(systemName: "cloud.sun.rain.fill")
    .symbolRenderingMode(.multicolor)

可变颜色: .symbolVariableColor(value:) 用于基于百分比的填充(信号强度、音量):

Image(systemName: "wifi")
    .symbolVariableColor(value: signalStrength) // 0.0–1.0

文档: SymbolRenderingMode · symbolRenderingMode(_:)

常见错误

1. 在需要精确作用域时使用裸的 .animation(_:)

// 范围过宽 — 视图改变时就会应用
.animation(.easeIn)

// 正确 — 将动画绑定到一个值
.animation(.easeIn, value: isVisible)

// 正确 — 将动画限定在选定的修饰符
.animation(.easeIn) { content in
    content.opacity(isVisible ? 1.0 : 0.0)
}

2. 在动画闭包内进行昂贵计算

绝对不要在 keyframeAnimator / PhaseAnimator 的内容闭包中运行繁重的计算 — 它们每帧都会执行。提前预计算,只动画视觉属性。

3. 缺少减弱动态效果支持

@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .none : .bouncy) { showDetail = true }

4. 多个 matchedGeometryEffect 源

每个 ID 在同一时间应该只有一个视图可见。两个具有相同 ID 的视图同时可见会导致布局未定义。

5. 使用 DispatchQueue 或 UIView.animate

// 错误
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { isVisible = true } }
// 正确
withAnimation(.spring.delay(0.5)) { isVisible = true }

6. 忘记为 ContentTransition 添加动画

// 错误 — 没有动画,内容过渡无效
Text("\(count)").contentTransition(.numericText(countsDown: true))
// 正确 — 与动画配对
Text("\(count)")
    .contentTransition(.numericText(countsDown: true))
    .animation(.snappy, value: count)

7. 在错误的视图上应用 navigationTransition

在最外层目标视图上应用 .navigationTransition(.zoom(sourceID:in:)),而不是在内部容器上。

审查清单

  • 动画曲线与意图匹配(弹簧用于自然,缓动用于机械)
  • withAnimation 包裹状态更改;隐式动画使用 .animation(_:body:) 限定修饰符作用域,或使用 .animation(_:value:) 搭配显式值
  • matchedGeometryEffect 每个 ID 只有一个源;缩放过渡使用匹配的 id/namespace
  • 使用 @Animatable 宏替代手动编写 animatableData
  • 检查了 accessibilityReduceMotion;没有使用 DispatchQueue/UIView.animate
  • 过渡使用 .transition()contentTransition 与动画配对,并使用最适合的最窄隐式动画作用域
  • 动画状态更改在 @MainActor 上;驱动动画的类型是 Sendable 的

参考资料

  • 参见 references/animation-advanced.md 了解 CustomAnimation 协议、完整的 Spring 变体、所有过渡类型、符号效果详情、事务系统、UnitCurve 类型和性能指南。

📄 原始文档

完整文档(英文):

https://skills.sh/dpearson2699/swift-ios-skills/swiftui-animation

💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。