Zubora Code

Implementing a countdown timer in SwiftUI

Implementing a countdown timer in SwiftUI

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

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.

References

Toshimitsu Kugimoto

Software Engineer

Specializing in backend and web frontend development for payment and media at work, the go-to languages for these tasks include Java and TypeScript. Additionally, currently exploring Flutter app development as a side project.