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 を活用することで、単調になりがちなアニメーションに変化とリズムを加えられる。