Build a Stopwatch app with SwiftUI

In this post I will show you how to build a stopwatch app using SwiftUI. In this post I will show you how to create custom views using SwiftUI as well as some basic layout and using "ObjectBinding".

I know we are building a stopwatch app, but I am not going to go through the actual StopWatch code logic as I feel that is out of scope for this tutorial. This tutorial is more about building the apps UI with SwiftUI.

In the above picture you can see what the app will look like when we are done. It is nothing special, but it is functional.

When the app is complete, you will be able to start the stop watch, record laps times(this will only be in memory), pause the stop watch and reset it.

So now that we know what it needs to look like and what the functionality is, we can start building it.

Step 1: Add the StopWatch class to your project

I created this class for this tutorial only. I would not classify it as production ready as it has not been tested properly, the code is also not too great. It is for demonstration purposes only.

You can find the StopWatch class here, or you can download the entire project here.

Step 2: Add the timer Text()

In your main ContentView inside the body variable we need to add a VStack. Your code should look like this:

struct ContentView : View {
    @ObservedObject var stopWatch = StopWatch()
    
    var body: some View {
        VStack {
       	    // Code here
        }
    }
}

That VStack will hold all of our UI. Now that we have our VStack we can add in our Text view to display the timer text.

With SwiftUI the parent view cannot force a size on the child/children view, so we will need to do most of the styling on the Text view that we add. Add the following code inside the VStack that we added in just now.

Text(self.stopWatch.stopWatchTime)
    .font(.custom("courier", size: 70))
    .frame(width: UIScreen.main.bounds.size.width,
           height: 300,
           alignment: .center)

In the above code we are creating a new Text view. We set the default text to be 00:00:00 which will be updated later on with the proper value.

After that we start with the styling. I changed the font to courier, this works better as it is a monospace font, when I used the standard system font the text would shake because it was changing all the time. We also set the size to be 70 so that it is nice and big. The next thing we do is update the frame. We want the width to be the full width of the screen but we also want it to be quite tall, so we set the height to 300. Technically you can leave the .center alignment out as that is the default setting.

Your app should now look like this:

Not exactly what we want, but it is a start.

Step 3: Create the buttons view

Before we carry on with the layout, we need to create the buttons. This will be a new custom view. The buttons functionality as well as text will change depending on the state of the stopwatch.

For example, when the timer is stopped, we will have two buttons, one will be to reset the timer and the other will be to start the timer. If the timer is busy timing, those buttons will change to a lap button and a pause button.

Because the app is super small, I have put the logic in the button. If the app was bigger, I would strip out the logic from the button and use something else to create the button that I need based on the parameters I pass to it.

What do the buttons require?

Each button needs to take in two actions and two strings. They also need to have a color and they need to know if the timer is paused or not.

The code

To do this, create a new struct called StopWatchButton and have it conform to the View protocol as below:

struct StopWatchButton : View {
    var body: some View {
        // Buttons coming soon
    }
}

We can now add in the properties just above the body property.

Change your struct to look like this now:

struct StopWatchButton : View {
    var actions: [() -> Void]
    var labels: [String]
    var color: Color
    var isPaused: Bool

    var body: some View {
        // Buttons coming soon
    }
}

Great, we can now add the button logic in the body.

The first thing that we need to add is a button width variable in the body. This value is quite random, but it works for our needs.

Your body property should now look like this:

var body: some View {
    let buttonWidth = (UIScreen.main.bounds.size.width / 2) - 12
}

This will give an error but don't worry, we will sort that out now.

We will now create the button view. It is not too complicated. A basic Button in SwiftUI takes two arguments, one is the action and the other is the label view. In our action and label parameters we will check if the timer is paused and based on that we will know what function to call and what Text view we need to display.

Update your body variable to look like the below:

var body: some View {
    let buttonWidth = (UIScreen.main.bounds.size.width / 2) - 12
    
    return Button(action: {
            if self.isPaused {
                self.actions[0]()
            } else {
                self.actions[1]()
            }
        }) {
            if isPaused {
                Text(self.labels[0])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            } else {
                Text(self.labels[1])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            }
        }
        .background(self.color)
    }
}

In the above code you can see what we are doing. In the action section(the first closure) we check the isPaused property. If it is true then we use the first action in our actions array, otherwise we use the second action.

The same is true for the label. If isPaused is true, we will use the first string from our labels array, otherwise we use the second string.

Other than that, each Text view has the same styling, we could create our own custom text view for that, but this is pretty simple so I don't see much of a need for that right now. If we were going to use text views that were the same as these elsewhere then I would definitely create a custom text view.

One thing to note is how the Text view frame is being set and not the buttons frame. As I mentioned earlier, SwiftUI requires us to set the frame on the child view as the parent cannot tell the child what to do.

The last thing that we do is we set the background color for the button.

The final code for the StopWatchButton should look like this:

struct StopWatchButton : View {
    var actions: [() -> Void]
    var labels: [String]
    var color: Color
    var isPaused: Bool
    
    var body: some View {
        let buttonWidth = (UIScreen.main.bounds.size.width / 2) - 12
        
        return Button(action: {
            if self.isPaused {
                self.actions[0]()
            } else {
                self.actions[1]()
            }
        }) {
            if isPaused {
                Text(self.labels[0])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            } else {
                Text(self.labels[1])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            }
        }
        .background(self.color)
    }
}

Step 4: Adding the buttons

Ok, most of the complicated stuff is now out of our way and we can get back to laying out our UI.

The next thing that we need to do is to add the two instances of our new StopWatchButton. Before we do that we need to create an HStack that can wrap our buttons.

Update your ContentView struct to look like the below:

struct ContentView : View {
    @ObjectBinding var stopWatch = StopWatch()
    
    var body: some View {
        VStack {
            Text(self.stopWatch.stopWatchTime)
                .font(.custom("courier", size: 70))
                .frame(width: UIScreen.main.bounds.size.width,
                       height: 300,
                       alignment: .center)
            
            HStack{
                // Our buttons will go here
            }
        }
     }
 }

As you can see. Nothing much has changed except that we now have an HStack below our Text view that will display the current timers string value.

Inside that HStack you need to add the following code:

StopWatchButton(actions: [self.stopWatch.reset, self.stopWatch.lap],
                labels: ["Reset", "Lap"],
                color: Color.red,
                isPaused: self.stopWatch.isPaused())

StopWatchButton(actions: [self.stopWatch.start, self.stopWatch.pause],
                labels: ["Start", "Pause"],
                color: Color.blue,
                isPaused: self.stopWatch.isPaused())

After adding that code, your app should look like this:

This might be a bit confusing. In the above code we are creating two new instances of our StopWatchButton.

As discussed earlier these buttons have four properties. The first property will be the functions that we want to call depending on the isPaused state. The first button that we create will be our Reset/Lap and our second button will be our Start/Pause button. In the image you cannot see the Lap button and the Pause button, but if you run the app and click on the Start button, you will be able to see the Lap and Pause button. Please note, if you run the app and you tap on the Lap button, nothing will happen visually. The code will be working correctly, but in the next step I will show you how to get the lap times to show up in the list.  

Step 5: Displaying the lap times

This next part is pretty simple. All we have is a VStack that wraps around a Text view and a List view.

Below the HStack that we created in the previous step, add the following code:

VStack(alignment: .leading) {
    Text("Laps")
        .font(.title)
        .padding()

    List {
        ForEach(self.stopWatch.laps, id: \.uuid) { (lapItem) in
            Text(lapItem.stringTime)
        }
    }
}

Now that you have added that, your app should look something like this:

Please note that these screen shots are being taken in Xcode, if you run the app, you will see the cells of the List.

Ok, so what is happening in that code. The first thing that is different is that the VStack is taking an argument for alignment. The reason that I have done this is because if we remove it, the Laps text view will be centered on the screen, and I preferred it to be on the left hand side.

Inside the VStack we add a Text view. We set the font size to be of .title size, has it is a section heading in our app. We also add padding to the Text view. This gives it a nice amount of spacing so that it is separated from the buttons above.

The final part is to add the List view. List views require a unique identifier for each cell. Unfortunately, each lapItem would not have a uniquely identifying property, so I had to add a uuid property which gets used in the ForEach. We then have access to the lapItem. We can now add in another Text view in which we can display each of the lap times that we have stored. To do this we simply access the stringTime property on the lapTime object that we have, and pass that to the Text view.

If you build and run the app now, you should be able to start the stopwatch, tap the Lap button and see the lap times show up in the bottom List. You should also be able to tap the Pause button and have the stopwatch pause, and once it is paused you should be able to tap the Reset button which will reset the stopwatch time as well as remove all the lap times from the List.

This is what the final code looks like:

import SwiftUI
import Combine

struct StopWatchButton : View {
    var actions: [() -> Void]
    var labels: [String]
    var color: Color
    var isPaused: Bool
    
    var body: some View {
        let buttonWidth = (UIScreen.main.bounds.size.width / 2) - 12
        
        return Button(action: {
            if self.isPaused {
                self.actions[0]()
            } else {
                self.actions[1]()
            }
        }) {
            if isPaused {
                Text(self.labels[0])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            } else {
                Text(self.labels[1])
                    .foregroundColor(Color.white)
                    .frame(width: buttonWidth,
                           height: 50)
            }
        }
        .background(self.color)
    }
}

struct ContentView : View {
    @ObservedObject var stopWatch = StopWatch()
    
    var body: some View {
        VStack {
            Text(self.stopWatch.stopWatchTime)
                .font(.custom("courier", size: 70))
                .frame(width: UIScreen.main.bounds.size.width,
                       height: 300,
                       alignment: .center)

            HStack{
                StopWatchButton(actions: [self.stopWatch.reset, self.stopWatch.lap],
                                labels: ["Reset", "Lap"],
                                color: Color.red,
                                isPaused: self.stopWatch.isPaused())

                StopWatchButton(actions: [self.stopWatch.start, self.stopWatch.pause],
                                labels: ["Start", "Pause"],
                                color: Color.blue,
                                isPaused: self.stopWatch.isPaused())
            }

            VStack(alignment: .leading) {
                Text("Laps")
                    .font(.title)
                    .padding()

                List {
                    ForEach(self.stopWatch.laps, id: \.uuid) { (lapItem) in
                        Text(lapItem.stringTime)
                    }
                }
            }
        }
    }
}

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

If you liked this post please share it and subscribe to my youtube channel.

You can find the source code here.