mcpField
S
swiftui-motion

swiftui-motion

github.com/athevon设计与 UI698940 次安装3 周前更新
代码仓库

关于 swiftui-motion

面向 Claude Code、claude.ai 和 Cowork 的 SwiftUI 动画技能,提供动画 API、弹簧、转场与手势的设计和实现指导,并涵盖无障碍与常见性能问题。

主要功能

  1. 01SwiftUI 动画 API 选择
  2. 02弹簧与转场指导
  3. 03手势驱动的动画模式
  4. 04减少动态效果的无障碍规则
  5. 05常见动画反模式

适用场景

  1. 01为视图选择动画 API
  2. 02构建协调的状态转场
  3. 03添加手势交互
  4. 04检查动态效果无障碍支持

安装

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

$npx skills add https://github.com/athevon/genjutsu --skill swiftui-motion

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

详细介绍

版本敏感。 以下所有 API 名称、SDK 要求和浏览器支持声明均已于 2026-09-08 对照一手资料核实。核实依据和时间见 _jutsu/VERSIONS.md。如果该日期已久,请在依据版本号采取行动前重新核实。

SwiftUI 动效

SwiftUI 动画核心。适用于任何 SwiftUI 项目(iOS、macOS、Apple 多目标项目)。 此处提供简明规则。深入内容见 references/。 搭配 ../motion-principles/SKILL.md(基础知识)和 ../mobile-principles/SKILL.md(触控 UX)使用。


动画 API 决策树

需求API
随时间变化的单个值withAnimation { } + @State 或 .animation(_, value:)
多个协调状态PhaseAnimator(phases)(iOS 17+)
基于时间的关键帧KeyframeAnimator(initialValue:repeating:content:)(iOS 17+)
自定义属性动画@Animatable 宏(需要 Xcode 26 / SwiftUI 2025 SDK 才能编译;运行时支持 iOS 13.0+ / macOS 10.15+ / watchOS 6.0+),或手动实现 Animatable 协议(iOS 13+)
共享元素过渡matchedGeometryEffect(id:in:)
手势驱动DragGesture / MagnifyGesture + .offset / .scaleEffect
永久循环.animation(.linear.repeatForever(autoreverses: true), value: ...) 或 .phaseAnimator

规则: 从 withAnimation 开始。只有在有 3 个或更多有序状态时,才使用 PhaseAnimator。只有在需要并行的基于时间的轨道时,才使用 KeyframeAnimator。


弹簧(你唯一需要关注的缓动方式)

SwiftUI 提供了具名弹簧预设(iOS 17+):.snappy、.bouncy、.smooth,以及参数化的 .spring(duration:bounce:blendDuration:) 和 .interactiveSpring(...)。这三个具名预设 也都支持 (duration:extraBounce:) 重载。使用它们。只有在预设不合适时,才调整 response / dampingFraction。

预设(iOS 17+)等效形式风格
.snappy.spring(response: 0.5, dampingFraction: 0.85)界面利落
.bouncy.spring(response: 0.5, dampingFraction: 0.7)活泼
.smooth.spring(response: 0.5, dampingFraction: 1.0)平和,无回弹
.interactiveSpring().spring(response: 0.15, dampingFraction: 0.86)跟随手势

response 是弹簧的周期——以时长表示的刚度,也就是一次振荡需要多长时间。它不是稳定时间:.spring(response: 0.5, dampingFraction: 1.0) 仍会在 0.5 秒之后持续运动。数值越低越利落,越高越柔和。dampingFraction 是 0...1 范围内的过冲强度(1 = 无过冲,0 = 持续振荡——切勿使用 0)。对于界面开发,将 response 保持在 0.2...0.5,将 dampingFraction 保持在 0.7...1.0。深入内容见 references/springs-cheatsheet.md。

iOS 17+ 还提供 .spring(duration:bounce:),其中 bounce 范围为 0...1(0 = 临界阻尼,1 = 完全回弹)。这是同一种弹簧,只是以更适合设计师的方式呈现:

.animation(.spring(duration: 0.4, bounce: 0.3), value: state)

隐式与显式动画

// Implicit - via .animation modifier (binds to a value)
Circle()
    .scaleEffect(scale)
    .animation(.spring(.snappy), value: scale)
// Explicit - via withAnimation block
Button("Grow") {
    withAnimation(.smooth) { scale = 1.5 }
}

规则: 对用户操作触发的状态变化,优先使用显式动画(withAnimation);如果某个值的任何变化都应始终触发动画(例如从任意位置更新的进度条),则使用隐式动画。同一属性上绝不要同时使用两者——外层的 withAnimation 优先,但隐式 .animation 修饰符仍会运行,造成令人困惑的叠加效果。


过渡

过渡用于在 if、switch 或 ForEach 中插入或移除视图。当父级的动画上下文触发时,过渡也会运行(因此应在 withAnimation 中包装状态变更)。

if visible {
    Card().transition(.asymmetric(
        insertion: .move(edge: .bottom).combined(with: .opacity),
        removal: .opacity.animation(.easeIn(duration: 0.15))
    ))
}

错误——消失在黑洞中:

Card().transition(.scale)  // scales to 0, the universal "broken" feel

正确——缩放值永远不要设为 0:

Card().transition(
    .scale(scale: 0.95).combined(with: .opacity)
)

iOS 17+ 还提供了 .transition(_:) 修饰符,可通过 Transition 协议实现自定义过渡——适合让多个视图共用相同的时序。对于 90% 的工作,内置组合器(.move、.opacity、.scale、.slide、.push、.asymmetric、.combined(with:))已经足够。


matchedGeometryEffect(英雄动画)

为同一 Namespace 中的两个视图指定相同的 id。源视图被替换时,SwiftUI 会插值计算视图的尺寸和位置。

struct Gallery: View {
    @Namespace private var ns
    @State private var expanded = false

    var body: some View {
        ZStack {
            if expanded {
                LargeCard()
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring(.smooth)) { expanded = false } }
            } else {
                SmallCard()
                    .matchedGeometryEffect(id: "card", in: ns)
                    .onTapGesture { withAnimation(.spring(.smooth)) { expanded = true } }
            }
        }
    }
}

isSource: true(默认应用于事实来源视图)用于告知 SwiftUI 从哪个视图尺寸开始插值。常见问题:无关命名空间之间的 id 冲突、视图身份不稳定(使用稳定的 id,不要使用数组索引),以及从 if 分支中执行动画时目标视图尚不存在(将两个分支放在同一个父视图中,并使用透明度隐藏视图,而不是将其移除)。


PhaseAnimator(iOS 17+)

适用于有序的状态编排。该 API 接受一个相位类型的 some Sequence,该类型需遵循 Equatable(而非 Hashable);CaseIterable 只是一个便利方式,可用于传入 Phase.allCases。SwiftUI 会按顺序遍历序列,并停留在最后一个元素上。

enum SuccessPhase: CaseIterable { case start, scaleUp, rotate, settle }

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

    var body: some View {
        Image(systemName: "checkmark.circle.fill")
            .font(.system(size: 64))
            .foregroundStyle(.green)
            .phaseAnimator(SuccessPhase.allCases, trigger: trigger) { view, phase in
                view
                    .scaleEffect(phase == .start ? 0 : phase == .settle ? 1 : 1.2)
                    .rotationEffect(.degrees(phase == .rotate ? 360 : 0))
                    .opacity(phase == .start ? 0 : 1)
            } animation: { phase in
                switch phase {
                case .start: .smooth(duration: 0.05)
                case .scaleUp: .spring(.bouncy, blendDuration: 0.25)
                case .rotate: .spring(response: 0.4, dampingFraction: 0.8)
                case .settle: .smooth(duration: 0.2)
                }
            }
            .onTapGesture { trigger.toggle() }
    }
}

trigger: 是可选的——省略时,视图出现后会自动依次推进各个相位。需要外部信号(按钮点击、模型更新)时再使用它。各相位按顺序运行,不会并行;如需并行,请使用 KeyframeAnimator。


KeyframeAnimator(iOS 17+)

适用于带并行轨道的连续、基于时间的动画。每个 KeyframeTrack 会独立对一个 keypath 执行动画;SwiftUI 会同时运行所有轨道。

struct AnimationValues {
    var scale: Double = 1
    var rotation: Angle = .zero
    var opacity: Double = 1
}

struct HeartTap: View {
    @State private var counter = 0

    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 64))
            .foregroundStyle(.pink)
            .keyframeAnimator(initialValue: AnimationValues(), trigger: counter) { content, values in
                content
                    .scaleEffect(values.scale)
                    .rotationEffect(values.rotation)
                    .opacity(values.opacity)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.3, duration: 0.15)
                    SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(15), duration: 0.1)
                    CubicKeyframe(.degrees(-15), duration: 0.2)
                    CubicKeyframe(.degrees(0), duration: 0.15)
                }
            }
            .onTapGesture { counter += 1 }
    }
}

四种关键帧类型:LinearKeyframe(点与点之间保持恒定速度)、SpringKeyframe(以弹簧效果逐渐稳定)、CubicKeyframe(三次贝塞尔缓动)、MoveKeyframe(直接跳转,不进行插值)。值发生变化时会触发动画重新运行。深入了解:references/phase-keyframe-deep.md。


Animatable / @Animatable

适用于需要插值的自定义绘图。@Animatable 宏(随 WWDC25 上发布的 iOS 26 SDK 引入,因此需要 Xcode 26+;其声明的可用性为 iOS 13.0+ / macOS 10.15+ / watchOS 6.0+,因此可向旧版本部署)可附加到结构体、类或枚举,并根据本身可动画的存储属性(Double、CGFloat、Angle、CGSize、CGPoint、UnitPoint、任意 VectorArithmetic)合成 Animatable 遵循和 animatableData。将存储属性标记为 @AnimatableIgnored(可用性相同),即可将其排除在合成数据之外。在较旧的工具链中,仍可手动编写 animatableData。

struct ProgressRing: Shape {
    var progress: Double  // 0...1

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

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

ProgressRing(progress: progress)
    .stroke(.tint, lineWidth: 6)
    .animation(.spring(.smooth), value: progress)

对于包含多个属性的形状,使用 AnimatablePair<A, B>(或嵌套的 pair)作为 animatableData。@Animatable 宏可省去这些样板代码:为形状添加注解后,每个遵循 VectorArithmetic 的存储属性(Double、CGFloat、Angle、CGSize、CGPoint、UnitPoint 等)都会自动纳入合成的 animatableData;使用 @AnimatableIgnored 可将属性排除在外。


手势

手势类型用途
TapGesture离散轻点、双击(count: 2)
LongPressGesture离散 + 连续(onEnded/onChanged)上下文菜单、按住录制
DragGesture连续拖动、滑动关闭
MagnifyGesture连续捏合缩放(iOS 17+,替代 MagnificationGesture)
RotateGesture连续旋转(iOS 17+,替代 RotationGesture)
SpatialTapGesture(iOS 16+、macOS 13+)离散获取位置信息的轻点
let tap = TapGesture().onEnded { print("tap") }
let drag = DragGesture().onChanged { value in offset = value.translation }
ZStack { ... }
    .gesture(tap.simultaneously(with: drag))

三种组合运算符:.simultaneously(with:)(并行识别)、.sequenced(before:)(必须先完成一个)、.exclusively(before:)(二选一,不能同时识别)。若要精细控制子视图何时可以响应手势,请将 .simultaneousGesture(_, including: GestureMask) 与 .gesture、.subviews、.all 或 .none 一起使用。深入了解:references/gestures-swiftui.md。


反模式(错误 / 正确)

1. 已弃用的 .animation 形式(无值绑定)
// BAD - implicit-anim-everywhere, deprecated in iOS 15+
Circle().scaleEffect(scale).animation(.easeInOut)
// GOOD - bind to a specific value
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// OR
withAnimation(.easeInOut) { scale = 1.5 }
2. 直接对 frame 尺寸执行动画
// BAD - .frame() drives layout pass every frame, drops fps under load
Card().frame(height: expanded ? 400 : 100)
    .animation(.spring(), value: expanded)
// GOOD - animate transform-equivalents (scale, offset) that the compositor handles
Card()
    .frame(height: 400)
    .scaleEffect(expanded ? 1 : 0.4, anchor: .top)
    .animation(.spring(), value: expanded)
// OR for actual layout transitions, use matchedGeometryEffect
3. 缩放至 0(消失为无的陷阱)
// BAD - element vanishes into a black hole, feels broken
Card().transition(.scale)
// GOOD - minimum scale 0.9-0.95 + opacity
Card().transition(.scale(scale: 0.95).combined(with: .opacity))
4. 在 body 中使用 withAnimation
// BAD - body runs on every render, animation re-fires arbitrarily
var body: some View {
    let _ = withAnimation(.spring()) { scale = 1.2 }  // never do this
    Circle().scaleEffect(scale)
}
// GOOD - trigger from user actions or .onChange
var body: some View {
    Circle()
        .scaleEffect(scale)
        .onTapGesture {
            withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
        }
}

尊重减少动态效果设置

必须遵守。SwiftUI 通过环境提供 iOS / macOS 的“减少动态效果”辅助功能设置。跨平台设计原理请参阅 ../motion-principles/SKILL.md。

struct Hero: View {
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    @State private var shown = false

    var body: some View {
        Text("Welcome")
            .opacity(shown ? 1 : 0)
            .offset(y: shown ? 0 : (reduceMotion ? 0 : 20))
            .animation(reduceMotion ? .none : .spring(.smooth), value: shown)
            .onAppear { shown = true }
    }
}

规则:减少动态效果时,仍可使用交叉淡入淡出和不透明度;必须停用大幅位移、从零开始缩放、视差和循环动画。


快速参考:加载子技能

需求加载
深入了解弹簧动画(response/dampingFraction 调整、情绪表现)references/springs-cheatsheet.md
使用 PhaseAnimator + KeyframeAnimator 编排复杂序列references/phase-keyframe-deep.md
手势组合与冲突解决references/gestures-swiftui.md
高级视觉效果(Metal、Liquid Glass)../swiftui-graphics/SKILL.md
跨平台 UX(移动端、桌面端)../mobile-principles/SKILL.md、../desktop-principles/SKILL.md
基础知识(时序、缓动、辅助功能)../motion-principles/SKILL.md

来源

评论

支持 Markdown