SwiftUI AnimatableModifier

AnimatableModifier は、カスタムモディファイアにアニメーション機能を追加するためのプロトコルだ。ViewModifier と Animatable を組み合わせることで、SwiftUI の補間システムを利用した独自のアニメーション効果を作成できる。

Animatable プロトコルの基本

Animatable プロトコルは animatableData プロパティを要求する。これにより、SwiftUI がその値を時間に沿って補間できるようになる。

struct ProgressModifier: ViewModifier, Animatable {
    var progress: Double
    
    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .clipShape(
                Rectangle()
                    .size(width: progress * 100, height: 100)
            )
    }
}

progress が 0 から 1 に変化するとき、SwiftUI は中間値(0.1, 0.2, 0.3…)を自動的に計算してアニメーションを生成する。

AnimatableModifier の実装

AnimatableModifier は ViewModifier と Animatable を組み合わせた typealias だ。

// これらは同等
struct MyModifier: ViewModifier, Animatable { ... }
struct MyModifier: AnimatableModifier { ... }  // 省略形

実践例:パーセント表示アニメーション

数値がカウントアップするアニメーションを作ってみる。

struct CountingModifier: AnimatableModifier {
    var number: Double
    
    var animatableData: Double {
        get { number }
        set { number = newValue }
    }
    
    func body(content: Content) -> some View {
        Text("\(Int(number))%")
            .font(.largeTitle.monospacedDigit())
    }
}

extension View {
    func countingNumber(_ number: Double) -> some View {
        modifier(CountingModifier(number: number))
    }
}

struct CountingDemo: View {
    @State private var progress: Double = 0
    
    var body: some View {
        VStack {
            Color.clear
                .countingNumber(progress)
            
            Button("Animate") {
                withAnimation(.easeInOut(duration: 2)) {
                    progress = 100
                }
            }
        }
    }
}

ボタンを押すと、0 から 100 まで数字がアニメーションしながら変化する。

AnimatablePair で複数の値をアニメーション

複数の値を同時にアニメーションさせたい場合は AnimatablePair を使う。

struct WaveModifier: AnimatableModifier {
    var amplitude: Double
    var frequency: Double
    
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(amplitude, frequency) }
        set {
            amplitude = newValue.first
            frequency = newValue.second
        }
    }
    
    func body(content: Content) -> some View {
        content
            .offset(y: sin(frequency * .pi * 2) * amplitude)
    }
}

AnimatablePair をネストすれば、3 つ以上の値も扱える。

// 3つの値
var animatableData: AnimatablePair<Double, AnimatablePair<Double, Double>> {
    get {
        AnimatablePair(first, AnimatablePair(second, third))
    }
    set {
        first = newValue.first
        second = newValue.second.first
        third = newValue.second.second
    }
}

実践例:グラデーション位置のアニメーション

struct GradientPositionModifier: AnimatableModifier {
    var position: Double
    
    var animatableData: Double {
        get { position }
        set { position = newValue }
    }
    
    func body(content: Content) -> some View {
        content
            .overlay(
                LinearGradient(
                    colors: [.clear, .white.opacity(0.5), .clear],
                    startPoint: UnitPoint(x: position - 0.3, y: 0),
                    endPoint: UnitPoint(x: position + 0.3, y: 1)
                )
            )
            .mask(content)
    }
}

extension View {
    func shimmer(position: Double) -> some View {
        modifier(GradientPositionModifier(position: position))
    }
}

これを使えば、ローディング時のシマー(光沢が流れる)エフェクトが実装できる。

Shape でのアニメーション

Shape も Animatable に準拠しているため、パスのアニメーションが可能だ。

struct AnimatableArc: Shape {
    var endAngle: Double
    
    var animatableData: Double {
        get { endAngle }
        set { endAngle = newValue }
    }
    
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.addArc(
                center: CGPoint(x: rect.midX, y: rect.midY),
                radius: rect.width / 2,
                startAngle: .degrees(0),
                endAngle: .degrees(endAngle),
                clockwise: false
            )
        }
    }
}

struct ArcDemo: View {
    @State private var progress: Double = 0
    
    var body: some View {
        AnimatableArc(endAngle: progress * 360)
            .stroke(lineWidth: 10)
            .frame(width: 100, height: 100)
            .onTapGesture {
                withAnimation(.easeInOut(duration: 1)) {
                    progress = progress == 0 ? 1 : 0
                }
            }
    }
}

円弧が滑らかに伸び縮みする。プログレスインジケーターなどに活用できる。

注意点

パフォーマンス

animatableData の getter/setter はアニメーション中に大量に呼ばれる。重い処理を入れないこと。

型の制限

animatableData は VectorArithmetic に準拠した型でなければならない。Double、CGFloat、AnimatablePair などが使える。

body の再構築

アニメーション中は body が毎フレーム再評価される。ビュー構造を軽量に保つことが重要。

AnimatableModifier を使いこなすと、SwiftUI の標準アニメーションでは表現できない独自の動きを実現できる。ただし、多くの場合は標準のアニメーションで十分なので、本当に必要な場面で活用するのがベストだ。