Featured image of post How to build Wrapped 2023 in Compose Animation

How to build Wrapped 2023 in Compose Animation

How to build Wrapped 2023 in Compose Animation

If I had to build the Spotify Wrapped 2023 animations in Compose, this is how I would do it. This blog post doesn’t reproduce the effects identically, but it shows some of the basic Compose animation techniques you could use. It uses Canvas drawing and especially the drawWithCache modifier. It also uses RoundedPolygon, various matrix transformations and looping animation techniques using infiniteRepeatable. There is a section about how to get a path from an svg file as well.

I created three different techniques. The first is a simple box with an animating linear gradient and an animating offset. The second is a spiky star using a RoundedPolygon with colour stop gradient and some animations in a graphicsLayer. The last is a progressivly drawing line. As a bonus we’ll make a palette using unsplash.com and coolors.co Let’s start with the palette.

Pick a palette

Pick an image from unsplash and pull some colours from the image. Here’s one I got. I then loaded it into coolors.co and picked some palette colours from the image.

Here are the colours I picked:

val Pasta = Color(0xFFE7D6CE)
val OrangeSquash = Color(0xFFFD9B72)
val Plumberry = Color(0xFF682084)
val Aubergette = Color(0xFF421F7B)
val SwimmingCap = Color(0xFF4FBBA5)
val Cherry = Color(0xFFFC1946)
val Sherbet = Color(0xFFD33BB9)
val Licorice = Color(0xFF35192B)
val Custard = Color(0xFFFEC05D)

We can now use these everywhere as gradients or backgrounds and our animation will have a consistent look that matches the mood from the image.

Shimer pane - animating Linear Gradient

For this sliding box we animate the offset with an infiniteRepeatable and we animate the offset of the LinearGradient. The colors of the gradient are chosen from the colors of our newly created palette. The linear gradient changes colour from corner to corner. By changeing the offset of the gradient it moves the gradient in the shape. If we then animate this offset it will make the colours move across the shape. The second animation is simply an x offset to slide the box in and out. You can look at the complete ShimmerPane function in the repo. This linear gradient trick is explained in detail in this blog post.

In the code snippet you can see how the linear gradient brush is created in the drawWithCache modifier and then the actual drawing happens in the lambda parameter of the onDrawBehind function.

Spacer(modifier
.offset(200.dp*slideIn, 0.dp)
.drawWithCache {
    val brush = Brush.linearGradient(
    colors = paneColors,
    start = Offset(offset, offset),
    end = Offset(offset + deltaPx, offset + deltaPx),
    tileMode = TileMode.Repeated
    )
    onDrawBehind {
        drawRect(brush = brush, style = Fill)
    }

})

Spiky Splash - rotating RoundedPolygon with drawWithCache modifier

The Spiky Splash shape is created with a RoundedPolygon with 14 vertices.

val spikySplash = RoundedPolygon.star(
    numVerticesPerRadius = 14,
    innerRadius = 0.4f,
    innerRounding = CornerRounding(radius = 0.1f)
)

The trick with the RoundedPolygon is that it is created in a canonical rectangle that ranges from -1 to 1 both the x and the y axis. To be able to see it we need to transform it. Also the transformation needs to happen using the Android View Matrix, not the Compose matrix. The transformation looks like this:

fun fromBoundsToView(
bounds: RectF = RectF(-1f, -1f, 1f, 1f),
width: Float,
height: Float
): Matrix {
    val originalWidth = bounds.right - bounds.left
    val originalHeight = bounds.bottom - bounds.top
    val scale = min(width / originalWidth, height / originalHeight)
    val newLeft = bounds.left - (width / scale - originalWidth) / 2
    val newTop = bounds.top - (height / scale - originalHeight) / 2
    val matrix = Matrix()
    matrix.setTranslate(-newLeft, -newTop)
    matrix.postScale(scale, scale)
    return matrix
}

This matrix can be used to transform the RoundedPolygon into the hosting view. When we draw this polygon we need to first transform it with the matrix and then convert it to a compose path. The transformation happens in the drawWithCache modifier so that it is cached.

Here is just the drawWithCache modifier:

.drawWithCache {
    val matrix = fromBoundsToView(width = size.width, height = size.height)
    val sizedSpikySplash = RoundedPolygon(spikySplash).apply { transform(matrix) }
    val spikyBrush = Brush.radialGradient(colorStops = colorStops)
    onDrawBehind {
        drawPath(
        path = sizedSpikySplash
        .toPath()
        .asComposePath(),
        brush = spikyBrush
        )
    }
}

It is coloured with a radial gradient with stops so that the colours make more distinct bands. Full spiky splash source here

The animation is pretty standard, infiniteTransition changing then rotation angle.

val spikyTransition = rememberInfiniteTransition(label = "spiky transition")
val rotate by spikyTransition.animateFloat(
    initialValue = 45f,
    targetValue = -45f,
    animationSpec = infiniteRepeatable(
        animation = tween(4_000, easing = FastOutSlowInEasing),
        repeatMode = RepeatMode.Reverse
    ),
    label = "Spiky rotate "
)

We use the rotation angle in a graphicsLayer modifier because we don’t need to redraw the spiky splash, we only need to rotate it. We also change the shape to be more oval in this snippet by scaling it to half the size in the y axis.

.graphicsLayer {
  scaleY = 0.5f
  rotationZ = rotate
}

Scribble drawing - path, matrix transform, flattened path

How to get a path from svg

Create some scribbles in a package like inkscape or any drawing package that will let you save an svg. Don’t make it complicated, literally just draw a line with the pencil. Then open the svg with a text editor. You can open the svg displayed here and see what it looks like. You are looking for a line that starts with something like <path style="fill:none;...

Copy everything in the d="... section as a string and paste it into your code.

Transforming the path with the matrix

The matrix transformation is very similar to the RoundedPolygon one, but… we now need the Compose Matrix not the Android view one. Also the bounds of the path are no longer -1,1 for both x and y. We can get the bounds of the path and use it to make a matrix to size the scribble to fit the view.

Drawing the path

If we just draw the path as is, it will draw the whole path and not progressively draw it. There is a neat trick as shown in this video at timestamp 31:36.

Scribble draw the path with flattened lines

The clue is we split the whole path up into short sections, little lines, which we draw one after the other. The line of code where this happens is

val lines = path.asAndroidPath().flatten(0.5f)

The 0.5f parameter in the flatten call is the error that the flatten call allows, 0.5 is half a pixel. You can also see we need to convert again to an AndroidPath because the flatten method is only available on Android paths. We animate a progress variable so we can loop from say 0% to 10% and so on up to 100% and then start again. Then in the modifier onDrawBehind function lambde we loop through the lines and draw only those lines up to a the progress variable. The animation is caused by only some of the path subsections being drawn and more and more of them being drawn as the progerss increases.

Combining everything

Since these different elements are all built in their own composable. We can put them all together remembering that the Composables that are drawn first will be at the bottom.

@Composable
fun Giggle(modifier: Modifier = Modifier) {
    Box(modifier.background(Sherbet)) {

        ShimmerPane(
          Modifier
            .height(280.dp)
            .width(250.dp)
        )
        Daisy(colors = listOf(SwimmingCap, Licorice))
        SpikyScribble(
          colors = listOf(Cherry, Licorice),
          modifier = Modifier
            .offset(-150.dp, 400.dp)
        )
        Bean(colors = listOf(Custard, OrangeSquash),
          modifier = Modifier
            .fillMaxSize()
        )
        SpikySplash(
          Modifier
            .size(500.dp)
            .offset(100.dp, 400.dp)
        )
    }
}

That’s a wrap

Here’s the repo

and here’s the demo

What’s next

This experiment is by no means complete. This is what I could try out next:

  • Figure out what is up with the scribble line transform that makes it sometimes cut off at the bottom
  • Make the scribble lines more like the real thing by figuring out how to make the gradient draw along the line
  • Figure out a way to do the blocky scribble
  • Draw some album art and add text
  • Make a circular calendar with a bezier or RoundedPolygon animating graph
  • Make sliding in blinds effect
  • Get real data from the Spotify API and expand the animations

But hey, there is always more to learn and explore.

If you want to get a step by step walkthrough of concepts such as these with some theory discussions and a practise repo, check out my droidcon academy coffee break codelab titled Draw and Animate on Canvas with Jetpack Compose in Android

References

Alejandra’s Medium article on animating linear gradient brush

Chet’s article on Rounded Polygons

Rebecca’s video on drawing text as a flattened path and more

Rebecca’s gist for the scribble lines