Circular Progress Bar with UIKit on iOS

Step-by-step guide on implementing a circular progress bar with UIKit, including custom views, layers, bezier paths, and engaging animations

Ruslan Dzhafarov
6 min readJun 4, 2024
Design by Kerr on Dribbble

In this tutorial, we will learn how to implement a circular progress bar using UIKit. This step-by-step guide will take you through creating a custom UIView subclass, defining custom layers, working with bezier paths, and adding custom progress animations.

Let’s get started!

Step 1: Create a New UIView Subclass

First, we need to create a new UIView subclass called CircularProgressBarView. This class will handle the drawing and animation of the circular progress bar.

import UIKit

final class CircularProgressBarView: UIView {

}

By subclassing UIView, we can customize its drawing behavior and add custom sublayers for our circular progress bar.

Step 2: Define Appearance Constants

Next, let’s define the constants for the appearance of the progress bar. This includes the line width and progress bar colors. Constants help in easily updating and maintaining the appearance settings.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}
}

Step 3: Setup Layers

Next, we need to add two CAShapeLayer instances to our view: backgroundLayer and progressLayer. The backgroundLayer will represent the complete circle, while the progressLayer indicate the current progress.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}

// MARK: - Layers

private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 0
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.progressColor.cgColor

return layer
}()

private lazy var backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 1
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.backgroundColor.cgColor

return layer
}()
}

Step 4: Add Layers to the View

Now, we need to add these layers to our view.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}

// MARK: - Layers

private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 0
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.progressColor.cgColor

return layer
}()

private lazy var backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 1
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.backgroundColor.cgColor

return layer
}()

// MARK: - Lifecycle

init() {
super.init(frame: .zero)
loadLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Layout

func loadLayout() {
layer.addSublayer(backgroundLayer)
layer.addSublayer(progressLayer)
}
}

Step 5: Apply Circular Path to Layers

Next, we need to define the circular path using UIBezierPath in the layoutSubviews() method. This path is then assigned to both backgroundLayer and progressLayer.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}

// MARK: - Layers

private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 0
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.progressColor.cgColor

return layer
}()

private lazy var backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 1
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.backgroundColor.cgColor

return layer
}()

// MARK: - Lifecycle

init() {
super.init(frame: .zero)
loadLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Layout

func loadLayout() {
layer.addSublayer(backgroundLayer)
layer.addSublayer(progressLayer)
}

override func layoutSubviews() {
super.layoutSubviews()

let circlePath = UIBezierPath(
arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
radius: (bounds.width - Appearance.lineWidth) / 2,
startAngle: -CGFloat.pi / 2,
endAngle: CGFloat.pi * 3 / 2,
clockwise: true
).cgPath

backgroundLayer.path = circlePath
progressLayer.path = circlePath
}
}

We create a circular bezier path which defines the shape of our progress bar. By specify the angles -CGFloat.pi / 2 to CGFloat.pi * 3 / 2 we create a full circle starting from the top. We also adjust the radius to account for the line width, ensuring the circle fits within the view’s bounds. Assigning the path to both layers ensures they follow the same circular path.

Step 6. Set progress

Next, we need to add a method to set current progress in our view.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}

// MARK: - Layers

private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 0
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.progressColor.cgColor

return layer
}()

private lazy var backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 1
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.backgroundColor.cgColor

return layer
}()

// MARK: - Lifecycle

init() {
super.init(frame: .zero)
loadLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Layout

private func loadLayout() {
layer.addSublayer(backgroundLayer)
layer.addSublayer(progressLayer)
}

override func layoutSubviews() {
super.layoutSubviews()

let circlePath = UIBezierPath(
arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
radius: (bounds.width - Appearance.lineWidth) / 2,
startAngle: -CGFloat.pi / 2,
endAngle: CGFloat.pi * 3 / 2,
clockwise: true
).cgPath

backgroundLayer.path = circlePath
progressLayer.path = circlePath
}


// MARK: - Public

func setProgress(_ progress: CGFloat) {
// Ensure progress is between 0 and 1
let clampedProgress = max(min(progress, 1), 0)

progressLayer.strokeEnd = clampedProgress
}
}

Step 7. Add progress animation

To make our progress bar more engaging, we should add the ability to set progress with animation. Let’s add a parameter to our method where we set the progress value.

import UIKit

final class CircularProgressBarView: UIView {

// MARK: - Constants

private struct Appearance {
static let lineWidth: CGFloat = 10.0
static let backgroundColor = UIColor.gray
static let progressColor = UIColor.systemBlue
}

// MARK: - Layers

private lazy var progressLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 0
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.progressColor.cgColor

return layer
}()

private lazy var backgroundLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.lineWidth = Appearance.lineWidth
layer.lineCap = .round
layer.strokeStart = 0
layer.strokeEnd = 1
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = Appearance.backgroundColor.cgColor

return layer
}()

// MARK: - Lifecycle

init() {
super.init(frame: .zero)
loadLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Layout

private func loadLayout() {
layer.addSublayer(backgroundLayer)
layer.addSublayer(progressLayer)
}

override func layoutSubviews() {
super.layoutSubviews()

let circlePath = UIBezierPath(
arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
radius: (bounds.width - Appearance.lineWidth) / 2,
startAngle: -CGFloat.pi / 2,
endAngle: CGFloat.pi * 3 / 2,
clockwise: true
).cgPath

backgroundLayer.path = circlePath
progressLayer.path = circlePath
}

// MARK: - Public

func setProgress(_ progress: CGFloat, animated: Bool) {
// Ensure progress is between 0 and 1
let clampedProgress = max(min(progress, 1), 0)

if animated {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = progressLayer.strokeEnd
animation.toValue = clampedProgress
animation.duration = 0.5
animation.timingFunction = CAMediaTimingFunction(name: .linear)
progressLayer.removeAnimation(forKey: "progress")
progressLayer.add(animation, forKey: "progress")
}

progressLayer.strokeEnd = clampedProgress
}
}

Step 8: Using CircularProgressBarView

Finally, we can use our CircularProgressBarView in a view controller.

import UIKit

class ViewController: UIViewController {

private lazy var circularProgressBarView = CircularProgressBarView()

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(circularProgressBarView)

circularProgressBarView.setProgress(0.75, animated: true)
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

circularProgressBarView.frame = CGRect(
x: view.bounds.midX - 125,
y: view.bounds.midY - 125,
width: 250,
height: 250
)
}
}

Conclusion

With these steps, you now have a fully functional circular progress bar in your iOS app using UIKit. You can customize it further to fit your needs, such as changing colors, line widths, or adding additional animations.

Thank you for reading until the end. If you have any questions or feedback, feel free to leave a comment below.

If you enjoyed reading this article, please press the clap button 👏 . Your support encourages me to share more of my experiences and write more articles like this. Follow me here on medium for more updates!

Happy coding! 😊👨‍💻👩‍💻

More from Ruslan Dzhafarov

Advanced UIKit Techniques

7 stories

Design Patterns in Swift

9 stories

Advanced Swift Programming

3 stories

--

--

Ruslan Dzhafarov

Senior iOS Developer since 2013. Sharing expert insights, best practices, and practical solutions for common development challenges