Andy Regensky

Implementing the Apple Watch activity rings in SwiftUI - Part 2

A long time has passed since part 1 of this series on recreating the iconic activity rings from Apple Watch in SwiftUI. If you didn't read it yet, you should do so before continuing with part 2.

In the following, we build upon the activity ring from part 1 and use it to recreate the Apple Watch interface with its three nested activity rings. This part required a great amount of reverse engineering in order to recreate the exact behaviour of the Apple Watch model with its colors, gradient behaviour and sizing.

TL;DR: Final code of part 2.

Step 1: Background ring

For progresses smaller than 100%, the activity rings on Apple Watch are overlaid on a background ring. The color of the background ring is selected similar to the end color of the corresponding activity ring gradient with a low opacity of 0.1. It's added behind the actual activity ring using a ZStack.

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

    var body: some View {
        ZStack {
            //Background ring
            Circle()
                .stroke(
                    gradient.stops.last!.color,
                    style: .init(lineWidth: lineWidth)
                )
                .opacity(0.1)
            // Main ring
            Circle()
                ...
        }
    }
}

As expected, this adds a background ring with a single matching color and low opacity behind the activity ring. Note that we can force unwrap the last gradient stop as the gradient is expected to have at least one entry. While the gradient could possibly define more than two stops, in this article, we assume exactly two stops. No more, no less, as required for the recreation of the Apple Watch activity rings.

Step 2: Gradient adaptions

While the additional background ring brought us closer to our final goal of matching the Apple Watch activity ring model, there is a problem with low progresses smaller than 0.5. A careful comparison to the original model shows that for these small progresses, the final stop of the gradient does not lie on the end point of the activity ring but is fixed to a progress of 0.5 instead (bottom of the ring). To achieve this, we define a function angularGradient() that returns the gradient adaptively. This replaces the angular gradient in stroking the main ring.

func angularGradient() -> AngularGradient {
    return AngularGradient(
        gradient: gradient,
        center: .center,
        startAngle: .degrees(-90),
        endAngle: .degrees((progress > 0.5 ? progress : 0.5) * 360 - 90)
    )

// Main ring
Circle()
    ...
    .stroke(
        // Replace here:
        angularGradient(),
        style: .init(lineWidth: lineWidth, lineCap: .round)
    )
    ...
}

This results in a calmer gradient for low progresses while keeping the behaviour for larger progresses. However, the end butt color does not match the end color value of the activity ring for small progresses anymore.

Step 3: Fixing the end butt color

To fix the end butt color mismatch for progresses smaller than 0.5, we need to calculate the correct color at the end butt position. This requires a linear interpolation of the two gradient stop colors (start and end) based on the current progress. The gradient stop colors are defined using SwiftUI Color. To perform the linear interpolation, we need to extract the different component values (red, green, blue) for each color. As SwiftUI does not provide an API for this yet, we need to translate the color to UIColor first. There are very interesting discussions on StackOverflow that inspired my implementation:

The final implementation for SwiftUI Color interpolation is provided by Mofawaw.

// Source: https://stackoverflow.com/a/64411618 by Mofawaw
extension Color {
    var components: (r: Double, g: Double, b: Double, o: Double)? {
        let uiColor: UIColor
        
        var r: CGFloat = 0
        var g: CGFloat = 0
        var b: CGFloat = 0
        var o: CGFloat = 0
        
        if self.description.contains("NamedColor") {
            let lowerBound = self.description.range(of: "name: \"")!.upperBound
            let upperBound = self.description.range(of: "\", bundle")!.lowerBound
            let assetsName = String(self.description[lowerBound..<upperBound])
            
            uiColor = UIColor(named: assetsName)!
        } else {
            uiColor = UIColor(self)
        }

        guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &o) else {
            return nil
        }
        
        return (Double(r), Double(g), Double(b), Double(o))
    }
    
    func interpolateTo(color: Color, fraction: Double) -> Color {
        let s = self.components!
        let t = color.components!
        
        let r: Double = s.r + (t.r - s.r) * fraction
        let g: Double = s.g + (t.g - s.g) * fraction
        let b: Double = s.b + (t.b - s.b) * fraction
        let o: Double = s.o + (t.o - s.o) * fraction
        
        return Color(red: r, green: g, blue: b, opacity: o)
    }
}

Similar to the adaption of the angular gradient for small progresses, we define a function endButtColor() to adaptively calculate the end butt color of the activity ring. Depending on the progress, the end butt color is equal to the final gradient stop color or is calculated by interpolating the gradient stop colors. This replaces the end butt color in the main ring overlay.

func endButtColor() -> Color {
    let color = progress > 0.5 ?
        gradient.stops.last!.color :
        gradient.stops.first!.color.interpolateTo(
            color: gradient.stops.last!.color,
            fraction: 2 * progress
        )
    return color
}

// Main ring
Circle()
    ...
    .overlay(
        GeometryReader { geometry in
            // End round butt and shadow
            Circle()
                // Replace here:
                .fill(endButtColor())
                ...

This fixes the color mismatch at the end butt for small progresses.

Step 4: Recreating the activity rings from the Activity App

Now that the base activity ring matches the Apple Watch model, we can recreate the activity rings from the activity app on Apple Watch and iPhone by composing a new view of multiple base activity rings. Most importantly, we need to extract the correct color gradients from the Apple Watch model. After some reverse engineering, we can define the following gradients for the different activity rings.

extension Gradient {
    static var activityMove: Gradient {
        return Gradient(colors: [
            Color(red: 0.8823529412, green: 0, blue: 0.07843137255),
            Color(red: 1, green: 0.1960784314, blue: 0.5294117647)
        ])
    }
    
    static var activityExercise: Gradient {
        Gradient(colors: [
            Color(red: 0.2156862745, green: 0.862745098, blue: 0),
            Color(red: 0.7176470588, green: 1, blue: 0)
        ])
    }
    
    static var activityStand: Gradient {
        Gradient(colors: [
            Color(red: 0, green: 0.7294117647, blue: 0.8823529412),
            Color(red: 0, green: 0.9803921569, blue: 0.8156862745)
        ])
    }
}

Furthermore, the line width and spacing of the activity rings needs to be figured out. While the spacing between the activity rings depends on context and will be init configurable, the line width is close to 1/10th of the available space for the activity rings and is hence set responsively using a GeometryReader.

struct ActivityRings: View {
    
    var ringGap: CGFloat = 2
    var progressMove
    var progressExercise
    var progressStand
    
    var body: some View {
        ZStack {
            GeometryReader { geo in
                ActivityRing(progress: progressMove,
                             lineWidth: geo.size.width/10,
                             gradient: .activityMove)
                ActivityRing(progress: progressExercise,
                             lineWidth: geo.size.width/10,
                             gradient: .activityExercise)
                    .padding(geo.size.width/10 + ringGap)
                ActivityRing(progress: progressStand,
                             lineWidth: geo.size.width/10,
                             gradient: .activityStand)
                    .padding(2 * (geo.size.width/10 + ringGap))
            }
        }
    }
}

Looking at the final results, we can be happy! The rings match the iconic activity rings known from Apple Watch. As we can see, by carefully implementing the base rings, the composition of the three nested activity rings is straightforward using SwiftUI's flexible layout system.

I hope that part 2 of this series on implementing the Apple Watch activity rings in SwiftUI was helpful to you! You can find the final code on my GitHub. Please let me know if there are any other things that you'd like to see in this series by contacting me on Twitter @andyregensky or via mail. Of course, 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 as well. All the best and a happy new year!

- Andy

Tagged with: