Implementing a Modern SwiftUI Circular Progress Bar & Control

Implementing a Modern SwiftUI Circular Progress Bar & Control

When I first wrote PZCircularControl in 2019, SwiftUI was still in its early days. The framework has matured significantly since then, introducing new features that enable interactions that were previously only achievable by dropping down to the Kits. Recently, I revisited the codebase and decided that a complete refactor of both the API surface and the implementation was overdue.

Original API

The original implementation had several issues that made it less than suitable for modern SwiftUI.

For example, the original API contract required three generic type parameters for styling (InnerBackgroundType, OuterBackgroundType, TintType), which limited composition – the cornerstone of all SwiftUI API design. We also used a PZCircularControlParams class to configure the control. While the configuration object pattern is common in AppKit and UIKit, it doesn't take advantage of SwiftUI's declarative nature.

Despite the name, the control was also initially designed to serve display purposes only, and was not directly interactive or editable by the user, unless the developer chose to extend it. With the refactor, there was an opportunity to change this and offer a built-in capability for the user to interactively edit the value.

Redesigning the API

The new API prioritizes ergonomics and SwiftUI idioms while maintaining and oftentimes improving flexibility.

You can declare a simple read-only version of the control in a single trivial line of code.

// A simple example of the display-only version of the control.
CircularControl(progress: 0.4)
0:00
/0:02

But you can now also declare the control to be editable, and allow the users to drag the knob on the control to change its value live. This works on iOS, macOS – and now also visionOS.

// A more advanced example of the editable circular control. 
CircularControl(
    progress: $value,
    strokeWidth: 15,
    style: .init(
        track: Color.secondary.opacity(0.2),
        progress: LinearGradient(colors: [.blue, .purple])
    )
) {
    CustomLabel()
}
0:00
/0:09

CircularControl is now generic over the shape styles its contents, as well as the label, which lets us shed the wrapping types and rely more heavily on Swift's type system and type inference.

public struct CircularControlStyle<Track: ShapeStyle, Progress: ShapeStyle, Knob: ShapeStyle> {
    let track: Track
    let progress: Progress
    let knob: Knob
}

The label can be customized just like the content of any other view via ViewBuilder. Editing capabilities are built-in, along with the native support for Binding-based configuration.

Interactive Control

The most interesting technical challenge was likely implementing the interactive control, the behavior of dragging along the circular path, and handling the various edge cases of how the user might interact with the knob across the various supported platforms.

Interaction Model

The interactive version of the new control has a knob that travels between the start and the end points. The user can drag it along the circular track. The developer may also customize whether the knob will wrap around the track when dragged beyond either the start or the end point.

The circular control's interactive behavior relies on two key mathematical concepts:

Converting touches or clicks to angles

When the user touches the control, we need to convert their touch point into an angle. This is done by:

  • Finding the relative position from the center point using atan2
  • Converting the angle into a 0-1 progress value, where 1 corresponds to , the maximum value.
  • Adjusting for SwiftUI's coordinate system where 0° is at the top of the control and increases clockwise.

Converting back from progress to knob position is the reverse process:

let angle = Double.pi * 2 * progress - Double.pi / 2
let position = CGPoint(
    x: cos(angle) * radius,
    y: sin(angle) * radius
)

Handling the wraparound behavior

Since a circle wraps around at , we need special handling when users drag across the start / end point. We detect this by comparing the change in angle – if it's too large in magnitude, we know the user has crossed the boundary. For example, when the user drags the control's knob in the increasing direction and crosses the end point, the computed angle delta will be negative and close to 1 in magnitude, despite the positive direction of travel.

Modern SwiftUI

The new @Entry macro, introduced at WWDC 2024, has greatly reduced the boilerplate of declaring new EnvironmentValues.

extension EnvironmentValues {
    @Entry public var circularControlAllowsWrapping: Bool = false
    @Entry public var circularControlKnobScale: CGFloat = 1.4
}

The complete implementation is available on GitHub.