Circular Progress Bar rất phổ biến: hiển thị tiến trình tải, download, loading... Bài viết hướng dẫn cách tạo đơn giản, dễ hiểu nhất dùng CAShapeLayer và CABasicAnimation.
Kiến trúc
- Track Layer: Vòng tròn nền (background track)
- Progress Layer: Vòng tròn tiến độ (animated)
- Cùng path tròn, khác
strokeEnd(0.0 → 1.0)
Các bước
Bước 1: Tạo dự án
Xcode → Single View App → Tên: "CircularProgress".
Bước 2: Tạo Custom View Class
File → New → File → Cocoa Touch Class → Tên: CircularProgressView → Subclass: UIView.
Bước 3: Thiết kế UI
Storyboard: Kéo UIView → Set Custom Class = CircularProgressView → Thêm 3 Button: "30%", "60%", "95%".
Tạo @IBOutlet: circularProgress (CircularProgressView)
Tạo @IBAction: btn30, btn60, btn95

Bước 4: Code CircularProgressView.swift
import UIKit
class CircularProgressView: UIView {
// MARK: - Layers
private let progressLayer = CAShapeLayer()
private let trackLayer = CAShapeLayer()
// MARK: - Inspectable Properties (xem trên Storyboard)
@IBInspectable var progressColor: UIColor = .systemBlue {
didSet { progressLayer.strokeColor = progressColor.cgColor }
}
@IBInspectable var trackColor: UIColor = .systemGray5 {
didSet { trackLayer.strokeColor = trackColor.cgColor }
}
@IBInspectable var lineWidth: CGFloat = 10 {
didSet {
progressLayer.lineWidth = lineWidth
trackLayer.lineWidth = lineWidth - 5
}
}
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupLayers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayers()
}
override func layoutSubviews() {
super.layoutSubviews()
updateCircularPath()
}
// MARK: - Setup
private func setupLayers() {
// Track layer (nền)
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.strokeEnd = 1.0 // Vẽ đầy đủ vòng tròn
layer.addSublayer(trackLayer)
// Progress layer (tiến độ)
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeEnd = 0.0 // Bắt đầu từ 0
progressLayer.lineCap = .round // Đầu tròn mượt
layer.addSublayer(progressLayer)
}
private func updateCircularPath() {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2
let circularPath = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: -CGFloat.pi / 2, // Bắt đầu từ 12h (top)
endAngle: 3 * CGFloat.pi / 2,
clockwise: true
)
trackLayer.path = circularPath.cgPath
progressLayer.path = circularPath.cgPath
}
// MARK: - Public API
/// Animation tiến độ từ 0 đến value (0.0 - 1.0)
func setProgress(_ value: Float, duration: TimeInterval = 1.0) {
let clampedValue = max(0, min(1, value)) // Clamp 0-1
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = duration
animation.fromValue = progressLayer.strokeEnd
animation.toValue = clampedValue
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
progressLayer.strokeEnd = CGFloat(clampedValue)
progressLayer.add(animation, forKey: "progressAnimation")
}
/// Reset về 0
func resetProgress() {
progressLayer.strokeEnd = 0
progressLayer.removeAnimation(forKey: "progressAnimation")
}
}
Bước 5: Code ViewController.swift
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var circularProgress: CircularProgressView!
override func viewDidLoad() {
super.viewDidLoad()
// Tùy chỉnh màu (có thể set trên Storyboard nhờ @IBInspectable)
circularProgress.progressColor = .systemPurple
circularProgress.trackColor = .systemCyan
circularProgress.lineWidth = 12
}
@IBAction func btn30(_ sender: Any) {
circularProgress.setProgress(0.30, duration: 1.0)
}
@IBAction func btn60(_ sender: Any) {
circularProgress.setProgress(0.60, duration: 1.0)
}
@IBAction func btn95(_ sender: Any) {
circularProgress.setProgress(0.95, duration: 1.0)
}
}
Kết quả
Nhấn button → Vòng tròn animate mượt từ 0 đến % tương ứng.

Mở rộng: Hiển thị text % ở giữa
// Thêm vào CircularProgressView
private let percentageLabel = UILabel()
private func setupLabel() {
percentageLabel.textAlignment = .center
percentageLabel.font = .systemFont(ofSize: 24, weight: .bold)
percentageLabel.textColor = .label
addSubview(percentageLabel)
percentageLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
percentageLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
percentageLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
func setProgress(_ value: Float, duration: TimeInterval = 1.0, showPercentage: Bool = true) {
// ... animation code ...
if showPercentage {
// Animate label text
let formatter = NumberFormatter()
formatter.numberStyle = .percent
formatter.maximumFractionDigits = 0
// Simple approach: update label at end
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
self.percentageLabel.text = formatter.string(from: NSNumber(value: value))
}
}
}
Lưu ý
lineCap = .roundtạo đầu tròn đẹp mắt- Start angle
-π/2bắt đầu từ 12h (top) - chuẩn UX - Clamp value 0-1 tránh crash
- Dùng
@IBInspectableđể config trên Storyboard không cần code - Production: cân nhắc thư viện Charts hoặc CircularProgressView pod