Implementing a countdown timer in SwiftUI
Implementing a countdown timer in SwiftUI
Overview
Let's implement a countdown timer in SwiftUI.
Background
I wanted to develop an app utilizing certain features of iOS Family Controls, and since I needed to work with SwiftUI rather than Flutter, I've been studying SwiftUI recently. In the process, I decided to implement a countdown feature, so I created a sample app for both learning and investigation purposes.
Link to Family Controls Documentation
Source Code
You can check out the source code in the following link.
https://github.com/tkugimot/CountDownTimer
Summary of the implementation
When creating a new project with the steps mentioned below, I believe the following two files are already created:
- CountDownTimerApp
- ContentView
In addition to the above, the following files will be created:
File name | explanation |
---|---|
CountDownTimerView | Displays the countdown time. |
CountDownTimerViewModel | A model that holds the numeric representation of the countdown time. |
EndTimePickerView | Screen to set the desired countdown time. |
EndTimePickerViewModel | Model to store the desired countdown time. |
InitialView | View used for the initial display. |
HomeScreen | Home screen. |
Upon clicking "Set CountDown Timer!" to set the desired countdown time, the time is stored in the EndTimePickerViewModel. The app then calculates the difference between the current time and the selected end time.
By passing the end time to the CountDownTimerViewModel during initialization, the app calculates the difference every second and displays it in the CountDownTimerView.
When the countdown is completed (i.e., the end time has passed the current time), an alert saying "Time's Up!" is displayed.
Implementation Key Points (State Management)
It was particularly enlightening for studying basic state management.
アノテーション | 役割 |
---|---|
@State | Temporarily stores variables used only within a single screen. |
@EnvironmentObject | Used when sharing a model implemented as an ObservableObject across multiple screens. |
@ObservedObject | Temporarily stores a model used only within a single screen. CountDownTimerView uses this when it wants to update synchronously with updates to EndTimePickerViewModel. |
Implementation
Creating a New Project in Xcode
1. Open Xcode and select "Create a new Xcode project..."
2. Choose "App" and click "Next."
3. Set "Product Name" to "CountDownTimer" and click "Next," then click "Create."
Creating InitialView
Display only an hourglass image in this view. This image is provided by default, so you don't need to prepare it yourself.
import SwiftUI
struct InitialView: View {
var body: some View {
VStack {
Image(systemName: "hourglass")
.font(.system(size: 50))
.foregroundColor(.blue)
.padding()
}
.padding()
}
}
#Preview {
InitialView()
}
Prepare a screen to set the countdown time
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())
}
Prepare count down screen
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())
}
Prepare HomeScreen, Modify 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()
}
Thoughts
Even with such a simple implementation, I feel like I've learned various essential concepts. Indeed, hands-on experience is a great way to learn. I'm planning to focus on app development using SwiftUI for a while.
While I also like Flutter, when it comes to exploring platform-specific features, it quickly becomes necessary to use Swift. This has led me to think that, in the end, it might be better to develop for Android using Kotlin or Java. It seems that in Android development, there's something called Jetpack Compose that allows for declarative UI implementation, and there are quite detailed tutorials available. For now, I'm thinking of diving into Android development once I've released an app currently in development using SwiftUI.