SwiftUI アニメーションの連鎖と delay

複数のアニメーションを順番に実行したい場面は多い。リスト項目を 1 つずつ表示したり、複数のプロパティを時間差でアニメーションさせたりする場合、delay を活用してシーケンスを組み立てる。

delay の基本

delay() は、アニメーションの開始を指定した秒数だけ遅らせる。

struct DelayedAnimation: View {
    @State private var show = false
    
    var body: some View {
        VStack(spacing: 20) {
            Text("First")
                .opacity(show ? 1 : 0)
                .animation(.easeOut.delay(0.0), value: show)
            
            Text("Second")
                .opacity(show ? 1 : 0)
                .animation(.easeOut.delay(0.2), value: show)
            
            Text("Third")
                .opacity(show ? 1 : 0)
                .animation(.easeOut.delay(0.4), value: show)
        }
        .onAppear {
            show = true
        }
    }
}

各テキストが 0.2 秒ずつずれて表示される。こうした「ずらし」はスタッガードアニメーション(staggered animation)と呼ばれ、リストの表示などでよく使われる。

ForEach と組み合わせる

インデックスを使って delay を計算すれば、動的なリストでもスタッガードアニメーションを実現できる。

struct StaggeredList: View {
    @State private var show = false
    let items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            ForEach(Array(items.enumerated()), id: \.offset) { index, item in
                Text(item)
                    .padding()
                    .background(Color.blue.opacity(0.2))
                    .cornerRadius(8)
                    .opacity(show ? 1 : 0)
                    .offset(x: show ? 0 : -50)
                    .animation(
                        .spring().delay(Double(index) * 0.1),
                        value: show
                    )
            }
        }
        .onAppear {
            show = true
        }
    }
}

各アイテムがインデックス × 0.1 秒の遅延でアニメーションを開始する。

複数プロパティの時間差アニメーション

1 つのビューに対して、異なるプロパティを時間差でアニメーションさせることもできる。

struct MultiPropertyAnimation: View {
    @State private var appear = false
    
    var body: some View {
        Circle()
            .fill(.purple)
            .frame(width: 100, height: 100)
            .opacity(appear ? 1 : 0)
            .scaleEffect(appear ? 1 : 0.5)
            .rotationEffect(.degrees(appear ? 0 : 180))
            .animation(.easeOut(duration: 0.4), value: appear)
            .blur(radius: appear ? 0 : 10)
            .animation(.easeOut(duration: 0.6).delay(0.2), value: appear)
    }
}

opacity、scale、rotation は同時に始まり、blur だけが 0.2 秒遅れて開始する。モディファイアの順序で animation の適用範囲が決まる点に注意が必要だ。

DispatchQueue を使った連鎖

より複雑な制御が必要な場合、DispatchQueue.main.asyncAfter を使う方法もある。

struct ChainedAnimation: View {
    @State private var step1 = false
    @State private var step2 = false
    @State private var step3 = false
    
    var body: some View {
        VStack {
            Circle().fill(.red).frame(width: 50, height: 50)
                .scaleEffect(step1 ? 1 : 0)
            Circle().fill(.green).frame(width: 50, height: 50)
                .scaleEffect(step2 ? 1 : 0)
            Circle().fill(.blue).frame(width: 50, height: 50)
                .scaleEffect(step3 ? 1 : 0)
        }
        .onAppear {
            withAnimation(.spring()) {
                step1 = true
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                withAnimation(.spring()) {
                    step2 = true
                }
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
                withAnimation(.spring()) {
                    step3 = true
                }
            }
        }
    }
}

この方法は冗長だが、各ステップで異なるアニメーションを適用したり、前のアニメーション完了を待ってから次を開始したりする場合に有効だ。

iOS 17 の completion を使った連鎖

iOS 17 以降では、withAnimation の completion パラメータを使ってより洗練された連鎖が可能になった。

func startSequence() {
    withAnimation(.spring(duration: 0.3)) {
        step1 = true
    } completion: {
        withAnimation(.spring(duration: 0.3)) {
            step2 = true
        } completion: {
            withAnimation(.spring(duration: 0.3)) {
                step3 = true
            }
        }
    }
}

アニメーションの実際の完了を検知して次に進むため、タイミングのずれが起きにくい。

実践的な例:カード展開アニメーション

struct ExpandingCard: View {
    @State private var isExpanded = false
    
    var body: some View {
        VStack(spacing: 0) {
            // ヘッダー
            Text("タイトル")
                .font(.headline)
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.blue)
            
            // コンテンツ
            if isExpanded {
                VStack(spacing: 8) {
                    ForEach(0..<3) { index in
                        Text("項目 \(index + 1)")
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.gray.opacity(0.1))
                            .opacity(isExpanded ? 1 : 0)
                            .offset(y: isExpanded ? 0 : -20)
                            .animation(
                                .spring().delay(Double(index) * 0.1),
                                value: isExpanded
                            )
                    }
                }
                .transition(.opacity)
            }
        }
        .cornerRadius(12)
        .shadow(radius: 4)
        .onTapGesture {
            withAnimation(.spring()) {
                isExpanded.toggle()
            }
        }
    }
}

カードが展開すると、内部の項目が順番にフェードインしてくる。閉じるときは一斉に消えるため、開閉で異なる印象を与える。delay を活用することで、単調になりがちなアニメーションに変化とリズムを加えられる。