SwiftUI PhaseAnimator(iOS 17)

iOS 17 で導入された PhaseAnimator は、複数のフェーズ(状態)を自動的に循環するアニメーションを簡単に作れる機能だ。従来は DispatchQueue や Timer を使って手動で制御していた段階的なアニメーションが、宣言的に記述できるようになった。

PhaseAnimator の基本

PhaseAnimator は、定義したフェーズの配列を順番にループする。

struct PulsingDot: View {
    var body: some View {
        PhaseAnimator([false, true]) { phase in
            Circle()
                .fill(.blue)
                .frame(width: 50, height: 50)
                .scaleEffect(phase ? 1.2 : 1.0)
                .opacity(phase ? 0.6 : 1.0)
        }
    }
}

[false, true] という 2 つのフェーズを定義し、ビューはこれを無限にループする。phase パラメータに現在のフェーズが渡されるので、それに応じてビューを変化させる。

フェーズに enum を使う

より複雑なアニメーションでは、フェーズを enum で定義すると可読性が上がる。

enum AnimationPhase: CaseIterable {
    case initial
    case expanded
    case rotated
    case final
}

struct MultiPhaseAnimation: View {
    var body: some View {
        PhaseAnimator(AnimationPhase.allCases) { phase in
            RoundedRectangle(cornerRadius: 20)
                .fill(.purple)
                .frame(width: phase == .expanded ? 150 : 100,
                       height: phase == .expanded ? 150 : 100)
                .rotationEffect(.degrees(phase == .rotated ? 45 : 0))
                .opacity(phase == .final ? 0.5 : 1.0)
        }
    }
}

CaseIterable に準拠した enum を使えば、allCases でフェーズ配列を自動生成できる。

animation パラメータでフェーズごとのアニメーションを指定

各フェーズへの遷移に異なるアニメーションを適用できる。

PhaseAnimator([0, 1, 2]) { phase in
    Circle()
        .fill(.green)
        .frame(width: 100, height: 100)
        .offset(y: phase == 1 ? -100 : 0)
        .scaleEffect(phase == 2 ? 1.5 : 1.0)
} animation: { phase in
    switch phase {
    case 0: .spring(duration: 0.3)
    case 1: .easeOut(duration: 0.5)
    default: .bouncy
    }
}

animation クロージャで、次のフェーズに移行するときのアニメーションを指定できる。フェーズによって動きの性格を変えたい場合に便利だ。

trigger でアニメーション開始を制御

デフォルトでは PhaseAnimator は自動でループするが、trigger パラメータを使えばユーザーアクションに応じて開始できる。

struct TriggeredAnimation: View {
    @State private var trigger = false
    
    var body: some View {
        VStack {
            PhaseAnimator(
                [false, true],
                trigger: trigger
            ) { phase in
                Star()
                    .fill(.yellow)
                    .frame(width: 100, height: 100)
                    .scaleEffect(phase ? 1.3 : 1.0)
                    .rotationEffect(.degrees(phase ? 360 : 0))
            }
            
            Button("Animate") {
                trigger.toggle()
            }
        }
    }
}

trigger の値が変わるたびに、フェーズを最初から最後まで 1 回実行する。

実践例:ローディングインジケーター

struct LoadingIndicator: View {
    var body: some View {
        HStack(spacing: 8) {
            ForEach(0..<3) { index in
                PhaseAnimator([false, true]) { phase in
                    Circle()
                        .fill(.blue)
                        .frame(width: 12, height: 12)
                        .offset(y: phase ? -10 : 0)
                } animation: { _ in
                    .easeInOut(duration: 0.4)
                        .delay(Double(index) * 0.15)
                }
            }
        }
    }
}

3 つのドットが時間差で上下に動く、よく見るローディングアニメーションが簡潔に実装できる。

実践例:通知バッジアニメーション

enum BadgePhase: CaseIterable {
    case normal, bump, settle
}

struct NotificationBadge: View {
    @State private var count = 0
    
    var body: some View {
        ZStack {
            Circle()
                .fill(.red)
                .frame(width: 24, height: 24)
            
            PhaseAnimator(
                BadgePhase.allCases,
                trigger: count
            ) { phase in
                Text("\(count)")
                    .font(.caption.bold())
                    .foregroundStyle(.white)
                    .scaleEffect(phase == .bump ? 1.4 : 1.0)
            } animation: { phase in
                switch phase {
                case .normal: .spring(duration: 0.1)
                case .bump: .spring(duration: 0.15, bounce: 0.5)
                case .settle: .spring(duration: 0.2)
                }
            }
        }
        .onTapGesture {
            count += 1
        }
    }
}

カウントが変わるたびに、数字がポンと弾むアニメーションが再生される。

PhaseAnimator vs withAnimation

PhaseAnimator が適している

複数フェーズの循環、段階的な状態変化、ローディングなど継続的なアニメーション。宣言的に書きたい場合。

withAnimation が適している

単発のアニメーション、ユーザーアクションへの即座の反応、細かいタイミング制御が必要な場合。

PhaseAnimator は iOS 17 以降でのみ使える点に注意が必要だが、従来は複雑だった多段階アニメーションをシンプルに記述できる強力なツールだ。