Building a Stopwatch or Timer App in SwiftUI

SwiftUI offers a declarative way to build user interfaces across all Apple platforms. Creating a stopwatch or timer app is a fantastic project to learn the fundamentals of SwiftUI while building a practical and engaging application.

Introduction

In this comprehensive guide, we’ll walk through the process of building a stopwatch/timer app using SwiftUI. We’ll cover the core concepts, code snippets, and best practices to ensure you grasp every aspect of the development process.

Project Setup

Let’s start by setting up our project. Open Xcode and create a new iOS project. Choose the “App” template and make sure you select SwiftUI for the Interface. Name your project something appropriate like “StopwatchApp.”

Basic UI Layout

We’ll begin with a basic layout that includes a text display for the timer, start/stop button, and a reset button.

Step 1: Define UI Elements

import SwiftUI

struct ContentView: View {
    @State private var timeElapsed: Double = 0.0
    @State private var isRunning: Bool = false

    var body: some View {
        VStack {
            Text(String(format: "%.2f", timeElapsed))
                .font(.system(size: 50, weight: .medium, design: .monospaced))
                .padding()

            HStack {
                Button(action: {
                    isRunning ? stopTimer() : startTimer()
                }) {
                    Text(isRunning ? "Stop" : "Start")
                        .font(.title2)
                        .padding()
                        .background(isRunning ? Color.red : Color.green)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }

                Button(action: {
                    resetTimer()
                }) {
                    Text("Reset")
                        .font(.title2)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
        }
        .padding()
    }

    func startTimer() {
        isRunning = true
    }

    func stopTimer() {
        isRunning = false
    }

    func resetTimer() {
        isRunning = false
        timeElapsed = 0.0
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Explanation:

  • We define a ContentView struct that conforms to the View protocol.
  • @State private var timeElapsed: Double = 0.0 initializes the time elapsed and makes it a state variable to trigger UI updates.
  • @State private var isRunning: Bool = false indicates whether the timer is running or not.
  • A VStack is used to arrange the text display and buttons vertically.
  • An HStack is used to arrange the “Start/Stop” and “Reset” buttons horizontally.
  • The “Start/Stop” button toggles the timer state and changes its text and color accordingly.
  • The “Reset” button resets the timer to zero.

Implementing the Timer Logic

Next, we need to implement the actual timer logic to update the time elapsed.

Step 2: Add a Timer

import SwiftUI

struct ContentView: View {
    @State private var timeElapsed: Double = 0.0
    @State private var isRunning: Bool = false
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack {
            Text(String(format: "%.2f", timeElapsed))
                .font(.system(size: 50, weight: .medium, design: .monospaced))
                .padding()

            HStack {
                Button(action: {
                    isRunning ? stopTimer() : startTimer()
                }) {
                    Text(isRunning ? "Stop" : "Start")
                        .font(.title2)
                        .padding()
                        .background(isRunning ? Color.red : Color.green)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }

                Button(action: {
                    resetTimer()
                }) {
                    Text("Reset")
                        .font(.title2)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
        }
        .padding()
        .onReceive(timer) { _ in
            if isRunning {
                timeElapsed += 0.01
            }
        }
    }

    func startTimer() {
        isRunning = true
    }

    func stopTimer() {
        isRunning = false
    }

    func resetTimer() {
        isRunning = false
        timeElapsed = 0.0
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Explanation:

  • let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() creates a timer that fires every 0.01 seconds.
  • .onReceive(timer) { _ in ... } sets up a subscriber to the timer.
  • Inside the onReceive closure, we check if the timer is running. If so, we increment timeElapsed by 0.01.

Improving UI with Lap Times (Stopwatch)

Let’s add the functionality to record lap times, transforming our app into a full-fledged stopwatch.

Step 3: Add Lap Times

import SwiftUI

struct ContentView: View {
    @State private var timeElapsed: Double = 0.0
    @State private var isRunning: Bool = false
    @State private var lapTimes: [Double] = []
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack {
            Text(String(format: "%.2f", timeElapsed))
                .font(.system(size: 50, weight: .medium, design: .monospaced))
                .padding()

            HStack {
                Button(action: {
                    isRunning ? stopTimer() : startTimer()
                }) {
                    Text(isRunning ? "Stop" : "Start")
                        .font(.title2)
                        .padding()
                        .background(isRunning ? Color.red : Color.green)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }

                Button(action: {
                    resetTimer()
                }) {
                    Text("Reset")
                        .font(.title2)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }

                Button(action: {
                    recordLap()
                }) {
                    Text("Lap")
                        .font(.title2)
                        .padding()
                        .background(Color.orange)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            
            List {
                ForEach(lapTimes.indices.reversed(), id: .self) { index in
                    HStack {
                        Text("Lap \(lapTimes.count - index)")
                        Spacer()
                        Text(String(format: "%.2f", lapTimes[index]))
                    }
                }
            }
            .listStyle(PlainListStyle())
        }
        .padding()
        .onReceive(timer) { _ in
            if isRunning {
                timeElapsed += 0.01
            }
        }
    }

    func startTimer() {
        isRunning = true
    }

    func stopTimer() {
        isRunning = false
    }

    func resetTimer() {
        isRunning = false
        timeElapsed = 0.0
        lapTimes.removeAll()
    }
    
    func recordLap() {
        lapTimes.append(timeElapsed)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Explanation:

  • @State private var lapTimes: [Double] = [] declares an array to hold the lap times.
  • A new button “Lap” is added, which calls the recordLap function.
  • The recordLap function appends the current timeElapsed to the lapTimes array.
  • A List displays the recorded lap times in reverse order, so the latest lap appears at the top.

Building a Countdown Timer

Let’s transform our app into a countdown timer where users can set a specific time to count down from.

Step 4: Implement Countdown Timer

import SwiftUI

struct ContentView: View {
    @State private var timeRemaining: Int = 60
    @State private var isRunning: Bool = false
    @State private var timerInput: String = "60"
    
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack {
            Text("\(timeRemaining)")
                .font(.system(size: 50, weight: .medium, design: .monospaced))
                .padding()

            HStack {
                Text("Set Time:")
                TextField("Seconds", text: $timerInput)
                    .keyboardType(.numberPad)
                    .frame(width: 80)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
            .padding()

            HStack {
                Button(action: {
                    isRunning ? stopTimer() : startTimer()
                }) {
                    Text(isRunning ? "Stop" : "Start")
                        .font(.title2)
                        .padding()
                        .background(isRunning ? Color.red : Color.green)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }

                Button(action: {
                    resetTimer()
                }) {
                    Text("Reset")
                        .font(.title2)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
        }
        .padding()
        .onReceive(timer) { _ in
            if isRunning {
                if timeRemaining > 0 {
                    timeRemaining -= 1
                } else {
                    stopTimer()
                }
            }
        }
    }

    func startTimer() {
        if let time = Int(timerInput) {
            timeRemaining = time
        }
        isRunning = true
    }

    func stopTimer() {
        isRunning = false
    }

    func resetTimer() {
        isRunning = false
        timeRemaining = 0
        timerInput = "60"
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Explanation:

  • We replace timeElapsed with timeRemaining and set it as an Int.
  • A TextField allows the user to input the initial time.
  • In startTimer, we convert the text input to an integer and set timeRemaining.
  • The onReceive closure decrements timeRemaining every second and stops the timer when it reaches zero.

Enhancements

Here are some potential enhancements to consider:

  • Improved UI: Add better visual elements such as progress bars, custom fonts, or animations.
  • Settings: Allow users to customize timer intervals, display formats, and other options.
  • Background Support: Implement background task support so the timer continues even when the app is in the background.
  • Alerts: Show a notification when the timer completes.

Conclusion

Building a stopwatch or timer app in SwiftUI is an excellent way to learn about state management, UI updates, and basic app logic. By following the steps outlined in this guide, you’ve created a functional timer and explored advanced features like lap times and countdown functionality. Experiment with different UI elements and functionalities to deepen your understanding and create a personalized app.