Custom Progress Bar with Jetpack Compose Canvas API: Tutorial

In this tutorial, you'll learn how to create a custom progress bar with Jetpack Compose Canvas API. The end result looks like the image below.

Steps for Creating Custom Progress Bar with Jetpack Compose Canvas API :

  • Declaring a canvas
  • Understanding the drawArc Composable API
  • Drawing an arc for the background
  • Drawing an arc for the foreground
  • Drawing a circle
  • Positioning the circle on the arc

Assumptions

  • The radius of the circle is Half of the Height of the Widget.
  • The color for the background arc will be #90A4AE
  • The color for the foreground arc will be #4DB6AC
  • The color for the circle on the custom progress bar will be #FFFFFF

Declaring our Canvas

Let's start by creating our composable. You will call it CustomProgressBar. Go ahead and add this code to your project.

@Composable
fun CustomProgressBar() {

}

Jetpack Compose provides us with a Composable called Canvas. It takes a lambda called DrawScope.

πŸ“Œ
DrawScopes implementations receive sizing information and the transformations are done relative to the local translation.

Here is how you would declare it.

@Composable
fun CustomProgressBar() {
	Canvas() {
    
    	}
}

It is crucial to provide two things to your Canvas at this point. The first is the size and the second is the padding. Go ahead and set the size to 150dp and add 10dp of padding.

πŸ‘‰
Drawing content inside a DrawScope is not clipped, so it is possible to draw outside of the specified bounds. Always keep this in mind, when deciding on the size of your Canvas.
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
    
    	}
}

Understanding the drawArc Composable API

Jetpack Compose provides us with a composable function called drawArc(). This function requires 4 parameters to be declared:

  1. brush: Brush Color or fill to be applied to the arc
  2. startAngle: Float Starting angle in degrees. 0 represents 3 o'clock
  3. sweepAngle: Float Size of the arc in degrees that is drawn clockwise relative to startAngle
  4. useCenter: Boolean Flag indicating if the arc is to close the center of the bound

There are other parameters that offer you more customization, but they are not required, so I will skip over them for now.

Drawing an Arc For The Background

Go ahead and add the first arc like this

@Preview()
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
    	}
}

As the startAngle's 0 represents 3'o clock, I want to start drawing somewhere close to 7'o clock. That is the reason we use 140 for the startAngle.

πŸ€”
Try to change the start angle to 90 and see if it starts at 6'o clock. This is really nice to understand how this works.

Drawing an Arc For The Foreground

Keeping everything the same, you will just add another arc, with a different color on top of this one.

@Preview()
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
            
            // Foreground Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#4DB6AC")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
    	}
}

Here is what the preview should look like

Drawing a Circle

A circle in its simplest form is also an arc, whose sweep angle is 360. So you can use the same drawArc API to draw a circle. However, Jetpack Compose provides us with another convenient API to draw a circle in an intuitive manner. The method is called drawCircle . It requires only one parameter to draw and that is the color.

Go ahead and add it to your canvas like this

@Preview()
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
            
            // Foreground Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#4DB6AC")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)
            
            // Progress dot
              drawCircle(
                  color = Color.Red
              )
    	}
}

As you can see it added a circle at the center of your canvas and the radius is half the size of your canvas. So now you have to fix both of these things.

First, go ahead and set the radius to 5f.

😎
Setting it to 5f because the stroke width for your arc above is set to 10f, it will make your circle fits inside the stroke of your arc perfectly.
@Preview()
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
            
            // Foreground Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#4DB6AC")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)
            
            // Progress dot
              drawCircle(
                  color = Color.Red,
                  radius = 5f
              )
    	}
}

Great job resizing the circle! πŸ‘

Now we need to position the circle onto the arc so that it matches your desired design. To achieve this, you have to provide the coordinates of the center of the circle as an Offset object. Go ahead and set this as (0,0) and see what happens.

@Preview()
@Composable
fun CustomProgressBar() {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
            
            // Foreground Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#4DB6AC")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)
            
            // Progress dot
              drawCircle(
                  color = Color.Red,
                  radius = 5f,
                  center = Offset(0f, 0f)
              )
    	}
}

You did not expect that to happen, did you? Do you know why this happened?

πŸ€”
(0,0) on a canvas represents the top, left point of the canvas. The right, bottom is represented by the width & height of the canvas, respectively.

If you are paying attention, you'd be thinking but why does the circle not appear on the extreme top, left of your canvas? This is because you added 10 dp, padding to your canvas. Try removing the padding modifier and see what happens.

Android Studio can even show this is to you, just hover over your composable's design preview and it will show you a bounding box like this

In the next session, you'll see the code to position the circle correctly on the arc.

Positioning the Circle on The Arc

Here is how you can position the circle on the arc.

@Preview()
@Composable
- fun CustomProgressBar() {
+	fun CustomProgressBar(
+		progressPercentage: Float = 1.0f
+	) {
	Canvas(
    	modifider = Modifier
        .size(150.dp)
        .padding(10.dp)
    	) {
        	// Background Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#90A4AE")),
                140f,
                260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)    
            
            // Foreground Arc
            drawArc(
                color = Color(android.graphics.Color.parseColor("#4DB6AC")),
                140f,
-                260f,
+				progressPercentage * 260f,
                false,
                style = Stroke(10.dp.toPx(), cap = StrokeCap.Round),
                size = Size(size.width, size.height)
        	)
+         var angleInDegrees = (progressPercentage * 260.0) + 50.0
+         var radius = (size.height / 2)
+         var x = -(radius * sin(Math.toRadians(angleInDegrees))).toFloat() + (size.width / 2)
+         var y = (radius * cos(Math.toRadians(angleInDegrees))).toFloat() + (size.height / 2)
           
            
            // Progress dot
              drawCircle(
-                  color = Color.Red,
+                  color = Color.White,
                  radius = 5f,
                  center = Offset(x, y)
              )
    	}
}
πŸ€—
Since I don't expect everyone reading this article to have studied Mathematics in the past, I've kept the in-depth overview of Circle Equations out of this article. 

If you'd like to understand in-depth, how I came up with the x and y coordinates, you can read this fun blog post on How to Find a Point on a Circle in Kotlin.

The result should look like this

Conclusion

Kudos on learning how to create a custom progress bar with Jetpack Compose Canvas API. I hope you enjoyed this article.

Before you go, let's connect with each other on Twitter!

Behind the Scenes 

πŸ’Œ A special section on my blog for ️️my subscribers❀️.
Here I share how did I come up with the idea for this article.
It could be inspirational, motivational, or quirky but definitely not traditional
because it was born out of my hustle.

Ishan Khanna

Ishan Khanna

Polyglot Mobile Engineer who loves solving problems and monetizing them. Talk Personal Finance, Investing, New Product Ideas and Hustle with me! Twitter - @droidchef
Los Angeles, CA