How to build a circular progress bar in SwiftUI

In this tutorial we’re going to learn how to design and build a circular progress bar in SwiftUI using stacks, Circle shapes and view modifiers. The sample app will include a circular progress bar and a button underneath it, which will increment the progress.

The final product will look like this:

Circular progress bar in SwiftUI
Circular progress bar in SwiftUI

Start by creating a new single view iOS app using SwiftUI.

Design/Architecture considerations

  • Just like in a UIProgressView, our current progress value will be represented by a Float which can have values between 0.0 and 1.0 (inclusive). It will simplify the math later on in the tutorial.
  • If you set the current progress value to anything above 1.0, the progress bar will treat it as 1.0 (or 100%) and won’t extend beyond its bounds.
  • The container/parent view will manage the state of progress bar (e.g., its current value).
  • The increment button will increase the current progress value by a random value.

Prepare ContentView

First of all, add a progressValue @State variable to the ContentView which will correspond to the progress value displayed by the progress bar.

Then, make sure that the body of ContentView is a ZStack and change the background color of main view to make it look nicer. Finally, make sure that the background color spreads across the entire screen by applying the edgesIgnoringSafeArea(.all) modifier.

import SwiftUI

struct ContentView: View {
    @State var progressValue: Float = 0.0
    
    var body: some View {
        ZStack {
            Color.yellow
                .opacity(0.1)
                .edgesIgnoringSafeArea(.all)
        }
    }
}

There is nothing exciting to see yet, but the result should look like this:

App background using edgesIgnoringSafeArea(.all) modifier
App background using edgesIgnoringSafeArea(.all) modifier

Start building ProgressBar view

Start by creating empty ProgressBar view with a reference to its current progress value binding and one circle reflecting background of the progress bar. Style the first circle by applying desired stroke width, opacity and color:

struct ProgressBar: View {
    @Binding var progress: Float
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 20.0)
                .opacity(0.3)
                .foregroundColor(Color.red)
        }
    }
}

Use the new ProgressBar view in the body of ContentView by wrapping it in a VStack and using Spacer() to push it to the top of the screen. Use the frame() modifier to fix the size of the progress bar and apply some padding to move it away from screen edges:

struct ContentView: View {
    @State var progressValue: Float = 0.0
    
    var body: some View {
        ZStack {
            Color.yellow
                .opacity(0.1)
                .edgesIgnoringSafeArea(.all)
            
            VStack {
                ProgressBar(progress: self.$progressValue)
                    .frame(width: 150.0, height: 150.0)
                    .padding(40.0)
                
                Spacer()
            }
        }
    }
}

Pay special attention to the following line which passes a reference to the progressValue binding from ContentView to the ProgressBar view:

ProgressBar(progress: self.$progressValue)

The result should look like this:

Background of progress bar
Background of progress bar

Indicate progress on the circle

The progress on the circle will be indicated by layering another circle (with darker color) on top of the background circle we just created. We’ll refer to this circle a progress circle. That’s why we used a ZStack earlier when creating the ProgressBar view – it will allow us to place one Circle above another and create an impression of progress bar.

In order to only render a part of the progress circle (to represent a percentage/fraction), we’re going to use the trim() modifier on the Circle shape which trims a shape by a fractional amount.

Add a second Circle to the ZStack of ProgressBar with fixed trim value for now (to indicated 30% progress) and rounded stroke line on both sides:

ZStack {
    Circle()
        .stroke(lineWidth: 20.0)
        .opacity(0.3)
        .foregroundColor(Color.red)
    
    Circle()
        .trim(from: 0.0, to: 0.3)
        .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
        .foregroundColor(Color.red)
}

The result should look like that:

Initial progress bar
Initial progress bar

Fantastic! We’re getting closer to what we want to achieve however there are several things we need to fix right now:

  • We have hardcoded the trim value to 0.3 (or 30%) but we need to make it refer to the actual progress value variable and prevent it from going over 1.0 (or 100%).
  • Personally, I’d like the progress indicator to start at the top of the progress circle and not on the right hand side (which is default).
  • We need to add an animation to the progress circle to create an appearance of smooth progression when progress value is updating.

Let’s tackle the above points one by one.

Point progress circle at its progress value variable

Update the trim() modifier parameters to trim the progress circle from 0.0 to self.progress. The stroke() and foregroundColor() modifiers stay the same:

Circle()
    .trim(from: 0.0, to: CGFloat(self.progress))
    .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
    .foregroundColor(Color.red)

In order to prevent the trim from wrapping around and going over 1.0 (or 100%) we need to place a constraint on its end value. We’ll achieve it by applying the following formula:

.trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))

In other words, we’re going to pick current progress value as long as it’s less than 1.0. If for some reason it’s higher than 1.0, then we’ll cap it at 1.0 by applying the min() function. Update your trim() modifier with the above code.

Start progress indicator at the top of the progress circle

All we need to do is to rotate the progress circle by 270 degrees in order to align its origin with the top. Add the rotationEffect() modifier to the progress circle:

Circle()
    .trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
    .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
    .foregroundColor(Color.red)
    .rotationEffect(Angle(degrees: 270.0))

Animate the progress circle

Animations in SwiftUI can be as simple as applying the animation() modifier with desired animation type. The complete progress circle should look like that:

Circle()
    .trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
    .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
    .foregroundColor(Color.red)
    .rotationEffect(Angle(degrees: 270.0))
    .animation(.linear)

Add a percentage text label in the middle of progress bar

Add a new Text view to the ZStack of the ProgressBar view:

Text(String(format: "%.0f %%", min(self.progress, 1.0)*100.0))
    .font(.largeTitle)
    .bold()

The text view will display the current progress value (no larger than 1.0) and multiply it by a 100 to indicate a percentage. The String format modifier will round the current progress value to zero decimal places.

Complete ProgressBar view

To sum it all up, the ProgressBar view should look like this:

struct ProgressBar: View {
    @Binding var progress: Float
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 20.0)
                .opacity(0.3)
                .foregroundColor(Color.red)
            
            Circle()
                .trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
                .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
                .foregroundColor(Color.red)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear)
            Text(String(format: "%.0f %%", min(self.progress, 1.0)*100.0))
                .font(.largeTitle)
                .bold()
        }
    }
}

If you’d like to see it in action, hardcode the progressValue inside of the ContentView to a desired value (e.g., 0.28):

struct ContentView: View {
    @State var progressValue: Float = 0.28
    ...

The result should look like this:

Progress bar in SwiftUI
Progress bar in SwiftUI

Revert the progressValue back to 0.0.

Add increment button to control the progress bar

For testing purposes, we’re going to add a button to the ContentView, inside of its VStack, just below the ProgressBar() view. The button will call the incrementProgress() method which picks a random value from an array of possible progress values and updates the progressValue variable.

Complete code for the project

struct ContentView: View {
    @State var progressValue: Float = 0.0
    
    var body: some View {
        ZStack {
            Color.yellow
                .opacity(0.1)
                .edgesIgnoringSafeArea(.all)
            
            VStack {
                ProgressBar(progress: self.$progressValue)
                    .frame(width: 150.0, height: 150.0)
                    .padding(40.0)
                
                Button(action: {
                    self.incrementProgress()
                }) {
                    HStack {
                        Image(systemName: "plus.rectangle.fill")
                        Text("Increment")
                    }
                    .padding(15.0)
                    .overlay(
                        RoundedRectangle(cornerRadius: 15.0)
                            .stroke(lineWidth: 2.0)
                    )
                }
                
                Spacer()
            }
        }
    }
    
    func incrementProgress() {
        let randomValue = Float([0.012, 0.022, 0.034, 0.016, 0.11].randomElement()!)
        self.progressValue += randomValue
    }
}
struct ProgressBar: View {
    @Binding var progress: Float
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 20.0)
                .opacity(0.3)
                .foregroundColor(Color.red)
            
            Circle()
                .trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
                .stroke(style: StrokeStyle(lineWidth: 20.0, lineCap: .round, lineJoin: .round))
                .foregroundColor(Color.red)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear)

            Text(String(format: "%.0f %%", min(self.progress, 1.0)*100.0))
                .font(.largeTitle)
                .bold()
        }
    }
}

Final result:

Circular progress bar in SwiftUI
Circular progress bar in SwiftUI

Related tutorials:

Questions, comments or suggestions? Follow us on Twitter @theswiftguide