Creating a segmented progress bar in Jetpack Compose

Introduction

In this article we’ll learn how to create a custom progress indicator in Jetpack Compose using the Canvas APIs. What we want to achieve is the following:

This is basically a variation on the linear progress indicator. We want the user of this composable to be able to specify:

  • current progress, as a float in the 0f to 1f range.
  • the number of segments in the progress indicator.
  • the gap between the segments.
  • the color for the background bars.
  • the colors for the foreground bars.
  • the height of the bars.

Let’s get started.

The progress indicator signature

First we need to define our progress signature. The only parameter that is required is the progress, the others will have defaults so they will be optional. As is customary on Jetpack Compose, we will also accept a Modifier to allow callers to customize the look of the progress indicator. Per the Jetpack Compose guidelines, the Modifier will be the first optional argument in our method.

With that in mind, we can define our progress composable signature as shown here:

private const val BackgroundOpacity = 0.25f
private const val NumberOfSegments = 8
private val ProgressHeight = 4.dp
private val SegmentGap = 8.dp

@Composable
fun SegmentedProgressIndicator(
/*@FloatRange(from = 0.0, to = 1.0)*/
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
backgroundColor: Color = color.copy(alpha = BackgroundOpacity),
progressHeight: Dp = ProgressHeight,
numberOfSegments: Int = NumberOfSegments,
segmentGap: Dp = SegmentGap
) {
check(progress in 0f..1f) { "Invalid progress $progress" }
check(numberOfSegments > 0) { "Number of segments must be greater than 0" }
}

As we can see, only the progress value is mandatory, the others are optional and we provide some sane defaults. Adhering to material guidelines, our progress will, by default, use the primary color for the current progress, and will use a modified version of this color with an alpha for the background.

We also add a couple of sanity checks to validate the arguments we receive and ensure they are within bounds.

Drawing the background

Next we are going to draw the background for the progress indicator. When we have something like this

there are 2 ways to go about it:

  • we can draw the progress part (solid purple) and then for the rest of the width we draw the background.
  • we can draw the background (purple with alpha) for the whole progress indicator width, then overlay the progress (solid purple) on top of it.

The second approach is preferable because we can then reuse the same draw logic for both the background and the progress, and there is no actual penalty for drawing the background where it will overridden by the progress part.

The first thing we need to do to draw the progress is convert our dimensions from Dp to pixels, as all draw functions operate on pixels. To do that we can use LocalDensity.current as shown here:

val gap: Float
val barHeight: Float
with(LocalDensity.current) {
gap = segmentGap.toPx()
barHeight = progressHeight.toPx()
}

This gets us the gap between segments and the height of the progress bar in pixels.

Next we are going to draw the background using the Canvas composable. Let’s have a look at the code:

Canvas(
modifier
.progressSemantics(progress)
.height(progressHeight)
) {
drawSegments(backgroundColor, barHeight, numberOfSegments, gap)
}

We can see here that:

  • our root composable is Canvas and it takes the modifier we receive as parameter.
  • we chain to this Modifier the progressSemantics modifier (this will be used for accessibility purposes) and we set the height our our composable to the height in pixels we received as argument.
  • the Canvas composable takes a lambda with receiver with the receiver being DrawScope and within this lambda we draw our background (we will see how below).

Now let’s have a look at how we can draw the background:

private fun DrawScope.drawSegments(
color: Color,
segmentHeight: Float,
segments: Int,
segmentGap: Float,
) {
// 1
val width = size.width
val start = 0f
val end = width
val gaps = (segments - 1) * segmentGap
val segmentWidth = (width - gaps) / segments

// 2
repeat(segments) { index ->
// 3
val offset = index * (segmentWidth + segmentGap)
val segmentStart = start + offset
// 4
val segmentEnd = (offset + segmentWidth).coerceAtMost(end)
// 5
drawRect(
color,
Offset(segmentStart, 0f),
Size(segmentEnd - segmentStart, segmentHeight)
)
}
}

Let’s have a look in detail at how we draw the background:

  1. we compute the start and end for the background segments, which is just the start and end of our composable as the background stretches from side to side. We also compute how wide each segment will be based on the available width, the number of segments to draw, and the gap between segments.
  2. once we have our sizes ready, we iterate over the number of segments.
  3. for each segment, we calculate the start position based on its index and the size of the previous segments, including the gap. So for the first segment we start at 0, the 2nd starts at segment size + gap, the 3rd at 2 * (segment size + gap) and so on.
  4. we compute where to end the current segment, basically where it started plus the segment width. We coerce this value to ensure it does not exceed our bounds.
  5. finally we draw the segment. Segments are just rectangles, so we use the drawRect method on DrawScope which takes a color, the one we receive as argument, the top left coordinate, and the size of the rectangle, which we calculate by subtracting the start position from the end position.

With this, we get this result:

Generalizing the draw function

This function we implemented draws always from the start to the end. In order to reuse it for the progress part, we want to make it more generic so that we can specify the progress and it will draw only a percentage of the segments, based on that progress. Let’s update the function to take a progress parameter:

private fun DrawScope.drawSegments(
// 1
progress: Float,
color: Color,
strokeWidth: Float,
segments: Int,
segmentGap: Float,
) {
val width = size.width
val start = 0f
// 2
val end = width * progress
val gaps = (segments - 1) * segmentGap
val segmentWidth = (width - gaps) / segments

repeat(segments) { index ->
val offset = index * (segmentWidth + segmentGap)
// 3
if (offset < end) {
val segmentEnd = (offset + segmentWidth).coerceAtMost(end)
drawLine(
color,
Offset(start + offset, 0f),
Offset(segmentEnd, 0f),
strokeWidth
)
}
}
}

The changes are fairly small:

  1. we pass the current progress in.
  2. where to stop drawing (the end coordinate) is now computed based on the progress.
  3. we add a check to see if this segment starts before where the progress ends — if it doesn’t, then there is nothing for us to draw.

For the background, we now have to call the function like this

drawSegments(1f, backgroundColor, barHeight, numberOfSegments, gap)

so that it draws from edge to end, and the result is the same as before.

With this small change, we can now reuse this function and draw the background, using the current progress and the progress color, it’s as simple as adding this line to our composable:

drawSegments(progress, color, barHeight, numberOfSegments, gap)

and the whole function is shown below:

@Composable
fun SegmentedProgressIndicator(
/*@FloatRange(from = 0.0, to = 1.0)*/
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
backgroundColor: Color = color.copy(alpha = BackgroundOpacity),
progressHeight: Dp = ProgressHeight,
numberOfSegments: Int = NumberOfSegments,
segmentGap: Dp = SegmentGap
) {
check(progress in 0f..1f) { "Invalid progress $progress"}
check(numberOfSegments > 0) { "Number of segments must be greater than 1" }

val gap: Float
val barHeight: Float
with(LocalDensity.current) {
gap = segmentGap.toPx()
barHeight = progressHeight.toPx()
}
Canvas(
modifier
.progressSemantics(progress)
.height(progressHeight)
) {
drawSegments(1f, backgroundColor, barHeight, numberOfSegments, gap)
drawSegments(progress, color, barHeight, numberOfSegments, gap)
}
}

with this, we achieve this result:

This looks pretty good, but if you look closely, you’ll notice that there is a pause when the progress is in between segments. Let’s fix that.

Smoothing out the animation

The problem we see in the previous animation is caused by the overall width of the segments being less than the width of the progress indicator. When the progress falls in between the segments the start coordinate for the next fragment is less than the end coordinate, so we do not draw the next segment. As long as the progress remains in that range, the next segment is not drawn so we have to wait for the progress to advance so that the next segment is now within bounds. To fix this we need to calculate the progress based on the actual width of the segments (excluding the gaps), then when we draw the segments we add back the gaps.

Let’s see the code:

private fun DrawScope.drawSegments(
progress: Float,
color: Color,
segmentHeight: Float,
segments: Int,
segmentGap: Float,
) {
val width = size.width
val start = 0f
val gaps = (segments - 1) * segmentGap
val segmentWidth = (width - gaps) / segments
val barsWidth = segmentWidth * segments
// 1
val end = barsWidth * progress + (progress * segments).toInt() * segmentGap

repeat(segments) { index ->
val offset = index * (segmentWidth + segmentGap)
if (offset < end) {
val segmentEnd = (offset + segmentWidth).coerceAtMost(end)
val segmentStart = start + offset
drawRect(
color,
Offset(segmentStart, 0f),
Size(segmentEnd - segmentStart, segmentHeight)
)
}
}
}

There is only 1 change we need to do:

  1. where to end the last solid segment is calculated based on the width of the segments without the gap, then we calculate how many segments precede that last solid segment, and we add the gaps for all those segments.

With this, we achieve our desired result:

And we can use this like this:

var runForwards by remember { mutableStateOf(false) }
val progress: Float by animateFloatAsState(
if (runForwards) 1f else 0f,
animationSpec = tween(
durationMillis = 10_000,
easing = LinearEasing
)
)
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
SegmentedProgressIndicator(
progress = progress,
modifier = Modifier
.padding(top = 64.dp, start = 32.dp, end = 32.dp)
.fillMaxWidth(),
)

Button(
onClick = { runForwards = !runForwards },
modifier = Modifier.padding(top = 32.dp)
) {
Text(
text = if (runForwards) "Reverse Animation" else "Forward Animation"
)
}
}
}

Adding support for RTL

Our current solution always draws starting on the left side of the screen and progresses towards the right. It would be nice if our composable used the system setting for RTL and drew starting on the right side for devices configured as RTL.

To determine if the device is configured LTR or RTL we can use the layoutDirection from within the Canvas lambda, the receiver DrawScope exposes this property, so we can read it in our draw function.

To draw in RTL we have to change how we calculate the solid rectangles for the progress portion, instead of starting at the 0 x coordinate and increasing as the progress increases, we will have to start at the width coordinate and decrease as progress increases.

This is how we need to modify our code to follow the system’s RTL setting:

private fun DrawScope.drawSegments(
progress: Float,
color: Color,
segmentHeight: Float,
segments: Int,
segmentGap: Float,
) {
val width = size.width
val gaps = (segments - 1) * segmentGap
val segmentWidth = (width - gaps) / segments
val barsWidth = segmentWidth * segments
val start: Float
val end: Float

// 1
val isLtr = layoutDirection == LayoutDirection.Ltr
// 2
if (isLtr) {
start = 0f
end = barsWidth * progress + (progress * segments).toInt() * segmentGap
} else {
start = width
end = (width - (barsWidth * progress + (progress * (segments - 1)).toInt() * segmentGap))
}

repeat(segments) { index ->
val offset = index * (segmentWidth + segmentGap)
val segmentStart: Float
val segmentEnd: Float
// 3
if (isLtr) {
segmentStart = start + offset
segmentEnd = (segmentStart + segmentWidth).coerceAtMost(end)
} else {
segmentEnd = width - offset
segmentStart = (segmentEnd - segmentWidth).coerceAtLeast(end)
}
// 4
if (isLtr && offset <= end || !isLtr && segmentEnd > end) {
drawRect(
color,
Offset(segmentStart, 0f),
Size(segmentEnd - segmentStart, segmentHeight)
)
}
}
}

And the changes are:

  1. we get the current LTR setting from the DrawScope by checking the property layoutDirection against LayoutDirection.Ltr.
  2. if the layout is left to right, the start and end are the same as before. Otherwise, the start is our width (so we start at the right edge), and the end moves left from that point based on the current progress.
  3. when we draw our segments, the start and end of the segment are the same as before for LTR, but for RTL the start (leftmost edge of the segment) is calculated from the right edge (width) based on the current offset, coerced to the end we calculated for the current progress. The end for the segment (rightmost edge) is just offsetting the width. You can see that the start/end coordinates for LTR and RTL are mirror images of one another, the start for LTR is calculated similar to the end for RTL, and the end for LTR is calculated similar to the start for RTL.
  4. to determine if we need to continue drawing we have to check the direction and stop, for LTR, when we would be drawing to the left (lower value) of our end coordinate.

Now, if we run our app on a device configured as RTL we get our desired outcome:

And with this we are done. The final code is below:

Senior Android Developer