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