Fun with shapes in Compose

[ad_1]

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

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
)
}

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 using the following helper method:

fun Morph.toComposePath(progress: Float, scale: Float = 1f, path: Path = Path()): Path {
var first = true
path.rewind()
forEachCubic(progress) { bezier ->
// move to the initial position if its the first cubic curve
if (first) {
path.moveTo(bezier.anchor0X * scale, bezier.anchor0Y * scale)
first = false
}
// add cubic curve to the current path for each curve in the Morph
path.cubicTo(
bezier.control0X * scale, bezier.control0Y * scale,
bezier.control1X * scale, bezier.control1Y * scale,
bezier.anchor1X * scale, bezier.anchor1Y * scale
)
}
path.close()
return path
}

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()
}

Box(
modifier = Modifier
.padding(16.dp)
.drawWithCache {
morphPath = morph
.toComposePath(progress = progress.value, scale = size.minDimension / 2f, path = morphPath)

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 {
morphPath = morph
.toComposePath(progress = progress.value, scale = size.minDimension / 2f, path = morphPath)
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

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. Next, we need to transform the curves that make up the current path to a list of lines. We can use Path#flatten(), a method which approximates a path with a series of straight lines (usually a large list of small lines when approximating curves on a path). It also exposes information we will need such as the startFraction and endFraction of the current line segment.

These individual lines can then be drawn progressively over time, giving the same illusion of progressively drawing the entire path.

val pathMeasurer = remember {
PathMeasure()
}
var morphPath = remember {
Path()
}
//..
// in drawWithCache:
morphPath = morph.toComposePath(progress = progress.value)
val morphAndroidPath = morphPath
.asAndroidPath()
val flattenedStarPath = morphAndroidPath.flatten()

pathMeasurer.setPath(morphPath, false)
val totalLength = pathMeasurer.length

onDrawBehind {
rotate(rotation.value) {
translate(size.width / 2f, size.height / 2f) {
val currentLength = totalLength * progress.value
// For each line segment, we determine whether the whole line segment should be shown or fractionally drawn.
flattenedStarPath.forEach { line ->
// Check if the current line should be drawn at all, if its less than the current length of the whole line
if (line.startFraction * totalLength < currentLength) {
// if the progress is greater than the current lines endFraction, then we should draw the whole line. Otherwise we draw only a fraction of the line.
if (progress.value > line.endFraction) {
drawLine(
start = Offset(line.start.x, line.start.y),
end = Offset(line.end.x, line.end.y),
// .. other parameters
)
} else {
// we need to find where the line should end (x,y), based on the current progress as a line might end after the current progress, meaning we shouldn’t draw the whole line, but rather a fraction of it.
val endX = mapValue(
progress.value,
line.startFraction,
line.endFraction,
line.start.x,
line.end.x
)
val endY = mapValue(
progress.value,
line.startFraction,
line.endFraction,
line.start.y,
line.end.y
)
drawLine(
color = Color.Black,
start = Offset(line.start.x, line.start.y),
end = Offset(endX, endY),
strokeWidth = 16.dp.toPx(),
cap = StrokeCap.Round
)
}
}
}
}

// This function maps between two number ranges based on a value.
// We use it to map between a fraction (startFraction & endFraction), and the actual coordinates that should be calculated. For example, we’d like to get the X coordinate between the line.start.x and line.end.x, using the progress between the startFraction & endFraction to determine the value.
private fun mapValue(
value: Float,
fromRangeStart: Float,
fromRangeEnd: Float,
toRangeStart: Float,
toRangeEnd: Float
): Float {
val ratio =
(value - fromRangeStart) / (fromRangeEnd - fromRangeStart)
return toRangeStart + ratio * (toRangeEnd - toRangeStart)
}

We iterate over all the lines returned from Path#flatten(). For each line segment, we determine whether it should be drawn or skipped (line.startFraction * totalLength < currentLength) and then if it should be drawn completely (progress.value > line.endFraction) or fractionally.

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:

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.

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! 🟩🟡💜

[ad_2]

Source link


Leave a Reply

Your email address will not be published. Required fields are marked *