Drawing with Compose : The Animated String Path
Probability of headache : 😌
M140 20C73 20 20 74 20 140c0 135 136 170 228 303 88–132 229–173 229–303 0–66–54–120–120–120–48 0–90 28–109 69–19–41–60–69–108–69z
Are these encrypted instructions meant to lead to some treasure? The first few decimals of pi? Or is it simply a code that must be entered into a bunker located on a desert island in the Pacific Ocean every 108 minutes, otherwise the world will end?
Nothing of the sort, this is an SVG path. The string describes the drawing of a shape :
- M140 20 means Pick up the pen and Move it to { x: 140, y: 20 }
- C73 20 20 74 20 140 means Put down the pen and Draw a Bézier curve from the current point to a new point { x: 20, y: 140 }. The start control point is { x: 73, y: 20 } and the end control point is { x: 20, y: 74 }
- …
If … is not enough for you, get a look at this SVG Path Visualizer.
Now you know what a path is. You can find some by opening an SVG file with a word processing software.
<?xml version="1.0" encoding="UTF-8"?>
<!-- Will you find the path ??? -->
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="31" viewBox="0 0 33 31" fill="none">
<g id="github-mark-white 1" clip-path="url(#clip0_252_1107)">
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M16.2648 0C7.4436 0 0.3125 7.10417 0.3125 15.893C0.3125 22.9184 4.88164 28.8652 11.2203 30.97C12.0127 31.1282 12.303 30.628 12.303 30.2072C12.303 29.8388 12.2769 28.5759 12.2769 27.26C7.83936 28.2074 6.91528 25.3654 6.91528 25.3654C6.20213 23.5235 5.14548 23.0501 5.14548 23.0501C3.69307 22.0765 5.25128 22.0765 5.25128 22.0765C6.86238 22.1818 7.70777 23.7079 7.70777 23.7079C9.13373 26.1285 11.4315 25.4445 12.3559 25.0235C12.4878 23.9972 12.9107 23.2868 13.3597 22.8922C9.82042 22.5238 6.09666 21.1556 6.09666 15.0508C6.09666 13.3142 6.73013 11.8933 7.73389 10.7883C7.57552 10.3937 7.02075 8.76202 7.89258 6.57814C7.89258 6.57814 9.23952 6.15705 12.2766 8.20951C13.5769 7.86162 14.9178 7.68465 16.2648 7.68316C17.6118 7.68316 18.9848 7.86754 20.2527 8.20951C23.2901 6.15705 24.6371 6.57814 24.6371 6.57814C25.5089 8.76202 24.9538 10.3937 24.7954 10.7883C25.8256 11.8933 26.433 13.3142 26.433 15.0508C26.433 21.1556 22.7092 22.4973 19.1435 22.8922C19.7247 23.3921 20.2263 24.3392 20.2263 25.8391C20.2263 27.9704 20.2002 29.6809 20.2002 30.2069C20.2002 30.628 20.4908 31.1282 21.283 30.9703C27.6216 28.8649 32.1907 22.9184 32.1907 15.893C32.2168 7.10417 25.0596 0 16.2648 0Z" fill="#FFFF00"></path>
</g>
<defs>
<clipPath id="clip0_252_1107">
<rect width="32" height="31" fill="white" transform="translate(0.3125)"></rect>
</clipPath>
</defs>
</svg>
Now that you know what a path is, let’s animate one with Compose! And if you ever need a reason to do that, I’ll give you three: it will enhance your knowledge about Compose, you can use it to create a loader, and animations are fuuun!
I will guide you through building the most important parts of the code. For the complete implementation, you can find a snippet at the end of this article, just above the claps and the comment section 👀.
First things first, let’s create a preview to test our future composable.
The composable will take as parameters: a path string, a color, a stroke width, an easing curve and a duration defining the animation and the now famous modifier.
@Preview
@Composable
fun StringPathAnimation() {
Box(modifier = Modifier.fillMaxSize()) {
val path = "M140 20C73 20 20 74 20 140c0 135 136 170 228 303 88-132 229-173" +
" 229-303 0-66-54-120-120-120-48 0-90 28-109 69-19-41-60-69-108-69z"
AnimatedPath(
pathStr = path,
color = Color.Red,
strokeWidth = 5.dp,
easing = EaseInOutSine,
duration = 2000,
modifier = Modifier.padding(20.dp)
)
}
}
In order to work with the path, we need to translate it into something Compose can draw. The DrawScope
has a drawPath()
function that takes an androidx.compose.ui.graphics.Path
—this is a start. Additionally, Compose has a PathParser
class that includes the functions:
parsePathString(pathData: String): PathParser
toPath(target: Path = Path()): Path
Nice, right? And it gets even better — it works!
with(LocalDensity.current) {
BoxWithConstraints(modifier) {
var path by remember { mutableStateOf(Path()) }
val strokeWidthPx = strokeWidth.toPx()
LaunchedEffect(pathStr, strokeWidthPx) {
val tmpPath = PathParser().parsePathString(pathStr).toPath()
tmpPath.fillBounds(strokeWidthPx, constraints.maxWidth, constraints.maxHeight)
path = tmpPath
}
}
The LaunchedEffect
will parse the path string into a Path
and then scale it to fill the composable.
Now we have a path, how are we going to draw it progressively ?
Compose has a class named PathMeasure
that will help us do the job.
After setting a path to PathMeasure
, we can obtain the path distance using the length
attribute. Additionally, we can retrieve an intermediate path between two distance values with getSegment()
. For instance, pathMeasure.getSegment(0f, pathMeasure.length, destination)
will place the entire path in the destination
, while pathMeasure.getSegment(0f, pathMeasure.length / 2, destination)
will place the first half. Can you guess how to get the second half?
It’s nearly complete now. All that’s left is to integrate PathMeasure
into an animation context, and for this, we'll employ an infinite transition.
val pathMeasure = remember { PathMeasure() }
pathMeasure.setPath(path, false)
val infiniteTransition = rememberInfiniteTransition(label = "Path infinite transition")
// Animating infinitely a float between 0f and the path length
val progress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = pathMeasure.length,
animationSpec = infiniteRepeatable(
animation = tween(duration, easing = easing),
repeatMode = RepeatMode.Reverse
), label = "Path animation"
)
// Create a intermediate path from 0f to progress
val animatedPath = remember {
derivedStateOf {
val destination = Path()
pathMeasure.setPath(path, false)
pathMeasure.getSegment(0f, progress, destination)
destination
}
}
// Draw the path
Canvas(modifier = Modifier.fillMaxWidth()) {
drawPath(animatedPath.value, color, style = Stroke(width = strokeWidthPx))
}
progress
will oscillate between 0f and the path length, guided by the specified easing curve. With each new progress
value, the animatedPath
will be updated and redrawn on the Canvas. It's as simple as that!
Let’s wrap it up :
@Composable
fun AnimatedPath(
pathStr: String,
color: Color,
strokeWidth: Dp,
easing: Easing,
duration: Int,
modifier: Modifier = Modifier
) {
with(LocalDensity.current) {
BoxWithConstraints(modifier) {
var path by remember { mutableStateOf(Path())}
val strokeWidthPx = strokeWidth.toPx()
// Here we transform the path string in path and make it fill the screen
LaunchedEffect(pathStr, strokeWidthPx) {
val tmpPath = PathParser().parsePathString(pathStr).toPath()
tmpPath.fillBounds(strokeWidthPx, constraints.maxWidth, constraints.maxHeight)
path = tmpPath
}
val pathMeasure = remember { PathMeasure() }
pathMeasure.setPath(path, false)
val infiniteTransition = rememberInfiniteTransition(label = "Path infinite transition")
// Animating infinitely a float between 0f and the path length
val progress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = pathMeasure.length,
animationSpec = infiniteRepeatable(
animation = tween(duration, easing = easing),
repeatMode = RepeatMode.Reverse
), label = "Path animation"
)
// Create a intermediate path from 0f to progress
val animatedPath = remember {
derivedStateOf {
val destination = Path()
pathMeasure.setPath(path, false)
pathMeasure.getSegment(0f, progress, destination)
destination
}
}
// Draw the path
Canvas(modifier = Modifier.fillMaxWidth()) {
drawPath(animatedPath.value, color, style = Stroke(width = strokeWidthPx))
}
}
}
}
fun Path.fillBounds(strokeWidthPx: Float, maxWidth: Int, maxHeight: Int) {
val pathSize = getBounds()
val matrix = Matrix()
val horizontalOffset = pathSize.left - strokeWidthPx / 2
val verticalOffset = pathSize.top - strokeWidthPx / 2
val scaleWidth = maxWidth / (pathSize.width + strokeWidthPx)
val scaleHeight = maxHeight / (pathSize.height + strokeWidthPx)
val scale = min(scaleHeight, scaleWidth)
matrix.scale(scale, scale)
matrix.translate(-horizontalOffset, -verticalOffset)
transform(matrix)
}
@Preview
@Composable
fun StringPathAnimation() {
Box(modifier = Modifier.fillMaxSize()) {
val path = "M140 20C73 20 20 74 20 140c0 135 136 170 228 303 88-132 229-173" +
" 229-303 0-66-54-120-120-120-48 0-90 28-109 69-19-41-60-69-108-69z"
AnimatedPath(
pathStr = path,
color = Color.Red,
strokeWidth = 10.dp,
easing = EaseInOutSine,
duration = 2000,
modifier = Modifier.padding(20.dp)
)
}
}
And the result ?
I hope you enjoy the reading. Feel free to share the story, give it a clap, and/or leave your comment in the dedicated section.