使用明確動畫與限定範圍的隱式動畫、彈簧、轉場、PhaseAnimator、KeyframeAnimator、matched geometry 或導航縮放、SF Symbol 效果以及自訂 Animation 類型,來實作、診斷或審查 SwiftUI 動畫。當視圖需要在狀態變更、插入、移除、導航或多步驟編排時產生動畫,或動畫必須尊重「減少動態效果」與 Swift 並行處理時使用。
SwiftUI 動畫(iOS 26+)
審查、撰寫與修正 SwiftUI 動畫。使用正確的時機、轉場與無障礙處理,搭配 Swift 6.3 模式來應用現代動畫 API。
目錄
- 分類工作流程
- withAnimation(明確動畫)
- 隱式動畫
- Spring 類型(iOS 17+)
- PhaseAnimator(iOS 17+)
- KeyframeAnimator(iOS 17+)
@Animatable Macro- matchedGeometryEffect(iOS 14+)
- 導航縮放轉場(iOS 18+)
- 轉場(iOS 17+)
- ContentTransition(iOS 16+)
- Symbol 效果(iOS 17+)
- Symbol 渲染模式
- 常見錯誤
- 審查清單
- 參考資料
分類工作流程
步驟 1:識別動畫類別
| 類別 | API | 使用時機 |
|---|---|---|
| 狀態驅動 | withAnimation, .animation(_:body:), .animation(_:value:) |
明確狀態變更、選擇性修飾詞動畫,或簡單的數值綁定變更 |
| 多階段 | PhaseAnimator |
序列化的多步驟動畫 |
| 關鍵影格 | KeyframeAnimator |
複雜的多屬性編排 |
| 共享元素 | matchedGeometryEffect |
佈局驅動的英雄轉場 |
| 導航 | matchedTransitionSource + .navigationTransition(.zoom) |
NavigationStack 推入/彈出縮放 |
| 視圖生命週期 | .transition() |
插入與移除 |
| 文字內容 | .contentTransition() |
原地文字/數字變更 |
| Symbol | .symbolEffect() |
SF Symbol 動畫 |
| 自訂 | CustomAnimation 協定 |
新穎的時序曲線 |
| Core Animation 橋接 | CALayer, CAAnimation, CADisplayLink |
在提供建議前先閱讀 references/core-animation-bridge.md |
步驟 2:選擇動畫曲線
.easeInOut(duration: 0.3) // 機械式時序
.smooth // 流暢,無彈跳
.snappy // 靈敏,小幅彈跳
.bouncy // 活潑,明顯彈跳
.spring(duration: 0.5, bounce: 0.3)
當預設無法表達預期的動態時,請使用進階目錄。
步驟 3:套用並驗證
- 確認動畫在正確的狀態變更時觸發。
- 在「輔助使用」>「減少動態效果」啟用時測試。
- 確認動畫內容閉包內沒有執行昂貴的工作。
- 對於 CA 橋接,使用 Coordinator 處理委派、使 display link 失效、將影格率範圍視為提示,並根據實際更新率調整工作。
withAnimation(明確動畫)
withAnimation(.spring) { isExpanded.toggle() }
// 含完成回呼(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 Macro
取代手動的 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 {
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 只能有一個來源視圖可見;否則結果未定義。
導航縮放轉場(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))
}
請參閱所有轉場類型以取得內建目錄與自訂 Transition 範例。
非對稱轉場:
.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。
Symbol 效果(iOS 17+)
使用語義效果動畫化 SF Symbols。.bounce、.pulse、.variableColor、.scale、.appear、.disappear 與 .replace 是 iOS 17+;.breathe、.rotate 與 .wiggle 需要 iOS 18+。
// 離散(在數值變更時觸發)
Image(systemName: "bell.fill").symbolEffect(.bounce, value: notificationCount)
// iOS 18+
Image(systemName: "arrow.clockwise")
.symbolEffect(.wiggle.clockwise, value: refreshCount)
// 持續(條件成立時保持作用)
Image(systemName: "wifi").symbolEffect(.pulse, isActive: isSearching)
// iOS 18+
Image(systemName: "mic.fill")
.symbolEffect(.breathe, isActive: isRecording)
// 可變顏色與鏈接
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.iterative.reversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
)
範圍:.byLayer、.wholeSymbol。方向因效果而異。
Symbol 渲染模式
使用 .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(_:) 但需要精確範圍
// 太廣泛 — 在視圖變更時套用
.animation(.easeIn)
.animation(.easeIn, value: isVisible) // 正確:數值綁定
// 正確 — 將動畫範圍限定在選定的修飾詞
.animation(.easeIn) { content in
content.opacity(isVisible ? 1.0 : 0.0)
}
withAnimation(.easeIn) { isVisible.toggle() } // 正確:擁有自己的變更
2. 在動畫閉包內執行昂貴工作或 actor 隔離讀取
keyframeAnimator / PhaseAnimator 的內容閉包每影格執行一次。請預先計算昂貴的值,僅動畫化視覺屬性,並在 @Sendable 關鍵影格閉包之前捕獲狀態/環境值。
3. 缺少減少動態效果支援
對於 symbol,移除繼承的效果;使用 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
// 錯誤
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { isVisible = true } }
// 正確
withAnimation(.spring.delay(0.5)) { isVisible = true }
6. 忘記在 ContentTransition 上加上動畫
// 錯誤 — 沒有動畫,content transition 無效
Text("\(count)").contentTransition(.numericText(countsDown: true))
// 正確 — 搭配動畫
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: count)
7. navigationTransition 套用在錯誤的視圖上
請將 .navigationTransition(.zoom(sourceID:in:)) 套用在最外層的目的地視圖上,而不是容器內部。
審查清單
- [ ] 動畫曲線符合意圖(彈簧用於自然,ease 用於機械)
- [ ]
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 變體、Transition 類型、symbol 效果、Transaction 系統、UnitCurve 與效能指南;Core Animation 橋接模式:references/core-animation-bridge.md。






