Amos Gyamfi
Amos GyamfiAugust 17, 2022

Animating a Circular Progress Bar in SwiftUI

Want to replicate the Apple Fitness Rings UI? Learn how to build an animated circular progress bar in SwiftUI using Shape together with the Trim modifier.
Animating a Circular Progress Bar in SwiftUI

This is the second tutorial of our series on the basics of animation in SwiftUI. If you missed the first article, you can find it here. This article will focus on making a simple ring animation for a gauge view, similar to the one in Apple's own Fitness app.

You will learn how to trim path properties in SwiftUI to create a drawing and erasing animations. You can achieve this using the stroke start or the stroke end properties of a SwiftUI path. The trim modifier has two parameters, from: and to: which can be used to create path trimming animations in SwiftUI. Let's get started!

Creating the Drawing animation

To start, create a blank SwiftUI project or a view file in Xcode and name it RingsAnimation.swift.

  1. Define the state variable @State private var drawingStroke = false. This will be used to change the path properties over time.
  2. Define the colors of the three rings. You can do so as parameters of your view, and customize the colors through color literals.
let strawberry = Color(#colorLiteral(red: 1, green: 0.1857388616, blue: 0.5733950138, alpha: 1))
let lime = Color(#colorLiteral(red: 0.5563425422, green: 0.9793455005, blue: 0, alpha: 1))
let ice = Color(#colorLiteral(red: 0.4513868093, green: 0.9930960536, blue: 1, alpha: 1))

Color literals are an Xcode feature that allow you to pick colors from the code editor and have them be used directly in code. These are great for small projects, but for larger apps you'll probably be better off storing colors another way, such as in an asset catalog color set.


  1. Create view for the rings. Let's make this a function that returns a view with both a background and a foreground ring. It will return a view that we'll use in our body. Make sure to rotate it 90 degrees anti-clock so that we can apply the trim modifier in the next step.
func ring(for color: Color) -> some View {
    // Background ring
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 16))
        .foregroundStyle(.tertiary)
        .overlay {
            // Foreground ring
            Circle()
                .stroke(color.gradient,
                        style: StrokeStyle(lineWidth: 16, lineCap: .round))
        }
        .rotationEffect(.degrees(-90))
}
  1. Trim the foreground ring based on drawingStroke. This will be done by using the trim modifier before the stroke modifier.
// Foreground ring
Circle().trim(from: 0, to: drawingStroke ? 1 : .leastNonzeroMagnitude)

Note that the trim modifier can only be applied to a Shape, and it also returns a shape. It has to be used before stroke, because although the latter can also be applied to any shape, it instead returns a view.

The trimming is done from 0 (starting position on middle right) to 1 if drawingStroke is true, and the smallest non-zero magnitude otherwise in a ternary operator. We use the smallest non-zero magnitude instead of 0 because if we did, the stroke would then not be drawn at all at the start of the animation.


  1. Build out the view body. Let's draw a black background, along with the three rings on a ZStack with different colors and sizes.
var body: some View {
    ZStack {
        Color.black
        ring(for: strawberry)
            .frame(width: 164)
        ring(for: lime)
            .frame(width: 128)
        ring(for: ice)
            .frame(width: 92)
    }
}

Note that because we're using Circle and not Ellipse, the rings will always be circular and therefore have the same width and height. That's why we don't need to specify the height for our view, and we can let it fill the space.


  1. Add an animation modifier. You can do so by creating an animation property on your view struct:
let animation = Animation
        .easeOut(duration: 3)
        .repeatForever(autoreverses: false)
        .delay(0.5)

And then you just append it to the view structure with .animation(animation, value: drawingStroke).

To break down this animation property, let's see what each line does:

  • The first line calls a static method of the Animation struct that creates an ease out animation with a duration of 3 seconds. This will make the rings move from the starting position to the end position in 3 seconds.
  • The second line is a method of an animation instance that return a new instance of the animation that repeats forever.
  • The third line is a method of the Animation struct that delays the animation by 0.5 seconds from the starting moment.
  1. Update the rings on appear. Use the onAppear modifier to update the rings when the view appears.
.onAppear {
    drawingStroke = true
}

Let's put it all together:

RingAnimation.swift
import SwiftUI
 
struct RingAnimation: View {
    @State private var drawingStroke = false
 
    let strawberry = Color(#colorLiteral(red: 1, green: 0.1857388616, blue: 0.5733950138, alpha: 1))
    let lime = Color(#colorLiteral(red: 0.5563425422, green: 0.9793455005, blue: 0, alpha: 1))
    let ice = Color(#colorLiteral(red: 0.4513868093, green: 0.9930960536, blue: 1, alpha: 1))
 
    let animation = Animation
        .easeOut(duration: 3)
        .repeatForever(autoreverses: false)
        .delay(0.5)
 
    var body: some View {
        ZStack {
            Color.black
            ring(for: strawberry)
                .frame(width: 164)
            ring(for: lime)
                .frame(width: 128)
            ring(for: ice)
                .frame(width: 92)
        }
        .animation(animation, value: drawingStroke)
        .onAppear {
            drawingStroke.toggle()
        }
    }
 
    func ring(for color: Color) -> some View {
        // Background ring
        Circle()
            .stroke(style: StrokeStyle(lineWidth: 16))
            .foregroundStyle(.tertiary)
            .overlay {
                // Foreground ring
                Circle()
                    .trim(from: 0, to: drawingStroke ? 1 : 0)
                    .stroke(color.gradient,
                            style: StrokeStyle(lineWidth: 16, lineCap: .round))
            }
            .rotationEffect(.degrees(-90))
    }
}

And that's it! The rings are now moving. You can see the animation in action by running the app in Xcode.

Animating a Circular Progress Bar in SwiftUI

Going Further

Now that you have the animation working, you can explore alternative designs. What if you try to change color, size, or stroke width? Or maybe try to match the trim modifier with a progress indicator? You could even use this as an opportunity to go and mess around with the Animation struct and try some of the other easing functions. It's all up to you.