SwiftUI TimelineView でリアルタイム更新

TimelineView は、時間の経過に応じてビューを更新する仕組みだ。一定間隔での更新やアニメーションフレームごとの更新が可能で、リアルタイムの時計表示やゲームのような滑らかな描画に活用できる。

TimelineView の基本

TimelineView は、スケジュールに従ってコンテンツを再描画する。

struct ClockView: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 1.0)) { context in
            Text(context.date.formatted(date: .omitted, time: .standard))
                .font(.largeTitle.monospacedDigit())
        }
    }
}

.periodic(from:by:) で 1 秒ごとに更新するスケジュールを指定している。context.date で現在の時刻が取得でき、毎秒ビューが再描画される。

スケジュールの種類

TimelineView には複数のスケジュールオプションがある。

// 一定間隔で更新
TimelineView(.periodic(from: .now, by: 0.1)) { ... }

// アニメーションフレームごとに更新(最大 60fps 程度)
TimelineView(.animation) { ... }

// 最小限の更新頻度(低電力)
TimelineView(.animation(minimumInterval: 1/30)) { ... }

// 指定した時刻のリストで更新
TimelineView(.explicit([date1, date2, date3])) { ... }

// 毎分0秒に更新
TimelineView(.everyMinute) { ... }
.periodic指定した間隔で定期的に更新
.animationディスプレイのリフレッシュレートに合わせて更新
.explicit指定した時刻の配列に従って更新
.everyMinute毎分0秒に更新

TimelineViewDefaultContext

context パラメータには、更新のタイミングに関する情報が含まれる。

TimelineView(.animation) { context in
    // 現在の日時
    let date = context.date
    
    // ビューが表示されているか
    // .lowFrequency: バックグラウンドなど
    // .normal: 通常表示
    let cadence = context.cadence
}

cadence を確認することで、バックグラウンド時に処理を軽減するといった最適化ができる。

アニメーションへの活用

.animation スケジュールを使えば、フレーム単位の滑らかなアニメーションが可能になる。

struct SmoothRotation: View {
    var body: some View {
        TimelineView(.animation) { context in
            let seconds = context.date.timeIntervalSinceReferenceDate
            let rotation = seconds.truncatingRemainder(dividingBy: 2) * 180
            
            Image(systemName: "gear")
                .font(.system(size: 80))
                .rotationEffect(.degrees(rotation))
        }
    }
}

時刻から回転角度を計算し、毎フレーム描画することで滑らかな回転を実現している。

Canvas と組み合わせる

TimelineView と Canvas を組み合わせると、カスタム描画のアニメーションが作れる。

struct ParticleAnimation: View {
    var body: some View {
        TimelineView(.animation) { context in
            let time = context.date.timeIntervalSinceReferenceDate
            
            Canvas { graphicsContext, size in
                for i in 0..<20 {
                    let angle = Double(i) / 20 * .pi * 2 + time
                    let radius = 100 + sin(time * 2 + Double(i)) * 30
                    
                    let x = size.width / 2 + cos(angle) * radius
                    let y = size.height / 2 + sin(angle) * radius
                    
                    let circle = Path(ellipseIn: CGRect(
                        x: x - 10, y: y - 10,
                        width: 20, height: 20
                    ))
                    
                    graphicsContext.fill(
                        circle,
                        with: .color(.blue.opacity(0.7))
                    )
                }
            }
        }
        .frame(width: 300, height: 300)
    }
}

20 個の円が波打ちながら回転する、複雑なアニメーションが実現できる。

パフォーマンスの考慮

TimelineView は継続的に再描画を行うため、パフォーマンスへの影響が大きい。

必要な時だけ使う

常時表示が不要な場合は、@State で表示状態を管理し、非表示時は TimelineView を使わないようにする。

適切なスケジュール選択

60fps が不要な場面では minimumInterval を指定するか、.periodic で更新頻度を下げる。

描画の最適化

Canvas 内の描画処理を軽量に保つ。複雑な計算は可能な限り事前に行う。

struct OptimizedTimelineView: View {
    @State private var isActive = true
    
    var body: some View {
        Group {
            if isActive {
                TimelineView(.animation(minimumInterval: 1/30)) { context in
                    // 30fps で十分な場合
                    AnimatedContent(time: context.date.timeIntervalSinceReferenceDate)
                }
            } else {
                StaticContent()
            }
        }
    }
}

実践例:アナログ時計

struct AnalogClock: View {
    var body: some View {
        TimelineView(.periodic(from: .now, by: 1.0)) { context in
            let components = Calendar.current.dateComponents(
                [.hour, .minute, .second],
                from: context.date
            )
            let hour = Double(components.hour ?? 0)
            let minute = Double(components.minute ?? 0)
            let second = Double(components.second ?? 0)
            
            ZStack {
                Circle().stroke(lineWidth: 4)
                
                // 時針
                ClockHand(length: 50, width: 4)
                    .rotationEffect(.degrees((hour + minute / 60) * 30 - 90))
                
                // 分針
                ClockHand(length: 70, width: 3)
                    .rotationEffect(.degrees(minute * 6 - 90))
                
                // 秒針
                ClockHand(length: 80, width: 1)
                    .foregroundColor(.red)
                    .rotationEffect(.degrees(second * 6 - 90))
            }
            .frame(width: 200, height: 200)
        }
    }
}

毎秒更新で十分なアナログ時計は .periodic(by: 1.0) を使い、不要な再描画を避ける。用途に応じた適切なスケジュール選択が、バッテリー消費を抑える鍵となる。