mcpField
S
swiftui-animations

swiftui-animations

github.com/tryhuset设计与 UI656 次安装23 周前更新
代码仓库

关于 swiftui-animations

面向开发者的 SwiftUI 动画技能,介绍状态驱动的动画、转场、手势动画、现代 API 与无障碍实践。

主要功能

  1. 01状态驱动动画指南
  2. 02隐式与显式动画模式
  3. 03视图转场与手势效果
  4. 04PhaseAnimator 与 KeyframeAnimator
  5. 05适配减少动态效果设置

适用场景

  1. 01设计 SwiftUI 动画交互
  2. 02构建自定义可动画效果
  3. 03制作响应手势的动画
  4. 04适配减少动态效果设置

安装

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

$npx skills add https://github.com/tryhuset/agent-skills --skill swiftui-animations

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

详细介绍

你是 SwiftUI 动画专家。帮助开发者遵循 Apple 的最佳实践,创建流畅、高性能且易于访问的动画。

核心原则

SwiftUI 动画的工作原理
  1. 动画由状态变化驱动 – SwiftUI 会为新旧状态之间的差异添加动画
  2. 事务向下传播 – 使用 withAnimation 包裹状态变化时,事务会沿视图层级传递
  3. 只有可动画数据才会动 – 类型必须遵循 Animatable(大多数内置类型都遵循该协议:CGFloat、Color、CGSize 等)
  4. 动画采用声明式方式 – 你只需描述最终状态,SwiftUI 会计算插值
思维模型
State Change → Transaction Created → View Diffed → Animatable Properties Interpolated → Frames Rendered

可以把动画看作两个视图快照之间的自动插值。你的任务是:

  1. 清晰地定义两种状态
  2. 告诉 SwiftUI 使用哪种时间曲线
  3. 确保你想要添加动画的属性遵循 Animatable

隐式动画

这是最简单的动画方式——添加 .animation() 修饰符,或使用 withAnimation 包裹状态变化。

withAnimation(推荐)

使用它包裹状态变化,即可为由此产生的所有视图更新添加动画:

withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
    isExpanded.toggle()
}

适用场景: 你能控制状态变化时(按钮点击、手势、响应)。

.animation() 修饰符

当观察的值发生变化时添加动画:

Circle()
    .scaleEffect(isActive ? 1.2 : 1.0)
    .animation(.easeInOut(duration: 0.3), value: isActive)

适用场景: 状态变化不受你控制时(绑定、父视图状态)。

重要: 始终使用 value: 参数。不带值的旧写法 .animation(.default) 已弃用,且会导致不可预测的行为。

时间曲线
曲线使用场景
.linear机械式匀速运动(进度条)
.easeIn由用户操作触发、进入画面的对象
.easeOut回到位的对象
.easeInOut通用 UI、对称运动
.spring(duration:bounce:)iOS 默认首选 – 自然的效果
.spring(response:dampingFraction:)精细调节弹簧物理效果
.interactiveSpring手势驱动、响应迅速

经验法则: 除非有特殊原因,否则使用 .spring()。Apple 的 HIG 建议大多数交互使用弹簧动画。

显式动画

适用于隐式动画无法满足需求的自定义类型和复杂动画。

Animatable 协议

只要提供 animatableData 属性,任何类型都可以添加动画:

struct AnimatableGradient: View, Animatable {
    var progress: CGFloat

    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }

    var body: some View {
        // Use progress (0→1) to interpolate colors, positions, etc.
    }
}

SwiftUI 会反复调用 setter,并传入新旧状态之间经过插值的值。

AnimatableModifier

用于创建可复用的动画效果:

struct CountingModifier: AnimatableModifier {
    var value: Double

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

    func body(content: Content) -> some View {
        content.overlay(Text("\(Int(value))"))
    }
}

// Usage: Text counts from 0 to 100
Text("Score").modifier(CountingModifier(value: score))
为形状添加动画

形状通过其 animatableData 属性实现动画。对于多个值,可使用 AnimatablePair:

struct Wedge: Shape {
    var startAngle: Double
    var endAngle: Double

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

    func path(in rect: CGRect) -> Path { /* ... */ }
}

对于 3 个或更多值,使用嵌套的 Pair:AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>

转场

转场用于为进入或离开视图层级的视图添加动画。仅在插入或移除视图时生效(通过 if、switch、ForEach 变化)。

内置转场
if showDetail {
    DetailView()
        .transition(.slide)
}
转场效果
.opacity淡入/淡出
.slide从前导边缘滑入
.move(edge:)从指定边缘滑入
.scale从中心放大
.scale(anchor:)从锚点放大
.push(from:)推入,同时将旧视图推出
.offset(x:y:)从偏移位置开始添加动画
组合转场
.transition(.scale.combined(with: .opacity))

// Or use extension for reusability:
extension AnyTransition {
    static var scaleAndFade: AnyTransition {
        .scale(scale: 0.8).combined(with: .opacity)
    }
}
非对称转场

插入和移除时使用不同的动画:

.transition(.asymmetric(
    insertion: .move(edge: .trailing).combined(with: .opacity),
    removal: .move(edge: .leading).combined(with: .opacity)
))
自定义转场

基于任意 ViewModifier 构建:

struct SlideAndBlur: ViewModifier {
    let active: Bool

    func body(content: Content) -> some View {
        content
            .offset(x: active ? 200 : 0)
            .blur(radius: active ? 10 : 0)
    }
}

extension AnyTransition {
    static var slideBlur: AnyTransition {
        .modifier(
            active: SlideAndBlur(active: true),
            identity: SlideAndBlur(active: false)
        )
    }
}

注意: 必须使用 withAnimation 包裹添加或移除视图的状态变化,转场才能生效。仅在视图本身添加 .animation() 修饰符不会触发转场。

手势驱动的动画

实时响应用户触摸的交互式动画。

基础拖动与弹簧释放
@State private var offset: CGSize = .zero

var body: some View {
    Circle()
        .offset(offset)
        .gesture(
            DragGesture()
                .onChanged { offset = $0.translation }
                .onEnded { _ in
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                        offset = .zero
                    }
                }
        )
}
使用 GestureState 自动重置

@GestureState 会在手势结束时自动重置,非常适合临时状态:

@GestureState private var dragOffset: CGSize = .zero

var body: some View {
    Card()
        .offset(dragOffset)
        .animation(.interactiveSpring, value: dragOffset)
        .gesture(
            DragGesture()
                .updating($dragOffset) { value, state, _ in
                    state = value.translation
                }
        )
}
速度感知动画

使用手势速度,让释放效果更自然:

.onEnded { gesture in
    let velocity = CGVector(
        dx: gesture.velocity.width / 300,
        dy: gesture.velocity.height / 300
    )
    withAnimation(.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.25).speed(1)) {
        // Factor velocity into final position
    }
}
跟踪动画进度

使用 GeometryReader + PreferenceKey 或 iOS 17 的 onGeometryChange 来读取动画值,以实现协调效果。

手势的弹簧参数
场景推荐弹簧
拖动(实时).interactiveSpring 或无动画
释放回原位.spring(response: 0.4, dampingFraction: 0.7)
释放到目标位置.spring(response: 0.5, dampingFraction: 0.8)
吸附到位置.spring(response: 0.3, dampingFraction: 0.9)

现代 API(iOS 17+)

iOS 17 引入了强大的声明式动画 API,可简化复杂序列。

PhaseAnimator

自动循环播放离散阶段,非常适合循环或多步骤动画:

enum BouncePhase: CaseIterable {
    case initial, compress, stretch, settle

    var scale: CGSize {
        switch self {
        case .initial: CGSize(width: 1, height: 1)
        case .compress: CGSize(width: 1.1, height: 0.9)
        case .stretch: CGSize(width: 0.9, height: 1.1)
        case .settle: CGSize(width: 1, height: 1)
        }
    }
}

PhaseAnimator(BouncePhase.allCases) { phase in
    Circle()
        .scaleEffect(phase.scale)
} animation: { phase in
    switch phase {
    case .initial: .spring(duration: 0.2, bounce: 0.5)
    default: .spring(duration: 0.25, bounce: 0.3)
    }
}

基于触发器: 添加 trigger: 参数,在值发生变化时运行,而不是循环播放:

PhaseAnimator(phases, trigger: triggerValue) { phase in ... }
KeyframeAnimator

通过多个属性上的关键帧进行精细控制:

KeyframeAnimator(initialValue: AnimationState()) { state in
    Circle()
        .offset(x: state.xOffset)
        .scaleEffect(state.scale)
        .opacity(state.opacity)
} keyframes: { _ in
    KeyframeTrack(\.xOffset) {
        LinearKeyframe(0, duration: 0.1)
        SpringKeyframe(100, duration: 0.4, spring: .bouncy)
        SpringKeyframe(0, duration: 0.3)
    }
    KeyframeTrack(\.scale) {
        LinearKeyframe(1.0, duration: 0.1)
        CubicKeyframe(1.3, duration: 0.2)
        CubicKeyframe(1.0, duration: 0.4)
    }
}

关键帧类型:

  • LinearKeyframe – 匀速
  • CubicKeyframe – 贝塞尔缓动
  • SpringKeyframe – 基于物理效果
  • MoveKeyframe – 瞬间跳转(不进行插值)
新的弹簧语法

iOS 17 简化了弹簧参数:

// Old (still works)
.spring(response: 0.5, dampingFraction: 0.7)

// New – more intuitive
.spring(duration: 0.5, bounce: 0.3)  // bounce: 0 = no bounce, 1 = max bounce

// Presets
.spring(.smooth)    // No bounce
.spring(.snappy)    // Slight bounce
.spring(.bouncy)    // Pronounced bounce
如何选择
场景API
单一状态变化withAnimation
循环动画PhaseAnimator
复杂的多属性编排KeyframeAnimator
用户触发的序列带 trigger: 的 PhaseAnimator
精细调整时间控制KeyframeAnimator

关键规则

应该:
  • 始终在 .animation() 修饰符中使用 value: 参数——不带参数的版本已弃用且存在问题
  • 优先使用 withAnimation,而非 .animation()——更明确地控制动画的触发条件
  • 默认使用弹簧动画——相比线性或缓动曲线,弹簧动画感觉更自然
  • 尊重减少动态效果设置——检查 accessibilityReduceMotion(参见“辅助功能”部分)
  • 动画化布局,而非帧——使用 .offset()、.scaleEffect()、.opacity(),而不是直接更改 frame
  • 将 UI 响应动画控制在 400ms 以内——时间更长会显得迟缓
  • 在设备上测试——模拟器的时序与真实硬件不同
不应该:
  • 不要在 body 计算中执行动画——body 应保持纯粹;从状态变化触发动画
  • 不要使用不带 value: 的 .animation()——这会导致不可预测的级联动画
  • 不要与框架对抗——如果某种动画难以实现,请重新考虑实现方式
  • 不要同时动画化过多属性——最多选择 2–3 个,以保持清晰
  • 不要使用 DispatchQueue.main.asyncAfter 排序——请使用 PhaseAnimator 或基于完成回调的 API
  • 不要嵌套 withAnimation 块——以内层为准,外层会被忽略
  • 不要假定动画会完成——SwiftUI 不保证提供完成回调;关键时序控制请使用 Transaction
常见问题
问题原因解决方法
转场没有动画状态变化外部缺少 withAnimation将 if 条件变化包在 withAnimation 中
动画播放两次同时使用了 withAnimation 和 .animation()选择其中一种方式
动画卡顿动画化的属性不可动画检查类型是否遵循 Animatable
弹簧始终无法稳定dampingFraction 过低使用 0.7+ 以使其稳定,或添加 .speed()
动画出现在错误的视图上Transaction 意外传播使用 .transaction { $0.animation = nil } 阻止传播
列表项动画异常缺少稳定的 id确保遵循 Identifiable 且 ID 稳定

辅助功能

尊重减少动态效果设置

用户可以在系统设置中启用“减少动态效果”。始终提供替代方案:

@Environment(\.accessibilityReduceMotion) var reduceMotion

var body: some View {
    Card()
        .transition(reduceMotion ? .opacity : .slide.combined(with: .opacity))
}

// For animations:
func animateChange() {
    if reduceMotion {
        // Instant or simple fade
        withAnimation(.easeOut(duration: 0.15)) {
            isExpanded.toggle()
        }
    } else {
        // Full spring animation
        withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
            isExpanded.toggle()
        }
    }
}
快速辅助函数
extension Animation {
    static func respectful(_ animation: Animation, reducedMotion: Animation = .easeOut(duration: 0.15)) -> Animation {
        // Use at call site with @Environment check
        animation
    }
}

// Or create a View extension:
extension View {
    func animateRespectfully<V: Equatable>(
        _ animation: Animation,
        value: V,
        reduceMotion: Bool
    ) -> some View {
        self.animation(reduceMotion ? .easeOut(duration: 0.15) : animation, value: value)
    }
}
指南
  • 淡化始终安全——.opacity 过渡效果对所有人都适用
  • 减少,而非移除——适度的动效有助于理解,只需让它更微妙
  • 避免诱发前庭不适——大幅缩放、视差和旋转都可能造成问题
  • 在启用该设置时进行测试——设置 → 辅助功能 → 动效 → 减少动态效果

评论

支持 Markdown