Zubora Code

SwiftUIでカウントダウンタイマーを実装する

SwiftUIでカウントダウンタイマーを実装する

Published: 8 January, 2024
Revised: 8 January, 2024

概要

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の方にも手を出していこうかなと企んでいます。


参考


Toshimitsu Kugimoto

Software Engineer

仕事では決済やメディアのWebやスマホアプリのBE開発、WebのFE開発をやっています。 JavaとTypeScriptをよく使います。プライベートではFlutterでのアプリ開発にも挑戦中です。