swiftui-animation

swiftui-animation

熱門

使用明確動畫與限定範圍的隱式動畫、彈簧、轉場、PhaseAnimator、KeyframeAnimator、matched geometry 或導航縮放、SF Symbol 效果以及自訂 Animation 類型,來實作、診斷或審查 SwiftUI 動畫。當視圖需要在狀態變更、插入、移除、導航或多步驟編排時產生動畫,或動畫必須尊重「減少動態效果」與 Swift 並行處理時使用。

918星標
45分支
更新於 2026/7/15
SKILL.md
readonlyread-only
name
swiftui-animation
description

使用明確動畫與限定範圍的隱式動畫、彈簧、轉場、PhaseAnimator、KeyframeAnimator、matched geometry 或導航縮放、SF Symbol 效果以及自訂 Animation 類型,來實作、診斷或審查 SwiftUI 動畫。當視圖需要在狀態變更、插入、移除、導航或多步驟編排時產生動畫,或動畫必須尊重「減少動態效果」與 Swift 並行處理時使用。

SwiftUI 動畫(iOS 26+)

審查、撰寫與修正 SwiftUI 動畫。使用正確的時機、轉場與無障礙處理,搭配 Swift 6.3 模式來應用現代動畫 API。

目錄

分類工作流程

步驟 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

參考資料