Fun with shapes in Compose

Rebecca Franks
Android Developers
Published in
6 min readFeb 27, 2024

--

We’ve just released new documentation covering how to use the graphics-shapes library in Jetpack Compose. Whilst that covers the basics, I thought it would be fun to try something a bit more advanced and create a different looking progress bar than the standard ones we are used to.

In this blog post, we will cover how to create this progress bar that transitions from a squiggly “star” shaped rounded polygon to a circle while performing the regular progress animation.

Squiggly line gradient infinite progress bar

Create the polygons

The first step we want to perform is a transition from a circle to a squiggly circle, so we create the two shapes that we need to morph between.

The two shapes that we will morph between

We use RoundedPolygon#star() as this allows us to set an inner radius for the shape with rounded corners, and a RoundedPolygon#circle() for the circle shape.

val starPolygon = remember {
RoundedPolygon.star(
numVerticesPerRadius = 12,
innerRadius = 1f / 3f,
rounding = CornerRounding(1f / 6f))
}
val circlePolygon = remember {
RoundedPolygon.circle(
numVertices = 12
)
}

Morph between the two shapes

In order to morph between the two polygons, we need to create a Morph object:

val morph = remember {
Morph(starPolygon, circlePolygon)
}

This will be used with an animated progress value to determine the progress of the morph between these two shapes. To draw a Morph object, we need to get a Path object from its geometry, which we create from using the toPath() method. This returns an android.graphics.Path, we transform it to a Compose Path using asComposePath(). With the Morph’s Path we can call DrawScope#drawPath() to draw our animating morph shape:

val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val progress = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(4000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "progress"
)
//.. shapes that are created .. //
var morphPath = remember {
Path()
}
var androidPath = remember {
Path()
}
var matrix = remember {
Matrix()
}


Box(
modifier = Modifier
.padding(16.dp)
.drawWithCache {
androidPath = morph.toPath(progress.value, androidPath)
morphPath = androidPath.asComposePath()
matrix.reset()
matrix.scale(size.minDimension / 2f, size.minDimension / 2f)
morphPath.transform(matrix)


onDrawBehind {
translate(size.width / 2f, size.height / 2f) {
drawPath(morphPath, color = Color.Black, style = Stroke(16.dp.toPx()))
}
}
}
)
Path morphing between circle and squiggly star shape

Now we can rotate the shape over time by creating another animating variable for rotation and calling DrawScope#rotate().

val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val progress = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(4000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "progress"
)
val rotation = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
tween(4000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "rotation"
)


Box(
modifier = Modifier
.padding(16.dp)
.drawWithCache {
androidPath = morph.toPath(progress.value, androidPath)
morphPath = androidPath.asComposePath()
matrix.reset()
matrix.scale(size.minDimension / 2f, size.minDimension / 2f)
morphPath.transform(matrix)

onDrawBehind {
rotate(rotation.value){
translate(size.width / 2f, size.height / 2f) {
drawPath(morphPath, color = Color.Black, style = Stroke(16.dp.toPx()))
}
}

Which results in a rotating and morphing shape animation like this:

Morphing and rotating circle

Progressively draw the path over time

We have the path, and we have it rotating over time, but the final result above shows that we are only drawing a segment of the path and not the full path. How can we achieve this?

First, we need to know the length of the path, which we can get from PathMeasure. We can then use PathMeasure.getSegment() to get a portion of the path, based on the current progress (totalLength * progress.value), with the result used in a new path object called destinationPath .

This segment of the path can then be drawn as follows:

val pathMeasurer = remember {
PathMeasure()
}
var morphPath = remember {
Path()
}
//..


// in drawWithCache:
androidPath = morph.toPath(progress.value, androidPath)
morphPath = androidPath.asComposePath()
matrix.reset()
matrix.scale(size.minDimension / 2f, size.minDimension / 2f)
morphPath.transform(matrix)

pathMeasurer.setPath(morphPath, false)
val totalLength = pathMeasurer.length
destinationPath.reset()
pathMeasurer.getSegment(0f, totalLength * progress.value, destinationPath)

onDrawBehind {
rotate(rotation.value) {
translate(size.width / 2f, size.height / 2f) {
val brush = Brush.sweepGradient(colors, center = Offset(0.5f, 0.5f))

drawPath(destinationPath, brush, style = Stroke(16.dp.toPx(), cap = StrokeCap.Round))
}
}
}

Without the rotation, this produces the following result:

With animations for both morphing and rotation added in, we can see it slowly morph to a circle as it draws the line:

Draw gradient colors over time with the lines

Now that we have our path drawing over time, we want to apply a gradient to the path. The näive approach would be to just set a Brush.linearGradient() with the colors we want for each drawing operation. However if we run this, we can see that it’s not giving the exact desired effect, the gradient is applied across the whole path in a single direction, and doesn’t follow the direction of the line.

From the image below, you can see that it follows one direction across the whole shape, where we’d actually want it to change color as the line is drawn in place.

Remember those very exciting rainbow gel pens you used to get back in the day? We’d like that effect to be applied to our shape — changing color as it follows the direction of the drawn line.

To do this, we can use Brush.sweepGradient() with the provided colors, this gives the effect of the gradient being drawn over time.

val brush = Brush.sweepGradient(colors, center = Offset(0.5f, 0.5f))

Which gives us the following result:

This looks great! However, if we wanted to have something more generic that worked for arbitrary path drawing, we’d need to change the implementation to something along the lines of this example.

Summary

The new graphics-shapes library unlocks a whole range of new shape possibilities in Android. The example in this article created a shape and used it to make a custom circular progress bar, but there are many other possibilities to explore with these new APIs for creating rounded shapes and morphs. The full code snippet can be found here.

Go forth and make fun shapes! 🟩🟡💜

The code snippets in this blog have the following license:

// Copyright 2023 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--

Rebecca Franks
Android Developers

Android Developer Relations Engineer at Google. London.