カウントダウンする丸い円

![スクリーンショット 2025-02-17 16.15.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3456384/3de57711-7e69-4cad-839c-9c8bc35803d9.png)

``` Swift: SwiftUI
import SwiftUI

/// カウントダウンの状態を管理するenum
enum CountdownState {
    // 初期 (カウントダウン前)
    case initial
    // カウントダウン中
    case started
    // 一時停止中
    case stopped
    // カウントダウン終了
    case finished
}

struct CountdownProgressView: View {
    /// 分 (オプショナル)
    let minutes: Int?
    /// 秒 (オプショナル)
    let seconds: Int?
    
    /// カウントダウンの状態
    @Binding var countdownState: CountdownState
    
    /// 残り時間（秒, 小数点以下はミリ秒表示のため Double）
    @State private var remainingTimeSec: Double = 0
    
    /// プログレス（0.0 ~ 1.0）
    @State private var progress: CGFloat = 1.0
    
    /// 総秒数 (Double)
    private var totalSec: Double {
        Double((minutes ?? 0) * 60 + (seconds ?? 0))
    }
    
    /// カウントを終了する時刻
    @State private var endDate: Date = Date()
    
    /// 一時停止したときの残り秒数を覚えておく
    @State private var pausedRemainingTimeSec: Double = 0
    
    /// カウントダウンに使うタイマー
    @State private var timer: Timer? = nil
    
    /// 表示用の残り時間テキスト
    private var displayedTime: String {
        switch countdownState {
        case .finished:
            // 終了状態
            return "終了！"
        default:
            break
        }
        
        // 1 分以上 or 総時間が 60 秒以上ある場合
        if totalSec >= 60 {
            if remainingTimeSec >= 60 {
                // mm 分 ss 秒 表示
                let mm = Int(remainingTimeSec / 60)
                let ss = Int(remainingTimeSec) % 60
                return "\(mm)分\(ss)秒"
            } else {
                // 1 分未満になったら小数点以下2桁 (10ms単位)
                let s = Int(remainingTimeSec)
                let ms = Int((remainingTimeSec - Double(s)) * 100)
                return "\(s).\(String(format: "%02d", ms))秒"
            }
        } else {
            // 合計 60 秒未満の場合はミリ秒表示
            let s = Int(remainingTimeSec)
            let ms = Int((remainingTimeSec - Double(s)) * 100)
            return "\(s).\(String(format: "%02d", ms))秒"
        }
    }
    
    var body: some View {
        ZStack {
            // 背景の円
            Circle()
                .stroke(lineWidth: 20)
                .foregroundColor(.mint.opacity(0.15))
            
            // 進捗を示す円
            Circle()
                .trim(from: 1 - progress, to: 1)
                .stroke(
                    style: StrokeStyle(
                        lineWidth: 12,
                        lineCap: .round,
                        lineJoin: .round
                    )
                )
                .fill(
                    LinearGradient(
                        gradient: Gradient(colors: [.blue, .mint]),
                        startPoint: .bottom,
                        endPoint: .top
                    )
                )
                .rotationEffect(Angle(degrees: 270))
            
            // 残り時間表示
            Text(displayedTime)
                .font(.title2)
                .fontWeight(.bold)
        }
        // Viewが登場したときに "initial" のロジックを反映
        .onAppear {
            handleCountdownStateChange(to: countdownState)
        }
        // state が変化するたびに処理を切り替え
        .onChange(of: countdownState) {
            handleCountdownStateChange(to: countdownState)
        }
    }
}

// MARK: - 状態変化ハンドラ
extension CountdownProgressView {
    private func handleCountdownStateChange(to newState: CountdownState) {
        switch newState {
        case .initial:
            // 初期状態にリセット
            resetCountdown()
            
        case .started:
            // 一時停止からの再開 or 初めての開始
            // まだ timer が動いていないなら開始
            startCountdown()
            
        case .stopped:
            // タイマーを止めて、現在の残り秒数を保持
            stopCountdown()
            
        case .finished:
            // 残りを 0 にして終了表示、タイマー停止
            finishCountdown()
        }
    }
    
    /// 初期状態へリセット
    private func resetCountdown() {
        timer?.invalidate()
        timer = nil
        
        // 残り時間を総時間にセット
        remainingTimeSec = totalSec
        pausedRemainingTimeSec = totalSec
        
        // プログレスを100%に
        progress = 1.0
    }
    
    /// カウントダウン開始（再開）
    private func startCountdown() {
        // もし timer が既に走っていたら一旦停止
        timer?.invalidate()
        
        // endDate を再計算 (今から `remainingTimeSec` 後が終了時刻)
        endDate = Date().addingTimeInterval(remainingTimeSec)
        
        // タイマーを新規作成
        timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { _ in
            let remain = endDate.timeIntervalSinceNow
            if remain <= 0 {
                // 終了
                remainingTimeSec = 0
                progress = 0
                timer?.invalidate()
                timer = nil
                
                // enumの状態を更新（外部でも監視できる）
                countdownState = .finished
            } else {
                remainingTimeSec = remain
                // 円形プログレスを更新 (アニメーション付き)
                withAnimation(.linear(duration: 0.01)) {
                    progress = CGFloat(remain / totalSec)
                }
            }
        }
    }
    
    /// カウントダウン一時停止
    private func stopCountdown() {
        // 現在の残り秒数を保持
        pausedRemainingTimeSec = remainingTimeSec
        
        // タイマー停止
        timer?.invalidate()
        timer = nil
    }
    
    /// カウントダウン終了処理
    private func finishCountdown() {
        timer?.invalidate()
        timer = nil
        
        remainingTimeSec = 0
        progress = 0
    }
}

#Preview {
    VStack {
        Text("ボタンなし")
        CountdownProgressView(
            minutes: 1,
            seconds: 30,
            countdownState: .constant(.initial)
        )
        .frame(width: 120, height: 120)
        .padding(30)
        
        Divider()
        
        Text("ボタンあり")
        // 例: 外部で state を管理する
        StatefulPreview()
            .frame(width: 200, height: 200)
            .padding(30)
    }
}

struct StatefulPreview: View {
    /// カウントダウン状態を外部で管理
    @State private var countdownState1: CountdownState = .initial
    @State private var countdownState2: CountdownState = .initial
    
    var body: some View {
        VStack(spacing: 40) {
            
            // 1つ目: 1分30秒のタイマー
            CountdownProgressView(
                minutes: 1,
                seconds: 30,
                countdownState: $countdownState1
            )
            
            HStack {
                Button("初期化") {
                    countdownState1 = .initial
                }
                Button("スタート") {
                    countdownState1 = .started
                }
                Button("ストップ") {
                    countdownState1 = .stopped
                }
                Button("強制終了") {
                    countdownState1 = .finished
                }
            }
            
            // 2つ目: 10秒のタイマー
            CountdownProgressView(
                minutes: 0,
                seconds: 10,
                countdownState: $countdownState2
            )
            
            HStack {
                Button("初期化") {
                    countdownState2 = .initial
                }
                Button("スタート") {
                    countdownState2 = .started
                }
                Button("ストップ") {
                    countdownState2 = .stopped
                }
                Button("強制終了") {
                    countdownState2 = .finished
                }
            }
        }
        .padding()
    }
}

```