mcpField
S
swiftui-animation

swiftui-animation

github.com/dpearson2699设计与 UI1.2k5.5k 次安装13 周前更新
代码仓库

关于 swiftui-animation

面向 SwiftUI 开发者的动画技能,用于审查、编写和修复动画。涵盖现代动画 API、时序选择、转场与辅助功能处理,并强调减少动态效果支持。

主要功能

  1. 01动画类型与 API 选择
  2. 02状态驱动与多阶段动画
  3. 03关键帧和共享元素转场
  4. 04SF Symbols 动效与渲染
  5. 05减少动态效果审查

适用场景

  1. 01为状态变化添加合适动画
  2. 02设计视图插入和移除转场
  3. 03排查动画范围或性能问题
  4. 04检查减少动态效果支持

安装

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

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-animation

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

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
references/
animation-advanced.md
core-animation-bridge.md

详细介绍

SwiftUI 动画(iOS 26+)

审查、编写和修复 SwiftUI 动画。使用 Swift 6.3 模式,应用现代动画 API,并正确处理时序、转场和辅助功能。

目录

分诊工作流

步骤 1:确定动画类别
类别API适用场景
状态驱动withAnimation、.animation(_:body:)、.animation(_:value:)显式状态变化、对选定修饰符应用动画,或简单的值绑定变化
多阶段PhaseAnimator按序执行多步骤动画
关键帧KeyframeAnimator复杂的多属性编排
共享元素matchedGeometryEffect由布局驱动的主视觉转场
导航matchedTransitionSource + .navigationTransition(.zoom)NavigationStack 推入/弹出缩放
视图生命周期.transition()插入和移除
文本内容.contentTransition()原位改变文本/数字
符号.symbolEffect()SF Symbol 动画
自定义CustomAnimation 协议新颖的时序曲线
Core Animation 桥接CALayer、CAAnimation、CADisplayLink提供建议前,请先阅读 references/core-animation-bridge.md
步骤 2:选择动画曲线
.easeInOut(duration: 0.3)       // mechanical timing
.smooth                         // fluid, no bounce
.snappy                         // responsive, small bounce
.bouncy                         // playful, visible bounce
.spring(duration: 0.5, bounce: 0.3)

预设无法表达预期动效时,请参阅高级目录。

步骤 3:应用并验证
  • 确认动画由正确的状态变化触发。
  • 开启“辅助功能”>“减少动态效果”进行测试。
  • 确认动画内容闭包中没有执行开销大的工作。
  • 对于 CA 桥接,使用 Coordinator 处理代理,使显示链接失效,将帧率范围视为参考,并根据实际刷新率调整工作量。

withAnimation(显式动画)

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

// With completion (iOS 17+)
withAnimation(.smooth(duration: 0.35), completionCriteria: .logicallyComplete) {
    isExpanded = true
} completion: { loadContent() }

隐式动画

使用 withAnimation 管理状态变更,使用 .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.smooth
Spring.snappy
Spring.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 {
        Button { trigger.toggle() } label: {
            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)
                    }
                }
        }
        .buttonStyle(.plain)
    }
}

关键帧类型:LinearKeyframe(线性)、CubicKeyframe(平滑曲线)、SpringKeyframe(弹簧物理效果)、MoveKeyframe(瞬间跳变)。

使用 repeating: true 循环播放关键帧动画。 Swift 6:关键帧闭包为 @Sendable;请在修饰符之前捕获状态/环境值。

@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 {
        // draw wave using frequency, amplitude, phase
    }
}

规则:

  • 存储属性必须遵循 VectorArithmetic。
  • 使用 @AnimatableIgnored 排除不可动画的属性。
  • 计算属性永远不会包含在内。

matchedGeometryEffect(iOS 14+)

同步视图之间的几何形状,以实现共享元素动画。

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

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

每个 ID 应恰好有一个源视图可见;否则结果未定义。

Navigation Zoom Transition(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,而非内部容器。

Transitions(iOS 17+)

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

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

请参阅所有 Transition 类型, 了解内置类型目录和自定义 Transition 示例。

非对称 Transition:

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

ContentTransition(iOS 16+)

为原位内容变化添加动画,无需插入或移除视图。

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

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

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

Symbol Effects(iOS 17+)

使用语义效果为 SF Symbols 添加动画。.bounce、.pulse、.variableColor、 .scale、.appear、.disappear 和 .replace 需要 iOS 17+;.breathe、 .rotate 和 .wiggle 需要 iOS 18+。

// Discrete (triggers on value change)
Image(systemName: "bell.fill").symbolEffect(.bounce, value: notificationCount)

// iOS 18+
Image(systemName: "arrow.clockwise")
    .symbolEffect(.wiggle.clockwise, value: refreshCount)

// Indefinite (active while condition holds)
Image(systemName: "wifi").symbolEffect(.pulse, isActive: isSearching)

// iOS 18+
Image(systemName: "mic.fill")
    .symbolEffect(.breathe, isActive: isRecording)

// Variable color with chaining
Image(systemName: "speaker.wave.3.fill")
    .symbolEffect(
        .variableColor.iterative.reversing.dimInactiveLayers,
        options: .repeating,
        isActive: isPlaying
    )

范围:.byLayer、.wholeSymbol。方向因效果而异。

Symbol Rendering Modes

使用 .symbolRenderingMode(_:) 选择 .monochrome、.hierarchical、.multicolor 或 .palette;使用 .foregroundStyle 提供调色板颜色。

**可变符号:**使用 Image(systemName:variableValue:)(iOS 16+)实现百分比填充。使用 .symbolVariableValueMode(_:)(iOS 26+)选择 .draw 或 .color。

Image(systemName: "wifi", variableValue: signalStrength) // 0.0...1.0
    .symbolVariableValueMode(.draw) // iOS 26+

文档:SymbolRenderingMode · symbolRenderingMode(_:) · Image(systemName:variableValue:) · symbolVariableValueMode(_:)

常见错误

1. 需要精确控制作用范围时使用裸 .animation(_:)
// TOO BROAD — applies when the view changes
.animation(.easeIn)

.animation(.easeIn, value: isVisible) // CORRECT: value-bound

// CORRECT — scope animation to selected modifiers
.animation(.easeIn) { content in
    content.opacity(isVisible ? 1.0 : 0.0)
}

withAnimation(.easeIn) { isVisible.toggle() } // CORRECT: own mutation
2. 在动画闭包中执行耗时操作或读取隔离于 Actor 的值

keyframeAnimator / PhaseAnimator 内容闭包每帧都会运行。请预先计算耗时的值,只为视觉属性添加动画,并在 @Sendable 关键帧闭包外捕获状态/环境值。

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

对于符号,移除继承的效果;对幅度较大的动态效果使用 reduceMotion ? .none : animation 进行控制。

@Environment(\.accessibilityReduceMotion) private var reduceMotion
Image(systemName: "wifi").symbolEffect(.pulse, isActive: isSearching).symbolEffectsRemoved(reduceMotion)
4. 存在多个 matchedGeometryEffect 源

同一时间每个 ID 只能有一个源视图可见。同一 ID 对应多个可见源视图会导致布局未定义。

5. 使用 DispatchQueue 或 UIView.animate
// WRONG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { isVisible = true } }
// CORRECT
withAnimation(.spring.delay(0.5)) { isVisible = true }
6. 忘记为 ContentTransition 添加动画
// WRONG — no animation, content transition has no effect
Text("\(count)").contentTransition(.numericText(countsDown: true))
// CORRECT — pair with animation
Text("\(count)")
    .contentTransition(.numericText(countsDown: true))
    .animation(.snappy, value: count)
7. navigationTransition 应用在了错误的视图上

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

审查清单

  • 动画曲线符合意图(自然效果使用 spring,机械效果使用 ease)
  • withAnimation 包裹状态变化;隐式动画使用 .animation(_:body:) 选择性控制修饰符作用范围,或使用带显式值的 .animation(_:value:)
  • matchedGeometryEffect 每个 ID 恰好有一个源;zoom 使用匹配的 id/namespace
  • 适用时使用 @Animatable 宏;只有在自定义打包方式更清晰时才保留手动的 animatableData
  • 检查了 accessibilityReduceMotion;未使用 DispatchQueue/UIView.animate
  • Transition 使用 .transition();contentTransition 与动画配合使用,并采用适用的最窄隐式动画作用范围
  • 动画状态在 @MainActor 上变化;驱动动画的类型符合 Sendable 要求

参考资料

评论

支持 Markdown