SwiftUIでカウントダウンタイマーを実装する
SwiftUIでカウントダウンタイマーを実装する
概要
SwiftUIで以下のカウントダウンタイマーを実装します。
背景
iOSのFamilyControlsの一部の機能を使ったアプリを開発したく、FlutterではなくSwiftUIを触る必要があったので、最近SwiftUIを勉強していました。その中でカウントダウン機能を実装することにしたので、調査も兼ねてサンプルアプリを実装してみました。
https://developer.apple.com/documentation/familycontrols
ソースコード
https://github.com/tkugimot/CountDownTimer
実装の概要
後述の手順で新規プロジェクトを作成すると、以下の2ファイルは既に作成されているかと思います。
- CountDownTimerApp
- ContentView
上記に加えて、以下のファイルを作成します。
ファイル名 | 役割 |
---|---|
CountDownTimerView | 時間のカウントダウンを表示 |
CountDownTimerViewModel | 時間のカウントダウンを表す数字を保持するモデル |
EndTimePickerView | カウントダウンしたい時間を設定する画面 |
EndTimePickerViewModel | カウントダウンしたい時間を格納するモデル |
InitialView | 初期表示に使うView |
HomeScreen | ホーム画面 |
「Set CountDown Timer!」をクリックしてカウントダウンしたい時間を設定したら、EndTimePickerViewModelに時間を格納し、現在時刻から終了時刻を計算します。
終了時刻をCountDownTimerViewModelに渡して初期化することで、現在時刻との差分を毎秒計算し、CountDownTimerViewで表示します。
カウントダウンが終了したら(= 終了時刻が現在時刻を過ぎたら)、「Time's Up!」のアラートを表示します。
実装で重要な点(状態管理)
特に初歩的な状態管理の良い勉強になりました。
アノテーション | 役割 |
---|---|
@State | 一画面の中のみで使う変数を一時的に格納する |
@EnvironmentObject | ObservableObjectとして実装したモデルを複数の画面で共有する際に利用する |
@ObservedObject | 一画面の中のみで使うモデルを一時的に格納する CountDownTimerViewはEndTimePickerViewModelが更新された際に合わせて更新したいのでこれを使う |
実装
XCodeで新規プロジェクトを作成
CreateNew Project...
Appを選択してNext
Product Name に CountDownTimer を設定して Next をクリックし、Create。
InitialViewの作成
砂時計の画像だけ表示します。この画像は元々用意されているものなので、自分で用意する必要はありません。
import SwiftUI
struct InitialView: View {
var body: some View {
VStack {
Image(systemName: "hourglass")
.font(.system(size: 50))
.foregroundColor(.blue)
.padding()
}
.padding()
}
}
#Preview {
InitialView()
}
カウントダウン時間を設定する画面を用意
import Foundation
class EndTimePickerViewModel: ObservableObject {
//Pickerで設定した"時間"を格納する変数
@Published var hourSelection: Int = 0
//Pickerで設定した"分"を格納する変数
@Published var minSelection: Int = 0
//Pickerで設定した"秒"を格納する変数
@Published var secSelection: Int = 0
func isInitialized() -> Bool {
hourSelection != 0 || minSelection != 0 || secSelection != 0
}
func calcEndTime() -> Date {
// 現在の日時を取得
let currentDate = Date()
// Calendarを取得
let calendar = Calendar.current
// 指定した時間を現在時刻に加算
if let newDate = calendar.date(byAdding: .hour, value: hourSelection, to: currentDate),
let newDate2 = calendar.date(byAdding: .minute, value: minSelection, to: newDate),
let newDate3 = calendar.date(byAdding: .second, value: secSelection, to: newDate2) {
return newDate3
}
return currentDate
}
func updateValues(hour: Int, min: Int, sec: Int) {
self.hourSelection = hour
self.minSelection = min
self.secSelection = sec
}
}
import SwiftUI
struct EndTimePickerView: View {
@EnvironmentObject var endTimePickerViewModel: EndTimePickerViewModel
// 「Set」を押した後に元の画面に戻すために利用
@Environment(\.presentationMode) var presentationMode
@State private var hoursSelection: Int = 0
@State private var minutesSelection: Int = 0
@State private var secondsSelection: Int = 0
//設定可能な時間単位の数値
var hours = [Int](0..<24)
//設定可能な分単位の数値
var minutes = [Int](0..<60)
//設定可能な秒単位の数値
var seconds = [Int](0..<60)
var body: some View {
Form {
HStack {
//時間単位のPicker
Picker(selection: $hoursSelection, label: Text("hour")) {
ForEach(0 ..< self.hours.count, id: \.self) { index in
Text("\(self.hours[index])")
.tag(index)
}
}
//上下に回転するホイールスタイルを指定
.pickerStyle(WheelPickerStyle())
Text("h")
.font(.headline)
//分単位のPicker
Picker(selection: $minutesSelection, label: Text("minute")) {
ForEach(0 ..< self.minutes.count, id: \.self) { index in
Text("\(self.minutes[index])")
.tag(index)
}
}
.pickerStyle(WheelPickerStyle())
.clipped()
Text("m")
.font(.headline)
//秒単位のPicker
Picker(selection: $secondsSelection, label: Text("second")) {
ForEach(0 ..< self.seconds.count, id: \.self) { index in
Text("\(self.seconds[index])")
.tag(index)
}
}
.pickerStyle(WheelPickerStyle())
.clipped()
Text("s")
.font(.headline)
}
Button("Set", action: {
print("Submit button tapped")
endTimePickerViewModel.updateValues(hour: hoursSelection, min: minutesSelection, sec: secondsSelection)
print(endTimePickerViewModel.calcEndTime())
// 元の画面に戻す
presentationMode.wrappedValue.dismiss()
})
}
}
}
#Preview {
EndTimePickerView()
.environmentObject(EndTimePickerViewModel())
}
カウントダウン画面の用意
import Foundation
@MainActor
class CountdownTimerViewModel: ObservableObject {
@Published var day: Int = 0
@Published var hour: Int = 0
@Published var minute: Int = 0
@Published var second: Int = 0
var endDate: Date
var hasCountdownCompleted: Bool {
Date() >= endDate
}
init(endDate: Date) {
self.endDate = endDate
updateTimer()
}
func updateTimer() {
let calendar = Calendar(identifier: .gregorian)
// 現在時刻からendDateまでの時間差を計算
let timeValue = calendar.dateComponents([.day, .hour, .minute, .second], from: Date(), to: endDate)
if !hasCountdownCompleted,
let day = timeValue.day,
let hour = timeValue.hour,
let minute = timeValue.minute,
let second = timeValue.second {
self.day = day
self.hour = hour
self.minute = minute
self.second = second
}
}
}
import SwiftUI
struct CountdownTimerView: View {
@ObservedObject var viewModel: CountdownTimerViewModel
@State private var showAlert = false
// viewを変更させるか否かを判定するためにtimerを動かす
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private var hasCountdownCompleted: Bool {
viewModel.hasCountdownCompleted
}
init(endDate: Date) {
_viewModel = ObservedObject(wrappedValue: CountdownTimerViewModel(endDate: endDate))
}
var body: some View {
VStack(spacing: 16) {
HStack {
VStack(spacing: 8) {
Text(String(format: "%02d", viewModel.hour))
.font(.system(size: 22, weight: .bold))
.foregroundColor(.red)
Text("hour")
.textCase(.uppercase)
.font(.system(size: 11))
}
VStack(spacing: 8) {
colon
Spacer()
.frame(height: 15)
}
VStack(spacing: 8) {
Text(String(format: "%02d", viewModel.minute))
.font(.system(size: 22, weight: .bold))
.foregroundColor(.red)
Text("min")
.textCase(.uppercase)
.font(.system(size: 11))
}
VStack(spacing: 8) {
colon
Spacer()
.frame(height: 15)
}
VStack(spacing: 8) {
Text(String(format: "%02d", viewModel.second))
.font(.system(size: 22, weight: .bold))
.foregroundColor(.red)
Text("sec")
.textCase(.uppercase)
.font(.system(size: 11))
}
}
}
.onReceive(timer) { _ in
if hasCountdownCompleted {
timer.upstream.connect().cancel() // turn off timer
showAlert = true
} else {
viewModel.updateTimer()
}
}
.alert(isPresented: $showAlert) {
Alert(title: Text("Time's up"))
}
}
}
extension CountdownTimerView {
private var colon: some View {
Text(":")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.red)
}
}
#Preview {
CountdownTimerView(endDate: Date())
}
HomeScreenの用意、ContentViewの修正
import SwiftUI
struct HomeScreen: View {
@EnvironmentObject var endTimePickerViewModel: EndTimePickerViewModel
var body: some View {
NavigationStack {
if endTimePickerViewModel.isInitialized() {
CountdownTimerView(endDate: endTimePickerViewModel.calcEndTime())
.padding()
} else {
InitialView()
}
NavigationLink(destination: EndTimePickerView()) {
Text("Set Countdown Timer!")
.font(.title)
.fontWeight(.bold)
}
}
}
}
#Preview {
HomeScreen()
.environmentObject(EndTimePickerViewModel())
}
import SwiftUI
struct ContentView: View {
let endTimePickerViewModel: EndTimePickerViewModel = EndTimePickerViewModel()
var body: some View {
VStack {
HomeScreen()
}
.padding()
.environmentObject(endTimePickerViewModel)
}
}
#Preview {
ContentView()
}
感想
たったこれだけの実装でも、意外に色々なエッセンスを学べたと思います。やはり自分で実際に手を動かしてみると学ぶことが多いですね。これからしばらくはSwiftUIを使ったアプリ開発をやっていこうと考えています。
Flutterも好きですが、結局OS独自の機能に触れようと思ったらすぐにSwiftが必要になってしまったので、これはAndroidの方も結局KotlinやJavaで開発するようにした方が良いのではないかな、という気がしてきています。Android開発でも宣言的UIで実装できるJetpack Composeなるものが用意されているようで、かなり丁寧なチュートリアルも用意されていました。とりあえずSwiftUIで一つ今開発中のアプリをリリースできたらAndroidの方にも手を出していこうかなと企んでいます。