Open-sourcing this Jetpack Compose widget
If you’ve been following my stories, this is the sequel of an article I’ve recently published where I describe how you can draw a segmented progress bar. For those who haven’t, you can find it here.
Following this article, I started to wonder how it would fare if I’d write it in Compose. Needless to say, Compose is one of the trendiest tools to build declarative UI as I’m writing. It allows you to easily build your UI components programmatically and order them on your screen. Meaning we no longer need an XML to declare our UI structure.
But when it comes to custom drawing, we’re already programmatically building our view using a Canvas
. So what would be the point to do it with Compose? Does it bring any benefits?
Short answer: absolutely! Let me share with you my journey that eventually led to publishing this Compose widget into an open-source library.
Breath effect when reaching the last segment
“Kamehameha-like” animation
Drawing Custom Shapes in a Canvas
With Compose
The logic behind drawing on a Canvas
hasn’t changed with Compose. Compose exposes its Canvas
widget to let you draw within a composable function. It relies on the same methods whether you want to draw a line, a rectangle, an arc, or a path.
Canvas
accepts two parameters:
- a
Modifier
to define how theCanvas
will be displayed on the screen - an
onDraw
lambda where all the drawing magic happens.
@Composable | |
fun SegmentedProgressBar() { | |
Canvas( | |
modifier = Modifier | |
onDraw = { | |
// Draw the segments | |
} | |
) | |
} |
To draw our segments, we’ll:
- reuse the
SegmentCoordinatesComputer
I’ve described in the previous article. This class computes a segment’s coordinates given a range of parameters (spacing, angle, etc). - store the computer’s state with the
remember
composable. - loop over the
segmentCount
argument and retrieve each segment’s coordinates. - draw the segment at the given coordinates. You can change the segment’s color and opacity —
SegmentColor
represents a data class with these two attributes.
@Composable | |
fun SegmentedProgressBar( | |
segmentCount: Int, | |
modifier: Modifier = Modifier, | |
spacing: Dp = 0.dp, | |
angle: Float = 0f, | |
segmentColor: SegmentColor = SegmentColor(), | |
) { | |
val computer = remember { SegmentCoordinatesComputer() } | |
val spacingPx = LocalDensity.current.run { spacing.toPx() } | |
Canvas( | |
modifier = modifier.fillMaxWidth(), | |
onDraw = { | |
(0 until segmentCount).forEach { position -> | |
val segmentCoordinates = computer.segmentCoordinates( | |
position = position, | |
segmentCount = segmentCount, | |
width = size.width, | |
height = size.height, | |
spacing = spacingPx, | |
angle = angle | |
) | |
drawSegment( | |
coordinates = segmentCoordinates, | |
color = segmentColor | |
) | |
} | |
} | |
) | |
} |
I’ve created an extension function to draw the segment as we’ll need it to draw the progress segment.
private fun DrawScope.drawSegment(coordinates: SegmentCoordinates, color: SegmentColor) { | |
val path = Path().apply { | |
reset() | |
moveTo(coordinates.topLeftX, 0f) | |
lineTo(coordinates.topRightX, 0f) | |
lineTo(coordinates.bottomRightX, size.height) | |
lineTo(coordinates.bottomLeftX, size.height) | |
close() | |
} | |
drawPath( | |
path = path, | |
color = color.color, | |
alpha = color.alpha, | |
) | |
} |
Finally, drawing the progress bar results in retrieving its coordinates from the SegmentedCoordinatesComputer
and calling the drawSegment
extension method.
@Composable | |
fun SegmentedProgressBar( | |
segmentCount: Int, | |
modifier: Modifier = Modifier, | |
progress: Float = 0f, | |
spacing: Dp = 0.dp, | |
angle: Float = 0f, | |
segmentColor: SegmentColor = SegmentColor(), | |
progressColor: SegmentColor = SegmentColor(), | |
) { | |
// Init computer | |
Canvas( | |
modifier = modifier.fillMaxWidth(), | |
onDraw = { | |
// Draw segments | |
val progressCoordinates = computer.progressCoordinates( | |
progress = progress.coerceIn(0f, segmentCount.toFloat()), | |
segmentCount = segmentCount, | |
width = size.width, | |
height = size.height, | |
spacing = spacingPx, | |
angle = angle | |
) | |
drawSegment( | |
coordinates = progressCoordinates, | |
color = progressColor | |
) | |
} | |
) | |
} |
We’ve now rebuilt our segmented progress bar with Compose. But we’re still missing a key aspect of any progress bar.
Job Offers
Animation with Compose
So our widget doesn’t animate its progression. Pretty lame for a progress bar, right?
Not to worry though. Compose comes with several built-in APIs. I was baffled at how straightforward animations are with Compose!
In fact, you can bundle it in a single line of code! Since our progression consists of interpolating two integers, we can leverage the animateFloatAsState
composable to recompose our widget with progress
as a target value. Then, you need to pass the animatedProgress
interpolated value to the computer and the recomposition will do the rest.
val animatedProgress by animateFloatAsState(targetValue = progress) | |
val progressCoordinates = computer.progressCoordinates( | |
progress = animatedProgress, | |
// Other arguments | |
) |
By default, the progress will animate with a spring
effect but animateFloatAsState
composable comes with an animationSpec
optional argument if you’d like to change its behavior.
Was migrating to Compose worth it?
I can safely say I’m glad I’ve invested in this endeavor. Not only it was a great first experience for me with Compose, but I now find the code easier to read compared to the former version. This is particularly useful when it comes to Open Source libraries.
Also, I’ve reduced the LOC by 180% 🤩
Finally, I’ve deeply appreciated the Preview tool while building the widget. It allowed me to directly see how the component was rendered on the screen. I’ve managed to break down my widget in many previews to ensure it answers all the different scenarios I could think of. When you have several parameters working together such as spacing and angles while changing the number of segments, the Preview helps a lot to visualize all these use-cases.
Using This Widget in Your Project
The last part of the journey was to make this widget available to the community. After all, the widget was already flexible enough to fit most of the use-cases.
The repository comes with a sample highlighting all you can do with this widget. I’ve also exposed a breath effect and a “Kamehameha-like” animations to give you an idea of how you could customize even further your component on top of it.
You can find the repository here. Feel free to post an issue you may find, I’ll do my best to address them promptly. Better though, if you’d have some requests or ideas to improve the widget, you’re welcome to open a pull-request 🙌