Morphing Number Animation in Jetpack Compose

Morphing Number Animation in Jetpack Compose

Jetpack Compose has made building Android apps easier than ever before. One aspect that is now easier to deal with is animation. That said, creating animations, in general, requires time and patience. In this blog post, I will show you how to create a morphing number animation where each digit gradually morphs into the next one.

To make these animations, you need to create each digit in a vector graphics editor like Inkscape and export them to SVG. We will use the SVG path for each digit and use Compose commands to draw them on Canvas. Canvas is used to draw everything you see on your screen on an Android device. To simplify our task, each digit will be drawn using a single stroke and only five nodes. This will make managing transitions easier by dealing with a fixed set of coordinates, rather than a variable number or variable types of commands.

Generating SVG images for all the digits is an involved process. To make this post easier to grasp, I have described how I created the SVG images in another post. You can refer to that post if you don't like how the digits or the transitions look like. Otherwise, you can just copy the ones I have already generated:

// SVG paths for digits 0 to 9
M 9.5711,149.46543 C 9.4772,86.81623 23.8726,8.74773 91.5763,4.53473 177.6464,-0.82107 202.5491,76.67563 200.7606,153.58433 200.2051,194.06073 190.073,297.12078 100.9805,292.54051 20.6571,288.41107 9.6441,198.09063 9.5711,149.46543
M 70.96325,59.18415 C 70.96325,59.18415 94.64565,46.46215 105.16455,38.20135 117.79115,28.28515 105.16455,38.20135 139.36595,4.28765 139.36595,4.28765 139.36595,94.72995 139.36595,139.95105 139.36595,190.87135 139.36595,292.71185 139.36595,292.71185
M 20.092742,73.588205 C 22.783442,-3.3354848 156.58864,-19.320625 176.83914,43.003315 203.17774,139.11666 106.15864,210.59574 11.931042,292.38013 95.809642,292.38013 135.99244,292.37463 198.02314,292.36814 198.03814,292.36813 198.05314,292.36813 198.06824,292.36813
M 35.0707,4.27402 C 35.0707,4.27402 84.0763,4.27402 108.5791,4.27402 134.1587,4.27402 185.318,4.27402 185.318,4.27402 130.7398,77.29711 128.6435,80.20258 103.6807,113.70261 313.538,97.84669 144.3483,418.1831 7.7136,237.02686
M 205.80476,192.59182 C 205.80476,192.59182 55.25923,192.92034 4.1948476,192.59182 27.430317,159.61295 102.25792,54.636836 138.45938,4.1952421 138.45938,53.311994 138.45938,130.20089 138.45938,193.20371 138.45938,224.2571 138.45938,286.36387 138.45938,286.36387
M 173.2887,4.298099 C 173.2887,4.298099 57.235402,4.298099 36.559902,4.298099 32.553402,39.855799 27.300002,86.480099 22.670002,127.5711 98.069502,59.533499 236.7401,120.7315 192.3053,235.49644 160.0425,315.53808 31.280502,307.4445 9.2142025,236.95416
M 180.6085,46.41543 C 170.477,8.05393 78.6828,-26.9429 37.3649,48.81914 15.5501,93.86806 13.4856,147.64663 19.1209,196.8542 23.999,90.50136 196.8706,87.98967 193.5056,204.21197 190.5619,305.87852 37.5273,340.55817 19.1209,196.8542
M 9.0506,4.28824 C 9.0506,4.28824 72.4102,4.28824 104.09,4.28824 136.4559,4.28824 171.1942,4.28824 201.1878,4.28824 180.111,48.58288 156.1061,99.03131 133.5652,146.40285 110.3591,195.17249 63.9469,292.71177 63.9469,292.71177
M 103.98236,123.90755 C -10.87144,105.65558 -0.2418397,8.938676 100.41516,4.221746 193.70096,-0.149754 235.81526,100.54044 104.01286,123.9475 220.75826,135.92096 245.49376,289.88455 105.21576,292.55385 -30.73964,295.61275 -15.84014,137.13145 104.04336,123.98745
M 193.46807,99.965401 C 159.02126,243.85802 -13.647492,172.39794 19.290401,68.241119 44.886316,-12.698763 178.54551,-30.17994 193.46807,99.965401 199.40338,151.7295 186.78965,213.13596 177.09557,239.7362 153.55747,311.24389 41.825865,305.84029 28.447965,251.2339

The paths above will output digits that look like this:

The paths need to be converted to arrays of floats so we can use them in Compose:

val points0 = arrayOf(9.5711f, 149.46542f, 9.4772f, 86.81623f, 23.8726f, 8.74773f, 91.5763f, 4.53473f, 177.6464f, -0.82107f, 202.5491f, 76.67563f, 200.7606f, 153.58434f, 200.2051f, 194.06073f, 190.073f, 297.1208f, 100.9805f, 292.5405f, 20.6571f, 288.41107f, 9.6441f, 198.09064f, 9.5711f, 149.46542f)
val points1 = arrayOf(70.96325f, 59.18415f, 70.96325f, 59.18415f, 94.64565f, 46.46215f, 105.16455f, 38.20135f, 117.79115f, 28.28515f, 105.16455f, 38.20135f, 139.36595f, 4.28765f, 139.36595f, 4.28765f, 139.36595f, 94.72995f, 139.36595f, 139.95105f, 139.36595f, 190.87135f, 139.36595f, 292.71185f, 139.36595f, 292.71185f)
val points2 = arrayOf(20.1064f, 73.93557f, 20.0159f, 34.525f, 63.4161f, 9.7031f, 97.6948f, 4.9576f, 126.7435f, 0.9361f, 169.1022f, 15.0676f, 176.8528f, 43.35068f, 203.1914f, 139.46402f, 106.1723f, 210.9431f, 11.9447f, 292.72748f, 95.8437f, 292.72748f, 136.0465f, 292.7245f, 198.0819f, 292.71548f)
val points3 = arrayOf(35.0707f, 4.27402f, 35.0707f, 4.27402f, 84.0763f, 4.27402f, 108.5791f, 4.27402f, 134.1587f, 4.27402f, 185.318f, 4.27402f, 185.318f, 4.27402f, 130.7398f, 77.29711f, 128.6435f, 80.20258f, 103.6807f, 113.70261f, 313.538f, 97.84669f, 144.3483f, 418.1831f, 7.7136f, 237.02686f)
val points4 = arrayOf(205.80476f, 192.59183f, 205.80476f, 192.59183f, 55.25923f, 192.92033f, 4.1948476f, 192.59183f, 27.430317f, 159.61295f, 102.25792f, 54.636837f, 138.45938f, 4.195242f, 138.45938f, 53.311993f, 138.45938f, 130.2009f, 138.45938f, 193.2037f, 138.45938f, 224.2571f, 138.45938f, 286.36386f, 138.45938f, 286.36386f)
val points5 = arrayOf(173.2887f, 4.298099f, 173.2887f, 4.298099f, 57.2354f, 4.298099f, 36.559902f, 4.298099f, 32.553402f, 39.8558f, 27.300001f, 86.4801f, 22.670002f, 127.5711f, 98.0695f, 59.5335f, 236.7401f, 120.7315f, 192.3053f, 235.49644f, 160.0425f, 315.5381f, 31.280502f, 307.4445f, 9.214203f, 236.95416f)
val points6 = arrayOf(180.6085f, 46.41543f, 170.477f, 8.05393f, 78.6828f, -26.9429f, 37.3649f, 48.81914f, 15.5501f, 93.86806f, 13.4856f, 147.64664f, 19.1209f, 196.8542f, 23.999f, 90.50136f, 196.8706f, 87.98967f, 193.5056f, 204.21198f, 190.5619f, 305.8785f, 37.5273f, 340.55817f, 19.1209f, 196.8542f)
val points7 = arrayOf(9.0506f, 4.28824f, 9.0506f, 4.28824f, 72.4102f, 4.28824f, 104.09f, 4.28824f, 136.4559f, 4.28824f, 171.1942f, 4.28824f, 201.1878f, 4.28824f, 180.111f, 48.58288f, 156.1061f, 99.03131f, 133.5652f, 146.40285f, 110.3591f, 195.17249f, 63.9469f, 292.71176f, 63.9469f, 292.71176f)
val points8 = arrayOf(103.98236f, 123.90755f, -10.87144f, 105.65558f, -0.2418397f, 8.938676f, 100.41516f, 4.221746f, 193.70096f, -0.149754f, 235.81526f, 100.54044f, 104.01286f, 123.9475f,220.75826f,135.92096f,245.49376f,289.88455f,105.21576f,292.55385f,-30.73964f,295.61275f,-15.84014f,137.13145f,104.04336f,123.98745f)
val points9 = arrayOf(193.46807f,99.965401f,159.02126f,243.85802f,-13.647492f,172.39794f,19.290401f,68.241119f,44.886316f,-12.698763f,178.54551f,-30.17994f,193.46807f,99.965401f,199.40338f,151.7295f,186.78965f,213.13596f,177.09557f,239.7362f,153.55747f,311.24389f,41.825865f,305.84029f,28.447965f,251.2339f)

Drawing the first digit

Our first step would be to use the path coordinates for a single digit and draw it on Canvas. I could cheat and tell you that you will need a single "Move" followed by four "Curve" commands, but since I wasn't sure before I started this, I used androidx.compose.ui.graphics.vector.PathParser to read a single path and then convert it to a set of PathNodes by calling toNodes() on the output. All the commands used are sealed classes that implement PathNode, and you will get their names if you loop through the nodes output by toNodes(). Also, you only need to do this for a single path since all of them use the same commands and only the coordinates are different.

// you could do this in a unit test
import androidx.compose.ui.graphics.vector.PathParser

val svgPath = "M 9.5711,149.46543 C 9.4772,86.81623 23.8726,8.74773 91.5763,4.53473 177.6464,-0.82107 202.5491,76.67563 200.7606,153.58433 200.2051,194.06073 190.073,297.12078 100.9805,292.54051 20.6571,288.41107 9.6441,198.09063 9.5711,149.46543"
val nodes = PathParser().parsePathString(svgPath).toNodes()
for (node in nodes) {
    println(node)
}

// which will output
MoveTo(x=9.5711, y=149.46542)
CurveTo(x1=9.4772, y1=86.81623, x2=23.8726, y2=8.74773, x3=91.5763, y3=4.53473)
CurveTo(x1=177.6464, y1=-0.82107, x2=202.5491, y2=76.67563, x3=200.7606, y3=153.58434)
CurveTo(x1=200.2051, y1=194.06073, x2=190.073, y2=297.1208, x3=100.9805, y3=292.5405)
CurveTo(x1=20.6571, y1=288.41107, x2=9.6441, y2=198.09064, x3=9.5711, y3=149.46542)

To draw the digit on the screen, we will convert the list of commands to a NodePath and then draw it using drawPath():

import androidx.compose.ui.graphics.vector.toPath
import androidx.compose.ui.graphics.vector.PathNode

val pz = listOf<Float>(/* list of coordinates for a single digit */)
val path = listOf(
    PathNode.MoveTo(pz[0], pz[1]),
    PathNode.CurveTo(pz[2], pz[3], pz[4], pz[5], pz[6], pz[7]),
    PathNode.CurveTo(pz[8], pz[9], pz[10], pz[11], pz[12], pz[13]),
    PathNode.CurveTo(pz[14], pz[15], pz[16], pz[17], pz[18], pz[19]),
    PathNode.CurveTo(pz[20], pz[21], pz[22], pz[23], pz[24], pz[25])
).toPath()

Spacer(modifier = Modifier.onDrawBehind {
    drawPath(path, color = Color.Black, style = Stroke(5f))
})

Animating the digit

As Compose works with States and an animation is merely a gradual change of State, we will use a class (calling it TransitionData) that stores the state of the current digit being displayed. We also write delegated "by" properties to make it easier to deal with this class:

class TransitionData(points: List<State<Float>>) {
    val p0 by points[0]
    val p1 by points[1]
    val p2 by points[2]
    // ...
    val p25 by points[25]
}

We will also use an Enum to represent the digits and a @Composable function that takes in the Enum and returns the "remembered" digit path using the TransitionData class.

enum class Digit { ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, ZERO }

@Composable
fun updateTransitionData(digit: Digit): TransitionData {
    val transition = updateTransition(digit)

    val points = mutableListOf<State<Float>>()
    for (i in 0 until 26) {
        points.add(transition.animateFloat() { digit ->
            when (digit) {
                Digit.ONE -> points1[i]
                Digit.TWO -> points2[i]
                // ...
                Digit.ZERO -> points0[i]
            }
        })
    }

    return remember(transition) { TransitionData(points) }
}

The updateTransition function sets up a Transition which is the basis of our animation. We pass digit to it to set its current state, which is a "remembered" value. The output of updateTransition is a Transition value, which we can use to add more animations to, using animateFloat, animateColor, etc.

To use the animated digit, we will then just need to use DrawScope.drawPath(path) in a @Composable. We can do that inside Modifier.drawBehind(), but it would be more efficient to also use drawWithCache() to reduce the number of draws to only when the screen size or the digit changes.

import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.PathNode
import androidx.compose.ui.graphics.vector.toPath
import io.github.hamidsafdari.animatedtext.Digit
import io.github.hamidsafdari.animatedtext.updateTransitionData

@Composable
fun MorphingDigit(digit: Digit) {
    val pz = updateTransitionData(digit)

    Spacer(modifier = Modifier
        .drawWithCache {
            val path = listOf(
                PathNode.MoveTo(pz.p0, pz.p1),
                PathNode.CurveTo(pz.p2, pz.p3, pz.p4, pz.p5, pz.p6, pz.p7),
                PathNode.CurveTo(pz.p8, pz.p9, pz.p10, pz.p11, pz.p12, pz.p13),
                PathNode.CurveTo(pz.p14, pz.p15, pz.p16, pz.p17, pz.p18, pz.p19),
                PathNode.CurveTo(pz.p20, pz.p21, pz.p22, pz.p23, pz.p24, pz.p25)
            ).toPath()

            onDrawBehind {
                drawPath(path, color = Color.Black, style = Stroke(5f))
            }
        })
}

A number is more than one digit

We are nearly done but, what gets drawn is a single digit number (i.e., 0 to 9), and you have to pass a Digit to it rather than something like a Long. To show longer numbers, we can convert our number to a string, map each character to a Digit , and then pass the result to a LazyRow:

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import io.github.hamidsafdari.animatedtext.Digit
import io.github.hamidsafdari.animatedtext.MorphingDigit

@Composable
fun MorphingNumber(number: Long) {
    val digits = number.toString(10).map { c ->
        when (c) {
            '1' -> Digit.ONE
            '2' -> Digit.TWO
            '3' -> Digit.THREE
            '4' -> Digit.FOUR
            '5' -> Digit.FIVE
            '6' -> Digit.SIX
            '7' -> Digit.SEVEN
            '8' -> Digit.EIGHT
            '9' -> Digit.NINE
            else -> Digit.ZERO
        }
    }

    LazyRow(reverseLayout = true) {
        itemsIndexed(digits.reversed()) { _, item ->
            MorphingDigit(item)
        }
    }
}

We are passing reverseLayout to LazyRow and reversing the list of digits, so the last digit is drawn first. That's because when we go from a shorter number to a larger number, the digits at the end should stay put. That means if the current number being shown is 99, and we go to 100, it's the 9s that get morphed into 0s and not the first two digits (e.g., 10).

Using the animation

We are done. All you need to do now is to use the MorphingNumber function like any other @Composable and pass a number to it. To see the animation in action, make the number change on click or after a delay in LaunchedEffect:

@Preview
@Composable
fun MorphingNumberPreview() {
    var count by remember { mutableLongStateOf(1000) }
    LaunchedEffect(true) {
        for (i in 0 until 10) {
            delay(1000)
            count += 1
        }
    }

    Surface(
        modifier = Modifier
            .width(300.dp)
            .height(110.dp)
    ) {
        MorphingNumber(number = count)
    }
}

Last words

In this post, I showed you how to create a morphing number animation in Jetpack Compose. Needless to say, you can use this process to animate any other shape as long as the number of points and the commands used to generate the shape are consistent between the two states. The source code for this post is available here.