关于 swiftui-animation
面向 SwiftUI 开发者的动画技能,用于审查、编写和修复动画。涵盖现代动画 API、时序选择、转场与辅助功能处理,并强调减少动态效果支持。
主要功能
- 01动画类型与 API 选择
- 02状态驱动与多阶段动画
- 03关键帧和共享元素转场
- 04SF Symbols 动效与渲染
- 05减少动态效果审查
适用场景
- 01为状态变化添加合适动画
- 02设计视图插入和移除转场
- 03排查动画范围或性能问题
- 04检查减少动态效果支持
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill swiftui-animation安装后在 Claude Code 中输入/swiftui-animation确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/animation-advanced.mdcore-animation-bridge.md详细介绍
SwiftUI 动画(iOS 26+)
审查、编写和修复 SwiftUI 动画。使用 Swift 6.3 模式,应用现代动画 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 协议 | 新颖的时序曲线 |
| 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 要求
参考资料
- 有关 CustomAnimation 协议、Spring 变体、Transition 类型、符号效果、Transaction 系统、UnitCurve 和性能指南,请参阅 references/animation-advanced.md;Core Animation 桥接模式请参阅:references/core-animation-bridge.md。