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