mcpField
S
swiftui-animations

swiftui-animations

github.com/tryhusetデザイン・UI656 回インストール23 週間前に更新
リポジトリ

swiftui-animations について

開発者向けの SwiftUI アニメーションスキルです。状態に応じた動き、トランジション、ジェスチャー、最新 API、アクセシビリティを解説します。

主な機能

  1. 01状態駆動のアニメーション指針
  2. 02暗黙・明示アニメーションのパターン
  3. 03ビュー遷移とジェスチャー効果
  4. 04PhaseAnimator と KeyframeAnimator
  5. 05視差効果を減らす設定への対応

活用シーン

  1. 01SwiftUI のアニメーション設計
  2. 02独自のアニメーション効果の構築
  3. 03ジェスチャーに応じた動きの作成
  4. 04視差効果を減らす設定への対応

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tryhuset/agent-skills --skill swiftui-animations

インストール後、Claude Code で/swiftui-animationsを入力して読み込まれたか確認します。

詳細

SwiftUI アニメーションのエキスパートです。Apple のベストプラクティスに従って、滑らかでパフォーマンスに優れ、アクセシビリティに配慮したアニメーションを作成できるよう、開発者を支援します。

基本原則

SwiftUI アニメーションの仕組み
  1. アニメーションは状態の変化によって駆動されます – SwiftUI は古い状態と新しい状態の 差分 をアニメーション化します
  2. トランザクションは下位へ伝播します – withAnimation で状態の変化を囲むと、トランザクションがビュー階層を通って流れます
  3. アニメーション化されるのはアニメーション可能なデータだけです – 型は Animatable に準拠している必要があります(ほとんどの組み込み型が準拠しています。CGFloat、Color、CGSize など)
  4. アニメーションは宣言的です – 最終状態を記述すると、補間は SwiftUI が行います
メンタルモデル
State Change → Transaction Created → View Diffed → Animatable Properties Interpolated → Frames Rendered

アニメーションは、ビューの2つのスナップショット間の自動補間 と考えてください。必要なのは次のとおりです。

  1. 2つの状態を明確に定義する
  2. 使用するタイミングカーブを SwiftUI に指定する
  3. アニメーション化するプロパティが 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 は古い状態と新しい状態の間の補間値を使って、セッターを繰り返し呼び出します。

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))
Shape のアニメーション

Shape は 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つ以上の値には ネストしたペア を使用します: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 の使い分け
シナリオAPI
単一の状態変化withAnimation
ループ/繰り返しアニメーションPhaseAnimator
複雑な複数プロパティの演出KeyframeAnimator
ユーザーがトリガーするシーケンスtrigger: を指定した PhaseAnimator
タイミングの細かな制御KeyframeAnimator

重要なルール

必ず行うこと:
  • .animation() 修飾子では常に value: パラメーターを使います。値を指定しない形式は非推奨で、バグがあります。
  • .animation() より withAnimation を優先します。アニメーションを開始するトリガーをより明示的に制御できます。
  • デフォルトではスプリングを使います。直線やイーズ曲線より自然に感じられます。
  • モーションの低減に配慮します。accessibilityReduceMotion を確認してください(「アクセシビリティ」セクションを参照)。
  • フレームではなくレイアウトをアニメーションさせます。frame を直接変更するのではなく、.offset()、.scaleEffect()、.opacity() を使います。
  • UI の応答は 400ms 未満にします。それ以上では動きが遅く感じられます。
  • 実機でテストします。シミュレーターと実機ではタイミングが異なります。
行わないこと:
  • body の計算中にアニメーションさせないでください。body は純粋であるべきです。状態の変化を起点にアニメーションを開始します。
  • value: を指定せずに .animation() を使わないでください。予測不能な連鎖アニメーションが発生します。
  • フレームワークの動作に逆らわないでください。アニメーションの実現が難しい場合は、アプローチを見直します。
  • 同時に多くのプロパティをアニメーションさせないでください。明瞭さを保つため、最大 2~3 個にします。
  • シーケンス処理に DispatchQueue.main.asyncAfter を使わないでください。PhaseAnimator または完了ベースの API を使います。
  • withAnimation ブロックをネストしないでください。内側のブロックが優先され、外側は無視されます。
  • アニメーションの完了を前提にしないでください。SwiftUI は完了コールバックを保証しません。重要なシーケンス処理には Transaction を使います。
よくある注意点
問題原因対処方法
トランジションがアニメーションしない状態変更が withAnimation で囲まれていないif 条件の変更を withAnimation で囲みます
アニメーションが 2 回実行されるwithAnimation と .animation() の両方を使用しているどちらか一方を選びます
アニメーションがぎこちないアニメーションに対応していないプロパティをアニメーションさせている型が Animatable に準拠しているか確認します
スプリングがいつまでも収束しないdampingFraction が低すぎる収束させるには 0.7 以上を使うか、.speed() を追加します
間違ったビューがアニメーションするトランザクションが予期せず伝播している.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 のトランジションは誰にとっても利用できます
  • なくすのではなく、減らしましょう – 動きは理解の助けになります。控えめにするだけで十分です
  • 前庭系への刺激を避けましょう – 大きなズーム、視差、回転は問題になることがあります
  • 設定を有効にしてテストしましょう – 「設定」→「アクセシビリティ」→「モーション」→「視差効果を減らす」

コメント