Andy Regensky

Implementing the Apple Watch activity rings in SwiftUI - Part 1

The activity rings in the activity app on Apple Watch and its iOS companion are an iconic design element. However, reproducing these seemingly simple activity rings comes with certain obstacles. While creating a circular path indicating the current progress and stroking it with an angular gradient is straightforward, the particular overlapping behaviour of the activity rings for a progress larger than 1 requires some additional elements. In part 1 of this series, we'll take a closer look at a simple, yet elegant implementation of the activity rings in SwiftUI.

TL;DR: Final code of part 1.

Step 1: Base layer

The ActivityRing view stores several properties accomodating the current progress, the line width and the color gradient. The base layer of the activity ring is defined by a Circle, which is trimmed to indicate the current progress and rotated by 90° counter-clockwise to match the orientation of the Apple Watch model. The circular shape is then stroked with an angular gradient using the colors defined by the gradient property and the start and end angles are defined such that they match the current progress. The stroke width is defined by the line width property and a round line cap is chosen. Finally, the view is scaled to fit and padded such that we receive a quadratic frame fitting inside the superview's bounds.

struct ActivityRing: View {
    var progress: Double
    var lineWidth: CGFloat
    var gradient: Gradient

    var body: some View {
        Circle()
            .trim(from: 0, to: CGFloat(progress))
            .rotation(.degrees(-90))
            .stroke(
                AngularGradient(gradient: gradient,
                                center: .center,
                                startAngle: .degrees(-90),
                                endAngle: .degrees(progress * 360 - 90)),
                style: .init(lineWidth: lineWidth, lineCap: .round)
            )
            .scaledToFit()
            .padding(lineWidth/2)
    }
}

With this simple base layer implementation, the following results are obtained. While the result for a progress of 0.8 does already match our expectations, the result for a progress of 1.2 requires some further adaptions.

Step 2: Fixing the line cap

In order to achieve a round line cap for progresses larger than 1, another element has to be added to the view. The round line cap is represented by another Circle, that is sized such that it matches the line width of the stroked circle and placed at the end angle of the angular gradient. This is achieved by a combination of positioning, offsetting and rotating. Thereby, the correct position and offset are chosen dynamically with the help of a GeometryReader. The color is chosen to match the end color of the gradient. This custom line cap is added to the view hierarchy in an overlay after stroking the base layer.

Circle()
    ...
    .stroke(...)
    .overlay(
        GeometryReader { geo in
            // End round line cap
            Circle()
                .fill(self.gradient.stops[1].color)
                .frame(width: self.lineWidth, height: self.lineWidth)
                .position(x: geo.size.width / 2, y: geo.size.height / 2)
                .offset(x: min(geo.size.width, geo.size.height)/2)
                .rotationEffect(.degrees(self.progress * 360 - 90))
        }
    )
    ...

The result for a progress of 1.2 does now form a round line cap, while the result for a progress of 0.8 remains similar. However, a vital design feature of the Apple Watch activity rings is missing: Depth.

Step 3: Adding depth

To add depth to the activity rings, their line end has to cast a shadow on the underlying activity ring elements. This is easily done by adding a shadow to the overlaid round line end cap from Step 2.

Circle()
    ...
    .stroke(...)
    .overlay(
        GeometryReader { geo in
            // End round line cap and shadow
            Circle()
                .fill(self.gradient.stops[1].color)
                .frame(width: self.lineWidth, height: self.lineWidth)
                .position(x: geo.size.width / 2, y: geo.size.height / 2)
                .offset(x: min(geo.size.width, geo.size.height)/2)
                .rotationEffect(.degrees(self.progress * 360 - 90))
                .shadow(color: .black, radius: 4, x: 0, y: 0)
        }
    )
    ...

While the added shadow adds depth to the activity rings, the custom line cap is now clearly discernible from the base layer and does hence not result in the desired overlapping effect.

Step 4: Fixing the overlap

As we want to display the front facing half of the line cap only, and the shadow should only be casted towards the front as well, we cut the rear facing half of the line cap. This is done using .clipShape on the overlaid end line cap. The clip shape is a stroked circle matching the base circle line width. It is rotated such that only the front facing half of the line cap lies within the clipping region.

Circle()
    ...
    .stroke(...)
    .overlay(
        GeometryReader { geo in
            // End round line cap and shadow
            Circle()
                .fill(self.gradient.stops[1].color)
                .frame(width: self.lineWidth, height: self.lineWidth)
                .position(x: geo.size.width / 2, y: geo.size.height / 2)
                .offset(x: min(geo.size.width, geo.size.height)/2)
                .rotationEffect(.degrees(self.progress * 360 - 90))
                .shadow(color: .black, radius: self.lineWidth/2, x: 0, y: 0)
        }
        .clipShape(
            // Clip end round line cap and shadow to front
            Circle()
                .rotation(.degrees(-90 + self.progress * 360 - 0.5))
                .trim(from: 0, to: 0.25)
                .stroke(style: .init(lineWidth: self.lineWidth))
        )
    )
    ...

As you can see below, we achieved our goal of creating Apple Watch-like activity rings with a self overlapping appearance for progresses larger than 1.

Thanks for following this step-by-step tutorial. You can find the full code for use in your projects on my GitHub. In part 2 of this series, we'll further polish the activity rings to fix issues with the gradient for very small progresses, refine the gradient color mapping to match the Apple Watch model and re-build the iconic Apple Watch interface with its three nested activity rings. Follow me on Twitter @andyregensky to get notified when part 2 is available. Moreover, if you have any comments, questions or remarks, you spotted an error, or you just liked this article and want to get in touch, please do not hesitate to contact me. See you next time!

Tagged with: