TÓM TẮT

Mở Đầu

Circle ProgressBar (hoặc Circular Progress Indicator) là một trong những thành phần UI (User Interface) phổ biến trong các ứng dụng di động hiện đại, đặc biệt là trên nền tảng Android. Khác với ProgressBar dạng linear truyền thống, Circle ProgressBar cho phép hiển thị tiến độ dưới dạng vòng tròn, mang lại cảm giác trực quan và thẩm mỹ hơn cho người dùng. Trong bài viết này, chúng ta sẽ đi sâu vào cách vẽ Circle ProgressBar từ các khái niệm cơ bản, các phương pháp thực hiện (sử dụng XML, Canvas, hoặc thư viện bên thứ ba), tới các tùy chỉnh nâng cao như animation, gradient, shadow và tích hợp với ViewModel.

Bài viết được chia thành các phần chính:

  1. Khái niệm và nguyên lý hoạt động
  2. Cách vẽ Circle ProgressBar bằng XML (ProgressBar tích hợp sẵn)
  3. Tự tạo Custom View với Canvas
  4. Sử dụng thư viện hỗ trợ (Material Components, CircleProgressBar, MPAndroidChart, v.v.)
  5. Animation và Interpolator
  6. Tùy chỉnh màu sắc, gradient, shadow
  7. Kết nối với LiveData / ViewModel
  8. Kiểm thử và tối ưu hiệu suất
  9. Kết luận và một số lưu ý quan trọng

Mỗi phần sẽ cung cấp ví dụ code chi tiết, giải thích từng bước và những lưu ý thực tiễn để bạn có thể áp dụng ngay vào dự án của mình.

1. Khái Niệm và Nguyên Lý Hoạt Động Của Circle ProgressBar

1.1. Circle ProgressBar là gì?

Circle ProgressBar là một widget hiển thị trạng thái tiến độ dưới dạng một vòng tròn hoặc cung tròn. Thông thường, nó bao gồm:

  • Background track: vòng tròn nền, thường có màu xám hoặc mờ, đại diện cho toàn bộ tiến độ (100%).
  • Progress arc: cung tròn màu sắc hiển thị phần đã hoàn thành (từ 0% tới giá trị hiện tại).
  • Optional thumb/indicator: một điểm hoặc hình ảnh di chuyển dọc theo cung để chỉ vị trí hiện tại.
  • Text hoặc icon: hiển thị phần trăm, thời gian, hoặc biểu tượng ở giữa vòng.

1.2. Nguyên lý vẽ

Khi vẽ một Circle ProgressBar, chúng ta thực chất đang vẽ hai hình elip (hoặc vòng tròn) trên Canvas:

  1. Draw background circle – dùng drawArc hoặc drawCircle với Paint.Style.STROKE.
  2. Draw progress arc – dùng drawArc với góc bắt đầu (startAngle) và góc quét (sweepAngle). sweepAngle được tính bằng công thức:
sweepAngle = (progress / maxProgress) 360
  1. Optional: Vẽ text trung tâm bằng drawText hoặc sử dụng TextView overlay.

1.3. Các thuộc tính quan trọng

Thuộc tính Mô tả Giá trị mặc định
max Giá trị tối đa của progress 100
progress Giá trị hiện tại 0
strokeWidth Độ dày của vòng 8dp
backgroundColor Màu nền vòng #CCCCCC
progressColor Màu tiến độ #3F51B5
startAngle Góc bắt đầu (độ) -90 (điểm trên)
clockwise Quay chiều kim đồng hồ true
animationDuration Thời gian animation 500ms

Hiểu rõ các thuộc tính này sẽ giúp bạn kiểm soát mọi khía cạnh của Circle ProgressBar.

2. Vẽ Circle ProgressBar Bằng XML (ProgressBar Có Sẵn)

Android cung cấp một widget ProgressBar có thể được biến tấu thành dạng vòng tròn bằng cách thay đổi style. Đây là cách nhanh nhất nếu bạn không cần tùy chỉnh phức tạp.

2.1. Sử dụng android:indeterminateDrawable

<ProgressBar android:id="@+id/circularProgressBar" style="?android:attr/progressBarStyleHorizontal" android:layout_width="120dp" android:layout_height="120dp" android:indeterminate="false" android:max="100" android:progress="30" android:progressDrawable="@drawable/custom_circular_progress" android:layout_gravity="center"/>

2.2. Tạo custom_circular_progress.xml trong thư mục drawable

<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Background track --> <item android:id="@android:id/background"> <shape android:shape="ring" android:innerRadiusRatio="3" android:thicknessRatio="8"> <size android:width="120dp" android:height="120dp"/> <solid android:color="#E0E0E0"/> </shape> </item> <!-- Progress arc --> <item android:id="@android:id/progress"> <rotate android:fromDegrees="-90" android:pivotX="50%" android:pivotY="50%"> <shape android:shape="ring" android:innerRadiusRatio="3" android:thicknessRatio="8"> <size android:width="120dp" android:height="120dp"/> <gradient android:startColor="#FF5722" android:endColor="#FFC107" android:angle="0"/> </shape> </rotate> </item>
</layer-list>

Giải thích:

  • layer-list chứa 2 layer: background và progress.
  • shape kiểu ring tạo vòng tròn.
  • rotate dùng để đặt góc bắt đầu là -90° (đỉnh trên).
  • gradient giúp tạo hiệu ứng màu chuyển dần.

2.3. Cập nhật giá trị trong code

Cách Vẽ Circle Progressbar
Cách Vẽ Circle Progressbar
val progressBar = findViewById<ProgressBar>(R.id.circularProgressBar)
progressBar.progress = 70 // Cập nhật 70%

Ưu điểm: Đơn giản, không cần viết code Java/Kotlin.

Nhược điểm: Hạn chế trong việc tùy chỉnh animation, không thể thêm text trung tâm, không hỗ trợ gradient phức tạp hoặc shadow.

3. Tự Tạo Custom View Với Canvas

Khi yêu cầu UI phức tạp hơn (ví dụ: gradient đa màu, shadow, thumb, text), chúng ta cần tự viết một Custom View.

3.1. Khởi tạo lớp Custom View

class CircleProgressBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) { // Thuộc tính private var maxProgress = 100 private var progress = 0 private var strokeWidth = 20f private var startAngle = -90f private var clockwise = true // Paints private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = Color.LTGRAY strokeCap = Paint.Cap.ROUND } private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = Color.BLUE strokeCap = Paint.Cap.ROUND } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.DKGRAY textSize = 48f textAlign = Paint.Align.CENTER } // RectF chứa vòng tròn private val rectF = RectF() init { // Đọc attribute từ XML nếu có attrs?.let { val a = context.obtainStyledAttributes(it, R.styleable.CircleProgressBar, 0, 0) maxProgress = a.getInt(R.styleable.CircleProgressBar_maxProgress, 100) progress = a.getInt(R.styleable.CircleProgressBar_progress, 0) strokeWidth = a.getDimension(R.styleable.CircleProgressBar_strokeWidth, 20f) startAngle = a.getFloat(R.styleable.CircleProgressBar_startAngle, -90f) clockwise = a.getBoolean(R.styleable.CircleProgressBar_clockwise, true) backgroundPaint.strokeWidth = strokeWidth progressPaint.strokeWidth = strokeWidth // Color backgroundPaint.color = a.getColor(R.styleable.CircleProgressBar_backgroundColor, Color.LTGRAY) progressPaint.color = a.getColor(R.styleable.CircleProgressBar_progressColor, Color.BLUE) a.recycle() } } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) val padding = strokeWidth / 2 + 4 // Thêm một chút padding cho shadow rectF.set(padding, padding, w - padding, h - padding) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 1. Vẽ vòng nền canvas.drawArc(rectF, 0f, 360f, false, backgroundPaint) // 2. Tính góc quét val sweepAngle = 360f progress / maxProgress if (clockwise) 1 else -1 // 3. Vẽ progress arc canvas.drawArc(rectF, startAngle, sweepAngle, false, progressPaint) // 4. Vẽ text trung tâm val percent = (progress 100 / maxProgress).toInt() val text = "$percent%" val x = width / 2f val y = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2 canvas.drawText(text, x, y, textPaint) } // Setter/getter để cập nhật từ bên ngoài fun setProgress(value: Int, animated: Boolean = true) { val newVal = value.coerceIn(0, maxProgress) if (animated) { val animator = ValueAnimator.ofInt(progress, newVal) animator.duration = 500 animator.interpolator = AccelerateDecelerateInterpolator() animator.addUpdateListener { progress = it.animatedValue as Int invalidate() } animator.start() } else { progress = newVal invalidate() } } // Các setter khác (color, strokeWidth, …) có thể thêm tương tự
}

3.2. Định nghĩa custom attributes trong res/values/attrs.xml

<resources> <declare-styleable name="CircleProgressBar"> <attr name="maxProgress" format="integer" /> <attr name="progress" format="integer" /> <attr name="strokeWidth" format="dimension" /> <attr name="startAngle" format="float" /> <attr name="clockwise" format="boolean" /> <attr name="backgroundColor" format="color" /> <attr name="progressColor" format="color" /> </declare-styleable>
</resources>

3.3. Sử dụng trong layout XML

<com.example.customview.CircleProgressBar android:id="@+id/customCircle" android:layout_width="200dp" android:layout_height="200dp" app:maxProgress="100" app:progress="45" app:strokeWidth="16dp" app:backgroundColor="#EEEEEE" app:progressColor="#FF5722" app:startAngle="-90" app:clockwise="true"/>

3.4. Cập nhật giá trị từ Activity/Fragment

val circle = findViewById<CircleProgressBar>(R.id.customCircle)
circle.setProgress(80) // animate tới 80%

Ưu điểm:

  • Toàn quyền tùy chỉnh: màu, độ dày, gradient, shadow, text, animation.
  • Hiệu suất tốt vì vẽ trực tiếp trên Canvas.

Nhược điểm:

  • Cần kiến thức về Canvas, Paint và Android custom view lifecycle.
  • Phải tự quản lý state (save/restore) nếu muốn hỗ trợ rotation.

4. Sử Dụng Thư Viện Hỗ Trợ

Nếu bạn không muốn “tự tay” viết code vẽ, có rất nhiều thư viện mở rộng giúp tạo Circle ProgressBar nhanh chóng và đẹp mắt.

4.1. Material Components – CircularProgressIndicator

Google Material Components cung cấp CircularProgressIndicator (được tích hợp trong com.google.android.material:material). Đây là lựa chọn chuẩn cho UI Material Design.

Thêm dependency:

implementation 'com.google.android.material:material:1.9.0'

Sử dụng trong XML:

<com.google.android.material.progressindicator.CircularProgressIndicator android:id="@+id/materialCircular" android:layout_width="80dp" android:layout_height="80dp" app:indicatorSize="80dp" app:trackThickness="8dp" app:trackColor="#E0E0E0" app:indicatorColor="#3F51B5" app:showAnimationBehavior="linear" app:progress="40"/>

Tùy chỉnh gradient: Material không hỗ trợ gradient trực tiếp, nhưng bạn có thể dùng CircularProgressDrawable tùy chỉnh.

4.2. CircleProgressBar – Thư viện của lopspower

GitHub: https://github.com/lopspower/CircleProgressBar

Thêm dependency:

implementation 'com.github.lopspower:circleprogressbar:1.4.3'

Sử dụng:

<com.lopspower.CircularProgressBar android:id="@+id/lopsCircle" android:layout_width="150dp" android:layout_height="150dp" app:cpb_progress="75" app:cpb_maxProgress="100" app:cpb_progressBarWidth="12dp" app:cpb_backgroundProgressBarWidth="12dp" app:cpb_progressBarColor="#FF5722" app:cpb_backgroundProgressBarColor="#BDBDBD" app:cpb_roundBorder="true"/>

Thư viện này hỗ trợ gradient, shadow, và text mặc định.

4.3. SmoothProgressBar – Hiệu ứng mượt mà

Thư viện com.github.castorflex.smoothprogressbar:library thường dùng cho linear, nhưng có phiên bản SmoothCircularProgressBar.

Example:

implementation 'com.github.castorflex.smoothprogressbar:library:1.1.0'

4.4. Khi nào nên dùng thư viện?

Cách Vẽ Circle Progressbar
Cách Vẽ Circle Progressbar
  • Tiết kiệm thời gian: Dự án gấp, không cần tùy biến sâu.
  • Yêu cầu UI chuẩn: Material Components.
  • Cần tính năng đặc thù: Gradient đa màu, thumb, icon. Thư viện thường đã triển khai sẵn.

Lưu ý: Kiểm tra kích thước AAR, độ tương thích SDK và có cập nhật thường xuyên không.

5. Animation và Interpolator cho Circle ProgressBar

Một ProgressBar “cứng” không thu hút người dùng. Thêm animation sẽ làm UI mượt mà và chuyên nghiệp.

5.1. Sử dụng ObjectAnimator

fun animateProgress(toProgress: Int, duration: Long = 800) { val animator = ObjectAnimator.ofInt(this, "progress", progress, toProgress) animator.duration = duration animator.interpolator = DecelerateInterpolator() animator.addUpdateListener { setProgress(it.animatedValue as Int, animated = false) // avoid double animation } animator.start()
}

Ghi chú: Phải có setter setProgress(int) để ObjectAnimator có thể truy cập.

5.2. Interpolators phổ biến

Interpolator Hiệu ứng
LinearInterpolator Tốc độ cố định
AccelerateInterpolator Tăng dần tốc độ
DecelerateInterpolator Giảm dần tốc độ
AccelerateDecelerateInterpolator Tăng rồi giảm (mặc định)
BounceInterpolator Nhảy lên khi kết thúc
OvershootInterpolator Đi qua giá trị mục tiêu rồi quay lại

Bạn có thể thử AnticipateOvershootInterpolator để tạo cảm giác “đánh bật”.

5.3. Animation vòng tròn vô hạn (Indeterminate)

Nếu bạn muốn hiển thị trạng thái đang tải mà không biết phần trăm, có thể tạo animation vòng xoay vô hạn:

val rotateAnim = ObjectAnimator.ofFloat(this, "rotation", 0f, 360f)
rotateAnim.duration = 1500
rotateAnim.repeatCount = ValueAnimator.INFINITE
rotateAnim.interpolator = LinearInterpolator()
rotateAnim.start()

Hoặc sử dụng ProgressBar style ?android:attr/progressBarStyleLarge với indeterminate="true".

6. Tùy Chỉnh Màu Sắc, Gradient, Shadow

6.1. Gradient cho progress arc

Sử dụng Shader trên Paint.

private fun initGradient() { val sweepGradient = SweepGradient( width / 2f, height / 2f, intArrayOf(Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.MAGENTA, Color.RED), null ) // Đặt góc bắt đầu -90 độ val matrix = Matrix() matrix.postRotate(startAngle, width / 2f, height / 2f) sweepGradient.setLocalMatrix(matrix) progressPaint.shader = sweepGradient
}

Gọi initGradient() trong onSizeChanged để có kích thước đúng.

6.2. Shadow (đổ bóng)

init { setLayerType(LAYER_TYPE_SOFTWARE, null) // bật phần mềm rendering cho shadow backgroundPaint.setShadowLayer(8f, 0f, 2f, Color.GRAY) progressPaint.setShadowLayer(8f, 0f, 2f, Color.DKGRAY)
}

6.3. Thêm Thumb (đầu mũi tên)

private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = Color.WHITE setShadowLayer(4f, 0f, 2f, Color.BLACK)
}
private val thumbRadius = 12f private fun drawThumb(canvas: Canvas) { val angle = Math.toRadians((startAngle + sweepAngle).toDouble()) val cx = (width / 2 + (rectF.width() / 2) Math.cos(angle)).toFloat() val cy = (height / 2 + (rectF.height() / 2) Math.sin(angle)).toFloat() canvas.drawCircle(cx, cy, thumbRadius, thumbPaint)
}

Gọi drawThumb(canvas) sau khi vẽ progressArc.

7. Kết Nối Với LiveData / ViewModel

Trong kiến trúc MVVM, UI nên phản ứng với dữ liệu thay vì tự thay đổi. Dưới đây là cách tích hợp CircleProgressBar với LiveData.

7.1. ViewModel

Cách Vẽ Circle Progressbar
Cách Vẽ Circle Progressbar
class DownloadViewModel : ViewModel() { private val _progress = MutableLiveData<Int>(0) val progress: LiveData<Int> = _progress fun startDownload() { viewModelScope.launch { for (i in 1..100) { delay(50) _progress.postValue(i) } } }
}

7.2. Activity / Fragment

class MainFragment : Fragment(R.layout.fragment_main) { private val viewModel: DownloadViewModel by viewModels() private lateinit var circleProgressBar: CircleProgressBar override fun onViewCreated(view: View, savedInstanceState: Bundle?) { circleProgressBar = view.findViewById(R.id.customCircle) viewModel.progress.observe(viewLifecycleOwner) { value -> circleProgressBar.setProgress(value) } view.findViewById<Button>(R.id.btnStart).setOnClickListener { viewModel.startDownload() } }
}

Nhờ LiveData, khi tiến độ thay đổi, UI sẽ tự động cập nhật mà không cần gọi setProgress thủ công.

7.3. Data Binding (tùy chọn)

Nếu bạn dùng Data Binding, có thể tạo BindingAdapter:

@BindingAdapter("app:progress")
fun setCircleProgress(view: CircleProgressBar, progress: Int) { view.setProgress(progress, animated = true)
}

Trong XML:

<com.example.customview.CircleProgressBar android:id="@+id/customCircle" android:layout_width="200dp" android:layout_height="200dp" app:progress="@{viewModel.progress}" />

8. Kiểm Thử và Tối Ưu Hiệu Suất

8.1. Kiểm thử UI (Espresso)

@RunWith(AndroidJUnit4::class)
class CircleProgressBarTest { @Rule @JvmField val activityRule = ActivityScenarioRule(MainActivity::class.java) @Test fun progress_updates_correctly() { onView(withId(R.id.btnStart)).perform(click()) // Đợi một thời gian ngắn Thread.sleep(1200) onView(withId(R.id.customCircle)).check(matches(isDisplayed())) // Kiểm tra giá trị progress đã thay đổi (cần expose getter hoặc sử dụng IdlingResource) }
}

8.2. Profiler

  • GPU Overdraw: Đảm bảo không vẽ lại nền không cần thiết. Sử dụng canvas.save() và canvas.restore() hợp lý.
  • Memory: Tránh tạo Shader mới trong onDraw. Tạo một lần trong onSizeChanged và tái sử dụng.
  • Hardware Acceleration: Khi dùng shadow, cần setLayerType(LAYER_TYPE_SOFTWARE) – có thể làm giảm hiệu năng. Nếu cần hiệu năng cao, cân nhắc dùng blurMaskFilter hoặc tránh shadow.

8.3. Đánh giá Frame Rate

Sử dụng Profile GPU Rendering trong Developer Options. Một vòng tròn đơn giản dưới 16ms (60fps) là tiêu chuẩn.

9. Kết Luận và Một Số Lưu Ý Quan Trọng

9.1. Tổng kết

  • XML + ProgressDrawable: Thích hợp cho UI đơn giản, nhanh chóng.
  • Custom View + Canvas: Cung cấp tối đa khả năng tùy biến (gradient, shadow, thumb, text, animation). Yêu cầu hiểu rõ onDraw, Paint, Canvas.
  • Thư viện bên thứ ba: Giải pháp cân bằng giữa tốc độ triển khai và tính năng. Chọn thư viện dựa trên độ ổn định, kích thước và tính năng cần thiết.
  • Animation: Sử dụng ObjectAnimator, ValueAnimator hoặc AnimatorSet. Chọn interpolator phù hợp với trải nghiệm người dùng.
  • Kiến trúc MVVM: Kết hợp LiveData/StateFlow để quản lý trạng thái progress một cách sạch sẽ.
  • Hiệu suất: Tối ưu tạo shader, tránh tạo đối tượng trong onDraw, sử dụng hardware acceleration khi có thể.

9.2. Các lỗi thường gặp và cách khắc phục

Vấn đề Nguyên nhân Giải pháp
Vòng tròn bị lệch, không trung tâm Không tính padding đúng khi thiết lập RectF Đảm bảo rectF.set(padding, padding, w - padding, h - padding)
Gradient không khớp góc bắt đầu SweepGradient mặc định bắt đầu từ 3h (right) Dùng Matrix để xoay gradient theo startAngle
Shadow không hiển thị trên API 28+ setLayerType(LAYER_TYPE_SOFTWARE) bị tắt Kiểm tra if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { setLayerType(LAYER_TYPE_SOFTWARE, null) }
Animation giật khi cập nhật nhanh Gọi invalidate() quá thường xuyên Dùng Choreographer hoặc postOnAnimation để đồng bộ với frame
Text không nằm chính giữa Không tính toán ascender/descender val y = height/2f - (textPaint.descent() + textPaint.ascent())/2

9.3. Hướng phát triển tiếp theo

  1. Responsive Size: Tính toán strokeWidth và textSize dựa trên kích thước view để hỗ trợ đa màn hình.
  2. Touch Interaction: Cho phép người dùng kéo thumb để thay đổi giá trị (cần xử lý onTouchEvent).
  3. Multi‑segment Progress: Hiển thị nhiều phần tiến độ (ví dụ: CPU, RAM, Disk) trong cùng một vòng tròn.
  4. SVG/Vector Integration: Dùng VectorDrawable cho thumb hoặc biểu tượng trung tâm để giảm kích thước tài nguyên.
  5. Compose: Nếu dự án chuyển sang Jetpack Compose, có thể tái hiện cùng logic bằng Canvas composable.

Tài Nguyên Tham Khảo

  • Android Developers – Canvas: https://developer.android.com/reference/android/graphics/Canvas
  • Material Components – CircularProgressIndicator: https://material.io/components/progress-indicators/android
  • GitHub – lopspower/CircleProgressBar: https://github.com/lopspower/CircleProgressBar
  • Kotlin Coroutines – viewModelScope: https://kotlinlang.org/docs/coroutines-android.html

Hy vọng qua bài viết chi tiết này, bạn đã nắm vững cách vẽ Circle ProgressBar trong Android, từ các giải pháp nhanh gọn cho tới việc tự xây dựng một Custom View mạnh mẽ, đồng thời biết cách tích hợp animation, gradient, shadow và kiến trúc MVVM. Chúc bạn thành công trong việc tạo ra những UI đẹp mắt và mượt mà cho ứng dụng của mình!

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 *