TÓM TẮT
- 1 1. Giới thiệu chung về Circle Progress Bar
- 2 2. Kiến thức nền tảng: Vòng tròn trong đồ họa máy tính
- 3 3. Vẽ Circle Progress Bar trên Web (HTML/CSS/JS)
- 4 4. Vẽ Circle Progress Bar trên Android
- 5 5. Vẽ Circle Progress Bar trên iOS (Swift)
- 6 6. Các hiệu ứng nâng cao & Mẹo tùy biến
- 7 7. Kiểm thử và Debug
- 8 8. Tổng kết và những lưu ý quan trọng
1. Giới thiệu chung về Circle Progress Bar
Circle Progress Bar (hoặc Circular Progress Indicator) là một dạng biểu đồ vòng tròn dùng để hiển thị mức độ hoàn thành của một công việc, tiến trình tải dữ liệu, hoặc bất kỳ thông tin định lượng nào dưới dạng phần trăm. Khác với thanh tiến trình dạng thẳng (linear progress bar), Circle Progress Bar mang lại cảm giác trực quan hơn, đặc biệt phù hợp với các giao diện hiện đại, ứng dụng di động và web.
1.1. Lợi ích khi sử dụng Circle Progress Bar
- Thẩm mỹ cao: Thiết kế tròn tạo cảm giác mềm mại, thu hút người dùng.
- Tiết kiệm không gian: Vòng tròn có thể đặt ở giữa một biểu tượng, nút hoặc hình ảnh mà không chiếm quá nhiều diện tích.
- Dễ dàng tùy biến: Màu sắc, độ dày, tốc độ quay, và các hiệu ứng chuyển động có thể thay đổi linh hoạt.
- Thích hợp cho đa nền tảng: Có thể triển khai trên iOS, Android, Web (HTML/CSS/JS), và thậm chí trong các ứng dụng desktop.
1.2. Ứng dụng thực tế
- Ứng dụng di động: Hiển thị tiến độ tải ảnh, video, hoặc quá trình đồng bộ dữ liệu.
- Trang web: Thông báo quá trình tải trang, thời gian chờ xử lý form, hoặc tiến độ thanh toán.
- Dashboard: Đưa vào các biểu đồ KPI, tỉ lệ hoàn thành mục tiêu doanh thu, v.v.
Trong bài viết này, chúng ta sẽ đi sâu vào cách vẽ Circle Progress Bar từ những kiến thức cơ bản, các công cụ hỗ trợ, cho tới việc triển khai thực tế trên các nền tảng phổ biến như HTML/CSS/JavaScript, Android (Kotlin/Java), và iOS (Swift). Ngoài ra, còn có các mẹo tối ưu hiệu suất, cải thiện trải nghiệm người dùng và một số ví dụ thực tế để bạn có thể sao chép và tùy biến ngay lập tức.
2. Kiến thức nền tảng: Vòng tròn trong đồ họa máy tính

Có thể bạn quan tâm: Cách Vẽ Chữ “đoàn Kết” Đẹp Và Ấn Tượng: Hướng Dẫn Chi Tiết Từng Bước
2.1. Hình học vòng tròn
- Phương trình: ((x – x_0)^2 + (y – y_0)^2 = r^2) trong hệ tọa độ Đề-các.
- Góc (radian): 1 vòng tròn = (2\pi) radian = 360 độ. Khi vẽ phần vòng tròn, chúng ta thường dùng góc bắt đầu (start angle) và góc kết thúc (end angle).
- Phần trăm → góc: Để chuyển phần trăm thành góc, công thức là:
\text{angle} = \frac{\text{percentage}}{100} \times 360^\circ
2.2. Thuật toán vẽ vòng cung
Trong hầu hết các môi trường đồ họa (Canvas, SVG, Android Canvas, CoreGraphics), việc vẽ vòng cung thường dựa trên đường path hoặc arc:
– Arc yêu cầu: tâm (center), bán kính (radius), góc bắt đầu, góc kết thúc, và hướng (clockwise hoặc counter‑clockwise).
– Stroke (đánh dấu viền) và Fill (đổ màu) là hai cách hiển thị chính.
2.3. Các thuộc tính quan trọng
| Thuộc tính | Mô tả | Ảnh hưởng |
|---|---|---|
stroke-width |
Độ dày của đường viền | Độ đậm nhạt của vòng tròn |
stroke |
Màu viền | Thẩm mỹ, tương phản |
fill |
Màu nền (thường để transparent) | Thường không dùng cho progress |
stroke-linecap |
Kiểu đầu mút (butt, round, square) |
Độ mượt của đầu vòng |
transform |
Quay, dịch chuyển | Thay đổi vị trí, góc khởi đầu |
3. Vẽ Circle Progress Bar trên Web (HTML/CSS/JS)
3.1. Phương pháp 1: Sử dụng SVG + CSS Animation

Có thể bạn quan tâm: Cách Vẽ Chữ Đẹp Nhất: Hướng Dẫn Chi Tiết Từ Cơ Bản Đến Nâng Cao
3.1.1. Cấu trúc HTML
<div class="circle-progress" data-percentage="75"> <svg viewBox="0 0 100 100"> <!-- Vòng nền (background) --> <circle class="bg" cx="50" cy="50" r="45"></circle> <!-- Vòng tiến trình (foreground) --> <circle class="progress" cx="50" cy="50" r="45"></circle> </svg> <div class="percentage">75%</div>
</div>
3.1.2. CSS cơ bản
.circle-progress { position: relative; width: 150px; height: 150px;
}
svg { width: 100%; height: 100%; transform: rotate(-90deg); / Đặt góc bắt đầu ở trên /
}
circle { fill: none; stroke-width: 10;
}
circle.bg { stroke: #e6e6e6;
}
circle.progress { stroke: #ff6600; stroke-linecap: round; stroke-dasharray: 283; / 2πr = 2π45 ≈ 283 / stroke-dashoffset: 283; / Ẩn toàn bộ / transition: stroke-dashoffset 1s ease-out;
}
.percentage { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 1.5rem; font-weight: bold;
}
3.1.3. JavaScript để cập nhật
document.querySelectorAll('.circle-progress').forEach(function(el){ const percent = el.dataset.percentage; const circle = el.querySelector('circle.progress'); const radius = circle.r.baseVal.value; const circumference = 2 Math.PI radius; circle.style.strokeDasharray = `${circumference}`; const offset = circumference - (percent / 100) circumference; circle.style.strokeDashoffset = offset; // Cập nhật text el.querySelector('.percentage').textContent = `${percent}%`;
});
3.1.4. Giải thích chi tiết
stroke-dasharray: Đặt độ dài chuỗi dash bằng chu vi vòng tròn, tạo ra “đường liền”.stroke-dashoffset: Độ dịch offset, khi giảm từ chu vi tới 0 sẽ mở ra phần tiến trình.transform: rotate(-90deg): Đặt vị trí bắt đầu ở đỉnh trên (mặc định bắt đầu ở phía phải).
3.2. Phương pháp 2: Canvas + requestAnimationFrame
3.2.1. HTML
<canvas id="myCircle" width="200" height="200"></canvas>
3.2.2. JavaScript
function drawCircleProgress(ctx, percent, options = {}){ const { radius = 80, lineWidth = 12, bgColor = '#e6e6e6', progressColor = '#00bfff', startAngle = -0.5 Math.PI, // 12h clockwise = true } = options; const centerX = ctx.canvas.width / 2; const centerY = ctx.canvas.height / 2; const endAngle = startAngle + (clockwise ? 1 : -1) (percent / 100) 2 Math.PI; ctx.clearRect(0,0,ctx.canvas.width, ctx.canvas.height); // Vòng nền ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 Math.PI); ctx.strokeStyle = bgColor; ctx.lineWidth = lineWidth; ctx.stroke(); // Vòng tiến trình ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, endAngle, !clockwise); ctx.strokeStyle = progressColor; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.stroke(); // Text phần trăm ctx.font = '24px Arial'; ctx.fillStyle = '#333'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`${percent}%`, centerX, centerY);
} // Animation
let cur = 0;
const target = 85; // % mong muốn
const canvas = document.getElementById('myCircle');
const ctx = canvas.getContext('2d'); function animate(){ if(cur <= target){ drawCircleProgress(ctx, cur); cur += 1; // tốc độ tăng requestAnimationFrame(animate); }
}
animate();
3.3. Tối ưu hiệu năng trên Web
- Sử dụng CSS transitions cho SVG là cách nhẹ nhất, không cần tính toán lại mỗi frame.
- Cache DOM: Lưu trữ tham chiếu tới các phần tử (
querySelector) để tránh lặp lại. - Throttling: Khi cập nhật thường xuyên (ví dụ: download đa tệp), giảm tần suất redraw bằng
requestAnimationFramehoặcsetTimeouthợp lý. - Resize: Khi thay đổi kích thước container, tính lại
stroke-dasharrayvàviewBox.
4. Vẽ Circle Progress Bar trên Android
4.1. Sử dụng ProgressBar có sẵn (CircularProgressDrawable)
Android hỗ trợ ProgressBar dạng vòng tròn mặc định, nhưng để tùy biến sâu hơn, chúng ta thường tạo Custom View.
4.1.1. Layout XML
<com.example.customview.CircleProgressView android:id="@+id/circleProgress" android:layout_width="120dp" android:layout_height="120dp" app:progress="70" app:max="100" app:strokeWidth="12dp" app:progressColor="@color/colorAccent" app:backgroundColor="@color/colorGrey"/>
4.1.2. Tạo Custom View (Kotlin)
class CircleProgressView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) { private var progress = 0 private var max = 100 private var strokeWidth = 20f private var progressColor = Color.BLUE private var backgroundColor = Color.LTGRAY private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND } private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK textSize = 48f textAlign = Paint.Align.CENTER } init { // Đọc attrs từ XML context.theme.obtainStyledAttributes( attrs, R.styleable.CircleProgressView, 0, 0).apply { try { progress = getInt(R.styleable.CircleProgressView_progress, 0) max = getInt(R.styleable.CircleProgressView_max, 100) strokeWidth = getDimension(R.styleable.CircleProgressView_strokeWidth, 20f) progressColor = getColor(R.styleable.CircleProgressView_progressColor, Color.BLUE) backgroundColor = getColor(R.styleable.CircleProgressView_backgroundColor, Color.LTGRAY) } finally { recycle() } } backgroundPaint.color = backgroundColor backgroundPaint.strokeWidth = strokeWidth progressPaint.color = progressColor progressPaint.strokeWidth = strokeWidth } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val radius = (min(width, height) / 2f) - strokeWidth / 2f val centerX = width / 2f val centerY = height / 2f // Vòng nền canvas.drawCircle(centerX, centerY, radius, backgroundPaint) // Góc bắt đầu ở 12h val startAngle = -90f val sweepAngle = 360f progress / max // Vòng tiến trình val rect = RectF( centerX - radius, centerY - radius, centerX + radius, centerY + radius ) canvas.drawArc(rect, startAngle, sweepAngle, false, progressPaint) // Text phần trăm val percentText = "${(progress 100 / max)}%" val fontMetrics = textPaint.fontMetrics val baseline = centerY - (fontMetrics.ascent + fontMetrics.descent) / 2 canvas.drawText(percentText, centerX, baseline, textPaint) } // Hàm cập nhật tiến trình với animation fun setProgressAnimated(target: Int, duration: Long = 1000) { val animator = ValueAnimator.ofInt(progress, target) animator.duration = duration animator.interpolator = DecelerateInterpolator() animator.addUpdateListener { progress = it.animatedValue as Int invalidate() } animator.start() }
}
4.1.3. Định nghĩa attrs.xml
<resources> <declare-styleable name="CircleProgressView"> <attr name="progress" format="integer"/> <attr name="max" format="integer"/> <attr name="strokeWidth" format="dimension"/> <attr name="progressColor" format="color"/> <attr name="backgroundColor" format="color"/> </declare-styleable>
</resources>
4.1.4. Sử dụng trong Activity
val circleView = findViewById<CircleProgressView>(R.id.circleProgress)
circleView.setProgressAnimated(85) // animate tới 85%
4.2. Các lưu ý khi tối ưu cho Android
- Hardware acceleration: Đảm bảo
setLayerType(LAYER_TYPE_HARDWARE, null)nếu sử dụng gradient hoặc shadow. - Avoid overdraw: Vẽ nền và tiến trình trong một
onDrawduy nhất để giảm lần gọiinvalidate. - Reuse Paint objects: Tạo
Paintmột lần, không tạo lại trongonDraw. - DP vs PX: Luôn sử dụng
dpcho kích thước để hiển thị đồng nhất trên các màn hình.
5. Vẽ Circle Progress Bar trên iOS (Swift)

Có thể bạn quan tâm: Cách Vẽ Chữ Trên Tay: Hướng Dẫn Chi Tiết Từ Cơ Bản Đến Nâng Cao
5.1. Sử dụng CAShapeLayer
5.1.1. Tạo lớp CircularProgressView
import UIKit @IBDesignable
class CircularProgressView: UIView { // MARK: - Inspectable properties @IBInspectable var lineWidth: CGFloat = 12 { didSet { configureLayers() } } @IBInspectable var progressColor: UIColor = .systemBlue { didSet { progressLayer.strokeColor = progressColor.cgColor } } @IBInspectable var trackColor: UIColor = .lightGray { didSet { trackLayer.strokeColor = trackColor.cgColor } } @IBInspectable var progress: CGFloat = 0 { // 0.0 – 1.0 didSet { setProgress(progress, animated: false) } } private let trackLayer = CAShapeLayer() private let progressLayer = CAShapeLayer() private let percentageLabel = UILabel() // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } // MARK: - Layout override func layoutSubviews() { super.layoutSubviews() configureLayers() configureLabel() } // MARK: - Setup private func setup() { // Track layer trackLayer.fillColor = UIColor.clear.cgColor trackLayer.lineCap = .round layer.addSublayer(trackLayer) // Progress layer progressLayer.fillColor = UIColor.clear.cgColor progressLayer.lineCap = .round progressLayer.strokeEnd = 0 layer.addSublayer(progressLayer) // Label percentageLabel.textAlignment = .center percentageLabel.font = UIFont.boldSystemFont(ofSize: 24) addSubview(percentageLabel) } private func configureLayers() { let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 let center = CGPoint(x: bounds.midX, y: bounds.midY) let startAngle = -CGFloat.pi / 2 let endAngle = startAngle + 2 CGFloat.pi let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) trackLayer.path = path.cgPath trackLayer.strokeColor = trackColor.cgColor trackLayer.lineWidth = lineWidth progressLayer.path = path.cgPath progressLayer.strokeColor = progressColor.cgColor progressLayer.lineWidth = lineWidth } private func configureLabel() { percentageLabel.frame = bounds let percent = Int(progress 100) percentageLabel.text = "\(percent)%" } // MARK: - Public API func setProgress(_ value: CGFloat, animated: Bool = true, duration: CFTimeInterval = 0.8) { let clamped = max(0, min(1, value)) progress = clamped let animation = CABasicAnimation(keyPath: "strokeEnd") animation.fromValue = progressLayer.strokeEnd animation.toValue = clamped animation.duration = animated ? duration : 0 animation.timingFunction = CAMediaTimingFunction(name: .easeOut) progressLayer.strokeEnd = clamped progressLayer.add(animation, forKey: "progress") configureLabel() }
}
5.1.2. Sử dụng trong Interface Builder
- Kéo một
UIViewvào storyboard. - Đặt class của nó thành
CircularProgressView. - Tùy chỉnh các thuộc tính
lineWidth,progressColor,trackColor,progresstrực tiếp trong inspector.
5.1.3. Sử dụng trong code
let circular = CircularProgressView(frame: CGRect(x: 50, y: 100, width: 150, height: 150))
circular.lineWidth = 14
circular.progressColor = .systemGreen
circular.trackColor = .systemGray5
view.addSubview(circular) // Animate tới 65%
circular.setProgress(0.65, animated: true)
5.2. Tối ưu cho iOS
- Rasterization: Khi dùng
shadowhoặcgradient, bậtshouldRasterizeđể giảm tải GPU. - Dynamic Type: Đảm bảo
percentageLabelhỗ trợ thay đổi kích thước chữ. - Light/Dark mode: Sử dụng
UIColor.labelvàUIColor.systemBackgroundđể tự động thích ứng.
6. Các hiệu ứng nâng cao & Mẹo tùy biến
6.1. Gradient trên vòng tiến trình
- Web: Dùng
<defs><linearGradient>hoặcstroke: url(#grad)trong SVG. - Android: Tạo
SweepGradientvà gán choPaint.shader. - iOS: Dùng
CAGradientLayerkết hợpmaskvớiCAShapeLayer.
6.2. Thêm biểu tượng trung tâm

Có thể bạn quan tâm: Cách Vẽ Chữ Trên Pleco: Hướng Dẫn Chi Tiết Từ A Đến Z
Bạn có thể đặt một UIImageView hoặc Icon ở giữa vòng tròn để biểu thị trạng thái (play/pause, success, error).
6.3. Thời gian đếm ngược (Countdown)
Khi muốn hiển thị thời gian còn lại, thay vì hiển thị phần trăm, bạn có thể tính thời gian còn lại và cập nhật stroke-dashoffset mỗi giây.
// Example countdown 10s
let remaining = 10;
const interval = setInterval(() => { const percent = (remaining / 10) 100; // update circle as ở trên remaining--; if (remaining < 0) clearInterval(interval);
}, 1000);
6.4. Đánh dấu “Hoàn thành” với animation bounce
Khi tiến độ đạt 100%, bạn có thể dùng scale animation để vòng tròn “bắt nhảy” một chút, tạo cảm giác phản hồi tích cực.
- CSS:
@keyframes bounce { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } - Android:
ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f).setDuration(300).start(); - iOS:
UIView.animate(withDuration: 0.3, animations: { view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) })
6.5. Đọc dữ liệu từ API và cập nhật realtime
Khi nhận dữ liệu tải lên (upload) hoặc tải xuống (download) qua WebSocket hoặc Retrofit (Android) / Alamofire (iOS), bạn chỉ cần truyền phần trăm mới vào hàm cập nhật và để animation xử lý mượt mà.

7. Kiểm thử và Debug
| Kiểm thử | Mô tả | Công cụ |
|---|---|---|
| Kiểm tra độ chính xác | Đảm bảo góc khớp với phần trăm (±0.5%). | Console log, unit test (Jest, JUnit, XCTest) |
| Hiệu năng | Đánh giá FPS khi animate trên thiết bị thực. | Chrome DevTools Performance, Android Profiler, Xcode Instruments |
| Độ tương thích | Kiểm tra trên các trình duyệt (Chrome, Safari, Edge) và thiết bị (iPhone, Android). | BrowserStack, Firebase Test Lab |
| Truy cập | Đảm bảo màu sắc đủ tương phản, text có aria-label. |
Lighthouse, axe-core |
7.1. Unit Test mẫu (JavaScript)
test('calculate offset correctly', () => { const radius = 45; const circumference = 2 Math.PI radius; const percent = 30; const expected = circumference - (percent / 100) circumference; expect(getOffset(radius, percent)).toBeCloseTo(expected);
});
7.2. UI Test (Android)
@get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) @Test
fun progressBarAnimatesToTarget() { onView(withId(R.id.circleProgress)).perform(setProgress(80)) onView(withId(R.id.circleProgress)).check(matches(isDisplayed())) // Thêm kiểm tra giá trị text onView(allOf(isDescendantOfA(withId(R.id.circleProgress)), withText("80%"))).check(matches(isDisplayed()))
}
8. Tổng kết và những lưu ý quan trọng
- Hiểu rõ công thức góc: Phần trăm → góc, và ngược lại, là nền tảng để tính
stroke-dashoffsethoặcsweepAngle. - Lựa chọn công nghệ phù hợp: Đối với web, SVG + CSS thường nhẹ và dễ bảo trì; Canvas thích hợp khi cần vẽ nhiều đối tượng động. Trên di động,
CAShapeLayer(iOS) vàCanvas+Paint(Android) cho hiệu suất tốt. - Tối ưu hiệu năng: Tránh vẽ lại toàn bộ mỗi khung hình, tái sử dụng
Paint/Path/Layer, và chỉ cập nhật khi giá trị thay đổi. - Tùy biến màu sắc và hiệu ứng: Gradient, shadow, và animation bounce sẽ làm UI trở nên sinh động hơn.
- Kiểm thử kỹ lưỡng: Đảm bảo độ chính xác, hiệu năng, và khả năng truy cập cho người dùng khuyết tật.
Với những kiến thức và ví dụ thực tế ở trên, bạn đã nắm vững cách vẽ Circle Progress Bar trên đa nền tảng. Hãy bắt tay vào thực hành, tùy biến màu sắc, độ dày, và các hiệu ứng để tạo ra những giao diện đẹp mắt, chuyên nghiệp và thân thiện với người dùng. Chúc bạn thành công!
