关于 swiftui-motion
面向 Claude Code、claude.ai 和 Cowork 的 SwiftUI 动画技能,提供动画 API、弹簧、转场与手势的设计和实现指导,并涵盖无障碍与常见性能问题。
主要功能
- 01SwiftUI 动画 API 选择
- 02弹簧与转场指导
- 03手势驱动的动画模式
- 04减少动态效果的无障碍规则
- 05常见动画反模式
适用场景
- 01为视图选择动画 API
- 02构建协调的状态转场
- 03添加手势交互
- 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 |