0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

スクリーンショット 2025-02-17 16.15.11.png

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()
    }
}

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?