TÓM TẮT

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ách Vẽ Circlep Progress Bả
Cách Vẽ Circlep Progress Bả

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ách Vẽ Circlep Progress Bả
Cách Vẽ Circlep Progress Bả

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 requestAnimationFrame hoặc setTimeout hợp lý.
  • Resize: Khi thay đổi kích thước container, tính lại stroke-dasharrayviewBox.

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 onDraw duy nhất để giảm lần gọi invalidate.
  • Reuse Paint objects: Tạo Paint một lần, không tạo lại trong onDraw.
  • DP vs PX: Luôn sử dụng dp cho 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ách Vẽ Circlep Progress Bả
Cách Vẽ Circlep Progress Bả

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 UIView vào storyboard.
  • Đặt class của nó thành CircularProgressView.
  • Tùy chỉnh các thuộc tính lineWidth, progressColor, trackColor, progress trự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 shadow hoặc gradient, bật shouldRasterize để giảm tải GPU.
  • Dynamic Type: Đảm bảo percentageLabel hỗ trợ thay đổi kích thước chữ.
  • Light/Dark mode: Sử dụng UIColor.labelUIColor.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ặc stroke: url(#grad) trong SVG.
  • Android: Tạo SweepGradient và gán cho Paint.shader.
  • iOS: Dùng CAGradientLayer kết hợp mask với CAShapeLayer.

6.2. Thêm biểu tượng trung tâm

Cách Vẽ Circlep Progress Bả
Cách Vẽ Circlep Progress Bả

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à.

Cách Vẽ Circlep Progress Bả
Cách Vẽ Circlep Progress Bả

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

  1. 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-dashoffset hoặc sweepAngle.
  2. 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.
  3. 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.
  4. 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.
  5. 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!

Rate this post
Mục nhập này đã được đăng trong Blog. Đánh dấu trang permalink.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *