🚀 快速安装
复制以下命令并运行,立即安装此 Skill:
npx skills add https://skills.sh/dpearson2699/swift-ios-skills/swiftui-animation
💡 提示:需要 Node.js 和 NPM
SwiftUI 动画 (iOS 26+)
审查、编写和修复 SwiftUI 动画。使用 Swift 6.2 模式,应用具有正确时序、过渡和无障碍处理的现代动画 API。
目录
- 分类工作流程
- withAnimation (显式动画)
- 隐式动画
- Spring 类型 (iOS 17+)
- PhaseAnimator (iOS 17+)
- KeyframeAnimator (iOS 17+)
- @Animatable 宏
- matchedGeometryEffect (iOS 14+)
- 导航缩放过渡 (iOS 18+)
- 过渡 (iOS 17+)
- ContentTransition (iOS 16+)
- 符号效果 (iOS 17+)
- 符号渲染模式
- 常见错误
- 审查清单
- 参考资料
分类工作流程
步骤 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
常见错误
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 原始英文文档,方便对照翻译。

评论(0)