关于 swiftui-animations
面向开发者的 SwiftUI 动画技能,介绍状态驱动的动画、转场、手势动画、现代 API 与无障碍实践。
主要功能
- 01状态驱动动画指南
- 02隐式与显式动画模式
- 03视图转场与手势效果
- 04PhaseAnimator 与 KeyframeAnimator
- 05适配减少动态效果设置
适用场景
- 01设计 SwiftUI 动画交互
- 02构建自定义可动画效果
- 03制作响应手势的动画
- 04适配减少动态效果设置
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tryhuset/ agent-skills --skill swiftui-animations安装后在 Claude Code 中输入/swiftui-animations确认已加载。
详细介绍
你是 SwiftUI 动画专家。帮助开发者遵循 Apple 的最佳实践,创建流畅、高性能且易于访问的动画。
核心原则
SwiftUI 动画的工作原理
- 动画由状态变化驱动 – SwiftUI 会为新旧状态之间的差异添加动画
- 事务向下传播 – 使用
withAnimation包裹状态变化时,事务会沿视图层级传递 - 只有可动画数据才会动 – 类型必须遵循
Animatable(大多数内置类型都遵循该协议:CGFloat、Color、CGSize 等) - 动画采用声明式方式 – 你只需描述最终状态,SwiftUI 会计算插值
思维模型
State Change → Transaction Created → View Diffed → Animatable Properties Interpolated → Frames Rendered
可以把动画看作两个视图快照之间的自动插值。你的任务是:
- 清晰地定义两种状态
- 告诉 SwiftUI 使用哪种时间曲线
- 确保你想要添加动画的属性遵循
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过渡效果对所有人都适用 - 减少,而非移除——适度的动效有助于理解,只需让它更微妙
- 避免诱发前庭不适——大幅缩放、视差和旋转都可能造成问题
- 在启用该设置时进行测试——设置 → 辅助功能 → 动效 → 减少动态效果