swiftui-motion
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSwiftUI Motion
SwiftUI 动效
SwiftUI animation core. Loaded for any SwiftUI project (iOS, macOS, multi-target Apple). Concise rules here. Deep-dive in. Pair withreferences/(foundation) and../motion-principles/SKILL.md(touch UX).../mobile-principles/SKILL.md
SwiftUI 动画核心组件。适用于所有 SwiftUI 项目(iOS、macOS、多目标苹果平台)。 此处为简明规则,深入内容请查看目录。 建议搭配references/(基础原理)和../motion-principles/SKILL.md(触控用户体验)阅读。../mobile-principles/SKILL.md
Animation API decision tree
动画API选择决策树
| Need | API |
|---|---|
| Single value over time | |
| Multiple coordinated states | |
| Time-based keyframes | |
| Custom property animations | |
| Shared element transitions | |
| Gesture-driven | |
| Loop forever | |
Rule: start with . Reach for only when you have 3+ ordered states. Reach for only when you need parallel time-based tracks.
withAnimationPhaseAnimatorKeyframeAnimator| 需求 | API |
|---|---|
| 随时间变化的单个值 | |
| 多个协同状态 | |
| 基于时间的关键帧 | |
| 自定义属性动画 | |
| 共享元素转场 | |
| 手势驱动 | |
| 无限循环 | |
规则: 从 开始。仅当存在3个及以上有序状态时,再使用 。仅当需要并行的时间轨道时,再使用 。
withAnimationPhaseAnimatorKeyframeAnimatorSprings (the only easing you should care about)
弹簧动画(你唯一需要关注的缓动效果)
SwiftUI ships 4 named springs (iOS 17+). Use them. Tune / only when a preset is wrong.
responsedampingFraction| Preset (iOS 17+) | Equivalent | Mood |
|---|---|---|
| | UI snappy |
| | playful |
| | calm, no bounce |
| | gesture follow |
responsedampingFraction0...1response: 0.2...0.5dampingFraction: 0.7...1.0references/springs-cheatsheet.mdiOS 17+ also exposes where is (0 = critically damped, 1 = full bounce). It's the same spring, exposed in a more designer-friendly way:
.spring(duration:bounce:)bounce0...1swift
.animation(.spring(duration: 0.4, bounce: 0.3), value: state)SwiftUI 内置了4种命名弹簧动画(iOS 17+)。直接使用即可。仅当预设效果不合适时,再调整 / 参数。
responsedampingFraction| 预设(iOS 17+) | 等效参数 | 风格 |
|---|---|---|
| | UI 轻快响应 |
| | 活泼有趣 |
| | 平稳无弹跳 |
| | 手势跟随 |
responsedampingFraction0...1response: 0.2...0.5dampingFraction: 0.7...1.0references/springs-cheatsheet.mdiOS 17+ 还提供了 方法,其中 取值范围为 (0表示临界阻尼,1表示完全弹跳)。这是同一种弹簧动画,以更适合设计师的方式呈现:
.spring(duration:bounce:)bounce0...1swift
.animation(.spring(duration: 0.4, bounce: 0.3), value: state)Implicit vs explicit animations
隐式动画 vs 显式动画
swift
// Implicit - via .animation modifier (binds to a value)
Circle()
.scaleEffect(scale)
.animation(.spring(.snappy), value: scale)swift
// Explicit - via withAnimation block
Button("Grow") {
withAnimation(.smooth) { scale = 1.5 }
}Rule: prefer explicit () for state changes triggered by user actions; use implicit when any change to a value should always animate (e.g., a progress bar that updates from anywhere). Never both on the same property - the outer wins, but the implicit modifier still runs and stacks confusingly.
withAnimationwithAnimation.animationswift
// 隐式动画 - 通过 .animation 修饰符(绑定到某个值)
Circle()
.scaleEffect(scale)
.animation(.spring(.snappy), value: scale)swift
// 显式动画 - 通过 withAnimation 代码块
Button("放大") {
withAnimation(.smooth) { scale = 1.5 }
}规则: 对于用户操作触发的状态变化,优先使用显式动画();当某个值的任何变化都应始终触发动画时(例如,从任意位置更新的进度条),使用隐式动画。切勿对同一属性同时使用两种方式——外层的 会生效,但隐式的 修饰符仍会运行,导致混乱的叠加效果。
withAnimationwithAnimation.animationTransitions
转场动画
Transitions drive insertion / removal of views inside an , , or . They run when the parent's animation context fires (so wrap state mutations in ).
ifswitchForEachwithAnimationswift
if visible {
Card().transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity.animation(.easeIn(duration: 0.15))
))
}BAD - vanish into a black hole:
swift
Card().transition(.scale) // scales to 0, the universal "broken" feelGOOD - never scale to 0:
swift
Card().transition(
.scale(scale: 0.95).combined(with: .opacity)
)iOS 17+ also has the modifier with custom transitions via the protocol - useful for shared timing across many views. For 90% of work, the built-in combinators (, , , , , , ) are enough.
.transition(_:)Transition.move.opacity.scale.slide.push.asymmetric.combined(with:)转场动画用于驱动 、 或 内部视图的插入/移除。当父视图的动画上下文触发时(因此需将状态变更包裹在 中),转场动画会运行。
ifswitchForEachwithAnimationswift
if visible {
Card().transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity.animation(.easeIn(duration: 0.15))
))
}错误示例 - 消失进黑洞:
swift
Card().transition(.scale) // 缩放到0,是普遍的"破碎感"效果正确示例 - 切勿缩放到0:
swift
Card().transition(
.scale(scale: 0.95).combined(with: .opacity)
)iOS 17+ 还提供了 修饰符,可通过 协议实现自定义转场——适用于多视图共享时序的场景。对于90%的开发工作,内置的组合器(、、、、、、) 已足够使用。
.transition(_:)Transition.move.opacity.scale.slide.push.asymmetric.combined(with:)matchedGeometryEffect (hero animations)
matchedGeometryEffect(英雄动画)
Tag two views with the same in the same . SwiftUI interpolates frame and position when the source view is replaced.
idNamespaceswift
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: trueif在同一个 中,为两个视图标记相同的 。当源视图被替换时,SwiftUI 会自动插值过渡帧和位置。
Namespaceidswift
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: trueifPhaseAnimator (iOS 17+)
PhaseAnimator(iOS 17+)
For ordered state choreography. Define a enum, SwiftUI walks through phases sequentially, settling on the last one.
CaseIterable + Hashableswift
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用于有序状态编排。定义一个 枚举,SwiftUI 会按顺序遍历各个阶段,最终停留在最后一个阶段。
CaseIterable + Hashableswift
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:KeyframeAnimatorKeyframeAnimator (iOS 17+)
KeyframeAnimator(iOS 17+)
For continuous, time-based animations with parallel tracks. Each animates one keypath independently; SwiftUI runs them all together.
KeyframeTrackswift
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 }
}
}Four keyframe types: (constant velocity between points), (settles with spring), (cubic bezier ease), (jump cut, no interpolation). Trigger on a value change to re-run the animation. Deep-dive: .
LinearKeyframeSpringKeyframeCubicKeyframeMoveKeyframereferences/phase-keyframe-deep.md用于连续的、基于时间的并行轨道动画。每个 独立动画一个关键路径;SwiftUI 会同时运行所有轨道。
KeyframeTrackswift
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 }
}
}四种关键帧类型:(两点间匀速)、(弹簧稳定效果)、(三次贝塞尔缓动)、(跳切,无插值)。当值变化时触发,重新运行动画。深入内容请查看:。
LinearKeyframeSpringKeyframeCubicKeyframeMoveKeyframereferences/phase-keyframe-deep.mdAnimatable / @Animatable
Animatable / @Animatable
For custom drawing that needs interpolation. The macro (iOS 26+, WWDC25) auto-synthesizes for any properties; the older protocol (iOS 13+) still works pre-26.
@AnimatableanimatableDataEquatableAnimatableswift
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)For multi-property shapes use (or nested pairs) as . The macro removes that boilerplate when properties are .
AnimatablePair<A, B>animatableData@AnimatableEquatable + Animatable用于需要插值的自定义绘制。 宏(iOS 26+,WWDC25)会自动为任何 属性合成 ;旧版的 协议(iOS 13+)在iOS 26之前仍可使用。
@AnimatableEquatableanimatableDataAnimatableswift
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>animatableDataEquatable + Animatable@AnimatableGestures
手势
| Gesture | Type | Use |
|---|---|---|
| discrete | tap, double-tap ( |
| discrete + continuous ( | context menus, hold-to-record |
| continuous | drag, swipe-to-dismiss |
| continuous | pinch-zoom (iOS 17+, replaces |
| continuous | rotate (iOS 17+, replaces |
| discrete | tap with location info |
swift
let tap = TapGesture().onEnded { print("tap") }
let drag = DragGesture().onChanged { value in offset = value.translation }
ZStack { ... }
.gesture(tap.simultaneously(with: drag))Three composition operators: (parallel recognition), (one must complete first), (one or the other, not both). For fine-grained control over when child views can claim the gesture, use with , , , or . Deep-dive: .
.simultaneously(with:).sequenced(before:).exclusively(before:).simultaneousGesture(_, including: GestureMask).gesture.subviews.all.nonereferences/gestures-swiftui.md| 手势 | 类型 | 用途 |
|---|---|---|
| 离散型 | 点击、双击( |
| 离散型+连续型( | 上下文菜单、按住录制 |
| 连续型 | 拖拽、滑动关闭 |
| 连续型 | 捏合缩放(iOS 17+,替代 |
| 连续型 | 旋转(iOS 17+,替代 |
| 离散型 | 带位置信息的点击 |
swift
let tap = TapGesture().onEnded { print("点击") }
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.nonereferences/gestures-swiftui.mdAnti-Patterns (BAD / GOOD)
反模式(错误示例 / 正确示例)
1. Deprecated .animation
form (no value binding)
.animation1. 已弃用的 .animation
形式(无值绑定)
.animationswift
// BAD - implicit-anim-everywhere, deprecated in iOS 15+
Circle().scaleEffect(scale).animation(.easeInOut)swift
// GOOD - bind to a specific value
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// OR
withAnimation(.easeInOut) { scale = 1.5 }swift
// 错误示例 - 全局隐式动画,iOS 15+ 已弃用
Circle().scaleEffect(scale).animation(.easeInOut)swift
// 正确示例 - 绑定到特定值
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// 或
withAnimation(.easeInOut) { scale = 1.5 }2. Animating frame size directly
2. 直接动画帧大小
swift
// BAD - .frame() drives layout pass every frame, drops fps under load
Card().frame(height: expanded ? 400 : 100)
.animation(.spring(), value: expanded)swift
// 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 matchedGeometryEffectswift
// 错误示例 - .frame() 每帧都会触发布局计算,负载下会掉帧
Card().frame(height: expanded ? 400 : 100)
.animation(.spring(), value: expanded)swift
// 正确示例 - 动画 compositor 处理的变换等效属性(缩放、偏移)
Card()
.frame(height: 400)
.scaleEffect(expanded ? 1 : 0.4, anchor: .top)
.animation(.spring(), value: expanded)
// 如需实际布局转场,使用 matchedGeometryEffect3. Scale to 0 (the vanish-into-nothing trap)
3. 缩放到0(消失陷阱)
swift
// BAD - element vanishes into a black hole, feels broken
Card().transition(.scale)swift
// GOOD - minimum scale 0.9-0.95 + opacity
Card().transition(.scale(scale: 0.95).combined(with: .opacity))swift
// 错误示例 - 元素消失进黑洞,体验糟糕
Card().transition(.scale)swift
// 正确示例 - 最小缩放0.9-0.95 + 透明度
Card().transition(.scale(scale: 0.95).combined(with: .opacity))4. withAnimation
inside body
withAnimationbody4. 在 body
中使用 withAnimation
bodywithAnimationswift
// 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)
}swift
// GOOD - trigger from user actions or .onChange
var body: some View {
Circle()
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
}
}swift
// 错误示例 - body 每次渲染都会运行,动画会任意重新触发
var body: some View {
let _ = withAnimation(.spring()) { scale = 1.2 } // 切勿这样做
Circle().scaleEffect(scale)
}swift
// 正确示例 - 从用户操作或 .onChange 触发
var body: some View {
Circle()
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
}
}Reduced motion respect
尊重减少动效设置
Mandatory. SwiftUI exposes the iOS / macOS "Reduce Motion" accessibility setting via the environment. See for the cross-platform rationale.
../motion-principles/SKILL.mdswift
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 }
}
}Rule: cross-fades and opacity are still allowed under reduced motion; large translations, scale-from-zero, parallax, and looping motion must be neutralized.
这是必须遵守的要求。SwiftUI 通过环境变量暴露 iOS / macOS 的"减少动效"无障碍设置。跨平台原理请查看 。
../motion-principles/SKILL.mdswift
struct Hero: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var shown = false
var body: some View {
Text("欢迎")
.opacity(shown ? 1 : 0)
.offset(y: shown ? 0 : (reduceMotion ? 0 : 20))
.animation(reduceMotion ? .none : .spring(.smooth), value: shown)
.onAppear { shown = true }
}
}规则:减少动效模式下仍允许使用淡入淡出和透明度动画;必须禁用大型位移、从0缩放、视差和循环动画。
Quick Reference: Loading sub-skills
快速参考:加载子技能
| Need | Load |
|---|---|
| Springs deep-dive (response/dampingFraction tuning, mood) | |
| PhaseAnimator + KeyframeAnimator complex sequences | |
| Gesture composition + conflict resolution | |
| Advanced visuals (Metal, Liquid Glass) | |
| Cross-platform UX (mobile, desktop) | |
| Foundation (timing, easing, a11y) | |
| 需求 | 加载路径 |
|---|---|
| 弹簧动画深入(response/dampingFraction 调优、风格) | |
| PhaseAnimator + KeyframeAnimator 复杂序列 | |
| 手势组合 + 冲突解决 | |
| 高级视觉效果(Metal、液态玻璃) | |
| 跨平台用户体验(移动、桌面) | |
| 基础原理(时序、缓动、无障碍) | |