How to Develop a Workout Countdown in Watchkit
Create a View that can render any single frame of an animation

by Lou Franco

Making a countdown animation for your workout app is really not that different than any other SwiftUI based animation you might do.

But, we have to start somewhere, and there is one surprising thing I learned while doing it. I don't want to bury it at the end of the article, because it's the most important thing you need to know.

Apps on the watch do not keep running when the watch goes idle (for example, when you lower your wrist). Workout apps only keep running if they are in an active workout.

Therefore, you must start the workout before you start the animation, and then immediately pause the workout until the animation is done.

If you do not do this, the user may start the workout and then lower their wrist. The app will go to sleep, and when they look at the app to see how it's going, it will complete the animation and then start the workout.

We'll get into workouts and how to start and pause them in a future article. And then, I'll be sure to show you how to do this correctly, but it's an important point I wanted to make now.

Creating the Animation

Here's what we'll try to do:

Animation of a countdown

Here is my suggestion for how to do these kinds of things generally in SwiftUI.

  1. Create a new View that represents one frame of the animation.
  2. Use @Binding variable that maps some input to the frame.
  3. Make sure that the variable can be animated to the in-between frames by making it a CGFloat or some floating-point type.
  4. Write body so that it returns a View based on this variable.
  5. In your previews, make several @State variables to represent the key frames and make a preview for each one.

Note that we are not using any animation here at all, we're just exposing a binding variable that can be animated. We'll let the caller decide how to set this binding.

Create the View

Here's some code to start from. It puts the countdown number in the center of a green circle (that has a gray circle under it)

struct CountdownView: View {
  var body: some View {
    ZStack {
      Text("3")
        .font(Font.system(.largeTitle).monospacedDigit())
        .foregroundColor(Color(.white))
      Circle()
        .stroke(lineWidth: 15)
        .foregroundColor(Color(.darkGray))
        .padding(10)
      Circle()
        .stroke(style: StrokeStyle(lineWidth: 13, lineCap: .round, lineJoin: .round))
(x: 1, y: 0, z: 0))
        .foregroundColor(Color(.green))
        .padding(10)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color(.black))
  }
}

This draws what we want it to look like at the beginning of the animation.

Now, to start making it useful, we need to first add a binding variable. Add this code to the CountdownView

// Set countdown to a number from 3 to 0. It's animatable.
@Binding var countdown: CGFloat

We are going to use countdown to render the view.

Basing the render on the binding

Now that we have added a way to control the rendering, we have to start using it.

The simplest thing to do is to use the countdown in the center of the view. You might be tempted to do:

Text("\(countdown)")

But, remember that countdown is a floating-point number that will be set to values between 3.0 and 0.0, but since we want to animate it, it will take on many non-Integer values.

We want the view to show the integers between 3 and 0, and we want it to show 1 at the end when the countdown is 0, so we'll add 0.01 to it and use ceil so that we get the effect we want. We also need to make sure it maxes out at 3.

So, replace Text("3") with:

Text("\(String(format: "%.0f", min(3, ceil(countdown + 0.01))))")

This will make the countdown show 3, then 2, then 1 even as countdown will go from 3.0, 2.88, 2.67, down to 0.2, 0.1, 0.

Fix the Previews

Our previews won't build until we fix the calls to our view, so at the top of the preview struct, add:

@State static var countdown1: CGFloat = 2.66
@State static var countdown2: CGFloat = 2.25
@State static var countdown3: CGFloat = 1.25
@State static var countdown4: CGFloat = 0.25

and use them in several CountdownView calls

static var previews: some View {
  CountdownView(countdown: $countdown1)
  CountdownView(countdown: $countdown2)
  CountdownView(countdown: $countdown3)
  CountdownView(countdown: $countdown4)
}

We should see the numbers from 3 to 1, but we still have a full circle.

The next step is to only show a part of the green circle.

Using countdown to draw part of a shape

In SwiftUI, shapes with a path (like Circles) can be set to be trimmed, meaning that you can set a start-percent and end-percent, and the shape will only be drawn between those points.

For us, we want the circle to be drawn from 0% to an end based on countdown. It's 100% (or 1.0) when countdown is 3 and 0% when countdown is 0. We get that with countdown / 3.0

So, we can go to the green circle and add the trim as the first modifier.

.trim(from: 0, to: countdown / 3.0)

The problem with this is that we now see that Circles are drawn from their right-most point and clockwise. We want it to be drawn from the top and counter-clockwise.

We can fix that with rotation effects.

Using Rotation Effects

When we set the countdown to 2.75, 1.75, and 0.75, it looks like this:

The countdown with trim before rotation

There are two rotations we need to get the circle to be drawn the way we want.

The first one is in the 2D plane of the watch screen, to get the start point to the bottom of the render, or a 90 degree rotation. It would then look like this:

The countdown with one rotation

Then, we want to flip the whole thing in 3D around the X-axis 180 degrees. That would move the start point to the top and also make the drawing go counter-clockwise. It now looks how we wanted it to:

The countdown with both rotations

You can add these effects by adding the following two modifiers to the green circle directly below the stroke modifier.

.rotation(Angle(degrees: 90))
.rotation3DEffect(Angle(degrees: 180), axis: (x: 1, y: 0, z: 0))

If you have trouble seeing why these work, comment out each line or change the angles to see how it affects the final render.

Here's the final code:

struct CountdownView: View {
  @Binding var countdown: CGFloat

  var body: some View {
    ZStack {
      Text("\(String(format: "%.0f", min(3, ceil(countdown + 0.01))))")
        .font(Font.system(.largeTitle).monospacedDigit())
        .foregroundColor(Color(.white))
      Circle()
        .stroke(lineWidth: 15)
        .foregroundColor(Color(.darkGray))
        .padding(10)
      Circle()
        .trim(from: 0, to: countdown / 3.0)
        .stroke(style: StrokeStyle(lineWidth: 13, lineCap: .round, lineJoin: .round))
        .rotation(Angle(degrees: 90))
        .rotation3DEffect(Angle(degrees: 180), axis: (x: 1, y: 0, z: 0))
        .foregroundColor(Color(.green))
        .padding(10)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color(.black))
  }
}

We'll connect this up to a timer in the next article

Subscribe to be notified of the next WatchKit article

This article is part of a series covering Workout apps. If you want to see articles about WatchKit in general, see How to Develop Apple Watch Apps with WatchKit.

Make sure to sign up to my newsletter to get notified when a new article is published.

Contact me if you are working on a Watch app and need help.

Next Article: How to Animate the Workout Countdown Timer

Never miss an article

Get more articles like this in your inbox.