Intermediate Android Compose – Custom Circular Progress Bar

Ken Ruiz Inoue
Deuk
Published in
11 min readDec 26, 2023
CustomCircularProgressBar

Introduction

Hi there! Welcome back to our Android Compose tutorial series. In this session, we’re diving into creating a custom circular progress UI, moving beyond the standard material design options. We will support two different modes for the Progress UX.

For those who prefer a more fundamental approach, I recommend checking out my previous tutorials:

Are you eager to dive into the code? It’s all yours! Please don’t forget to star the repository 🙏.

Now, it’s time to get going; let’s get started!

Environment

  • Android Studio Hedgehog | 2023.1.1
  • Compose version: androidx.compose:compose-bom:2023.08.00
  • Mac OS Sonoma 14.1
  • Pixel 5 Emulator

On Today’s Menu

  1. Learn how to use the Canvas() composable for drawing the shapes needed in our CustomCircularProgressBar().
  2. Explore a helper Flow function to simulate progress.
  3. Learn to add animations to your progress bar. We’ll cover how to animate changes in progress values, providing a more dynamic and engaging user experience.

Step 1: Drawing the Background Arc

First, we’ll lay the foundation of the UI by drawing the background arc, which signifies the full scope of our progress circle. Start by creating a new Kotlin file named CustomCircularProgressBar.kt. This file will house our composable function for rendering the custom progress bar. Add the necessary imports at the top of the file to ensure you have all the tools needed for each step.

// your package...

// Needed imports for all the steps
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/**
* Composable function to draw a custom circular progress UI.
*
* @param size The diameter of the circular progress UI.
* @param strokeWidth The thickness of the progress stroke.
* @param backgroundArcColor The color of the background arc that shows the full extent of the progress circle.
*/
@Composable
fun CustomerCircularProgressBar(
size: Dp = 96.dp,
strokeWidth: Dp = 12.dp,
backgroundArcColor: Color = Color.LightGray
) {
Canvas(modifier = Modifier.size(size)) {
// Background Arc Implementation
drawArc(
color = backgroundArcColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
size = Size(size.toPx(), size.toPx()),
style = Stroke(width = strokeWidth.toPx())
)
}
}

@Preview
@Composable
fun CustomerCircularProgressBarPreview() {
CustomerCircularProgressBar()
}

Background Arc Implementation

  • The drawArc() function is our primary tool, enabling us to render an arc – a circle segment. We specify a sweepAngle of 360 degrees to create a complete circle.
  • The .toPx() method is crucial for converting dimensions from dp (density-independent pixels) to px (pixels). This is vital as drawers operate at a pixel level, ensuring our drawing is accurately scaled to the device's screen.

After adding the code, run CustomerCircularProgressBarPreview() to visualize the result. The circle appears cut off. This is due to how drawArc() handles strokes: the stroke's width is centered on the arc's boundary, causing parts to extend beyond the defined area. Additionally, the size calculation does not account for the stroke width, potentially resulting in a circle larger than intended.

To address this, we must adjust our size calculations to accommodate the stroke width, ensuring our circle fits perfectly within the canvas.

CustomCircularProgressBar Implementation Step 1

Step 2: Fixing the Arc-Stroke Issue

To fix the issue we faced in the previous step, let’s update the CustomerCircularProgressBar() with these changes:

...

@Composable
fun CustomerCircularProgressBar(
...
) {
Canvas(
...
) {
val strokeWidthPx = strokeWidth.toPx()
// Correct Arc Size
val arcSize = size.toPx() - strokeWidthPx
// Background Arc Implementation
drawArc(
...
// Offset Half Stroke Width
topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2),
size = Size(arcSize, arcSize),
style = Stroke(width = strokeWidthPx)
)
}
}

...

Correct Arc Size

When drawing an arc with a stroke, the arc’s actual visible diameter is slightly smaller than the specified size due to the stroke width. This is because the stroke is centered on the arc’s path. We reduce the arc’s size by the stroke width to correct this. This ensures the entire stroke fits within the canvas, avoiding clipping at the edges. The arcSize is calculated by subtracting the stroke width from thesize.

Offset Half Stroke Width

To prevent the stroke from being clipped at the canvas edges, we need to draw the arc slightly inward. By offsetting the arc by half the stroke width on both the top and left using topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2), we ensure the entire stroke is visible within the canvas. This offset is crucial to maintain the arc's visual integrity and ensure it’s fully contained within the designated area.

Now you should eliminate the issue and see a proper background circle.

CustomCircularProgressBar Implementation Step 2

Step 3: Drawing the Progress Arc

In this phase, we will enhance the CustomerCircularProgressBar() with new parameters and drawing logic to visually indicate progress using an arc. This enhancement will dynamically allow the progress bar to represent various states of completion. Update the CustomerCircularProgressBar() by incorporating the following changes:

...

/**
* Composable function to draw a custom circular progress UI.
*
* @param progress The current progress value, where 0f represents no progress and 1f represents complete progress.
* @param startAngle The starting angle for the progress arc, in degrees. 0 degrees is at the 3 o'clock position.
...
* @param progressArcColor1 The starting color of the progress arc's gradient.
* @param progressArcColor2 The ending color of the progress arc's gradient. If not specified, it defaults to the same color as progressArcColor1, resulting in a solid color.
*/
@Composable
fun CustomerCircularProgressBar(
progress: Float = 0f,
startAngle: Float = 270f,
...
backgroundArcColor: Color = Color.LightGray,
progressArcColor1: Color = Color.Blue,
progressArcColor2: Color = progressArcColor1
) {
Canvas(modifier = Modifier.size(size)) {
...
// Gradient Brush
val gradientBrush = Brush.verticalGradient(
colors = listOf(progressArcColor1, progressArcColor2, progressArcColor1)
)
// Progress Arc Implementation
withTransform({
rotate(degrees = startAngle, pivot = center)
}) {
drawArc(
brush = gradientBrush,
startAngle = 0f,
sweepAngle = progress * 360,
useCenter = false,
topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2),
size = Size(arcSize, arcSize),
style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
)
}
}
}

@Preview
@Composable
fun CustomerCircularProgressBarPreview() {
CustomerCircularProgressBar(
progress = 0.85f,
progressArcColor1 = Color(0xFF673AB7),
progressArcColor2 = Color(0xFF4CAF50),
)
}

Gradient Brush

  • Gradients are a powerful visual tool in UI design, allowing smooth color transitions. They can add depth, dimension, and a modern aesthetic to various components, including progress bars. In Android Compose, gradients are created using the Brush class, which offers different methods to achieve various gradient effects. We use a vertical gradient (Brush.verticalGradient()) to draw the progress arc. However, Compose provides five distinct gradient methods, each offering a unique visual effect. Feel free to experiment with these different gradients to find the one that best suits your design needs.
  • In the implementation, we use a color list pattern: progressArcColor1, progressArcColor2, progressArcColor1. This specific ordering enables a smooth color transition. We create a seamless, cyclic transition by starting and ending with the same color (progressArcColor1). This is particularly effective for continuous or looping progress bars, ensuring a smooth flow without abrupt color changes.

Progress Arc Implementation

  • The withTransform block is essential for modifying the canvas orientation before we begin drawing the progress arc. It ensures that the arc begins from the specified startAngle. Without this transformation, the arc would default to starting at 0 degrees, the equivalent of the 3 o'clock position on a clock. 🕒
  • Inside withTransform, the rotate function requires two parameters: degrees and pivot. The degrees parameter here is set to startAngle, ensuring the canvas rotates to this angle. The pivot is the center of the canvas, serving as the rotation's focal point. This rotation is crucial for aligning the start of the progress arc correctly and ensuring the gradient aligns seamlessly along the arc's path.
  • The expression sweepAngle = progress * 360 calculates the extent of the arc to be drawn, representing the progress. Here, progress (a value between 0 and 1) is multiplied by 360 to convert it into degrees. This calculation determines the length of the arc that will visually represent the progress, with 1f (or 100%) corresponding to a full 360-degree sweep.

At this point, you should observe the vertical gradient effectively displayed on the progress arc.

CustomCircularProgressBar Implementation Step 3

Step 4: Animation Support

Now, we’ll enhance the functionality with animation capabilities. Begin by creating a new Kotlin file, FlowUtils.kt, and include the following code:

// Your package

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.delay

/**
* A function that creates a Flow emitting Float values, simulating a progress animation.
*
* @param targetProgress The final progress value to reach, default is 1f.
* @param step The increment for each emitted progress value, default is 0.01f.
* @param delayTime The delay between emissions in milliseconds, default is 1L.
* @return A Flow emitting Float values representing the progress.
*/
fun progressFlow(targetProgress: Float = 1f, step: Float = 0.01f, delayTime: Long = 1L): Flow<Float> {
return flow {
var progress = 0f
while (progress <= targetProgress) {
emit(progress)
progress += step
delay(delayTime)
}
}
}

progressFlow()

This function utilizes Kotlin’s coroutine flow to emit a sequence of Float values, each representing a momentary progress state. Designed to simulate the progression of a task, this non-blocking approach allows for a dynamic and responsive UI experience. The flow gradually advances the progress value from 0 to the specified targetProgress, creating a smooth and visually appealing progression animation when used properly.

We will now integrate the newly created helper function into the CustomerCircularProgressBar(). This is the last update, I promise. Add these changes to CustomerCircularProgressBar() as below:

/**
* Composable function to draw a custom circular progress UI.
*
...
* @param animationOn Flag to control whether the progress change should be animated.
* @param animationDuration Duration of the progress animation in milliseconds. Only applicable if animationOn is true.
*/
@Composable
fun CustomerCircularProgressBar(
...
animationOn: Boolean = false,
animationDuration: Int = 1000
) {
// Progress Animation Implementation
val currentProgress = remember { mutableFloatStateOf(0f) }
val animatedProgress by animateFloatAsState(
targetValue = currentProgress.floatValue,
animationSpec = if (animationOn) tween(animationDuration) else tween(0),
label = "Progress Animation"
)
LaunchedEffect(animationOn, progress) {
if (animationOn) {
progressFlow(progress).collect { value ->
currentProgress.floatValue = value
}
} else {
currentProgress.floatValue = progress
}
}

...

// Progress Arc Implementation
withTransform({
...
}) {
drawArc(
...
sweepAngle = animatedProgress * 360, // Using animatedProgress
...
)
}
}

Progress Animation Implementation

  • val currentProgress holds the current progress value, initialized at 0f. The remember function preserves the state across recompositions, ensuring a consistent UI.
  • val animatedProgress by animateFloatAsState(...) animates the progress value. If animationOn is true, the progress transitions smoothly over the specified duration. Otherwise, it updates instantly, resulting in no animation.
  • LaunchedEffect(animationOn, progress) {...} triggers when animationOn or progress changes. It manages the animation flow, updating the currentProgress accordingly.
  • sweepAngle = animatedProgress * 360 calculates the animated sweep angle of the progress arc, offering a dynamic visual representation of the progress.

Finally, let’s incorporate the CustomerCircularProgressUi() in MainActivity to showcase two distinct usage scenarios of our custom progress bar.

// Your package...

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Remembering the Progress State
val progressFlow = remember { progressFlow(delayTime = 10L) }
val progressState = progressFlow.collectAsState(initial = 0f)
Column {
Box (
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CustomerCircularProgressBar(
progress = progressState.value,
progressArcColor1 = Color(0xFF673AB7),
progressArcColor2 = Color(0xFF4CAF50)
)
}
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CustomerCircularProgressBar(
progress = 0.75f,
startAngle = 180f,
size = 160.dp,
strokeWidth = 24.dp,
progressArcColor1 = Color(0xFFECBC13),
backgroundArcColor = Color(0xFF1343EC),
animationOn = true
)
}
}
}
}
}

Remembering the Progress State

  • The remember function in Jetpack Compose is utilized to preserve the progressFlow instance, maintaining consistency through UI updates. We ensure the progress animation continues seamlessly, without resetting or flickering, whenever the UI is redrawn.

Running the app will display two different implementations of the CustomerCircularProgressBar(), each exhibiting a distinct and visually engaging filling animation.

CustomCircularProgressBar Implementation Step 4

Enhancements

To further refine our CustomCircularProgressBar() composable, we can introduce several enhancements:

  1. Customizable Animation Speed: Allowing users to adjust the speed of the progress animation can provide more flexibility. This feature can be especially useful for cases requiring different visual feedback based on the task’s duration.
  2. Intermittent Animation Capability: An option for intermittent animation could enhance the user experience. This can be particularly effective for indicating ongoing processes where progress is not continuous but occurs in steps or intervals.
  3. Utilizing Modifier Over Fixed Size: Embracing the power of modifiers in Jetpack Compose, we can make our composable more versatile. The progress bar fluidly to different UI layouts and designs using a modifier instead of a fixed size.

Discover More

Are you eager to delve deeper into Android Compose? Explore these tutorials:

Conclusion

Thank you for following along with this tutorial. I hope you found the instructions clear and the journey enjoyable. Your feedback and questions are not just welcome, but essential, so please don’t hesitate to leave a comment. Your input is invaluable in making these tutorials more effective and user-friendly.

If you appreciated the content and found it helpful, please show your support by giving a clap to the article. Your claps keep me motivated and enable me to aim for even better content in future tutorials. Looking forward to our next session. Until then, happy coding!

Deuk Services: Your Gateway to Leading Android Innovation

Are you looking to boost your business with top-tier Android solutions?Partner with Deuk services and take your projects to unparalleled heights.

--

--