Subscribe for UI recipes

Animated Counter in Jetpack Compose

Create a realistic number counter animation in Jetpack Compose

Animated Counter in Jetpack Compose

In this article, we will create a counter that animates when changed. The numbers will slide from up when increasing and from down while decreasing.

Let's begin

Step 1: A simple counter

First, we need to create a counter before we start adding animations to it.

var count by remember { mutableStateOf(0) }  
  
Column (  
    modifier = Modifier.fillMaxSize(),  
    horizontalAlignment = Alignment.CenterHorizontally,  
    verticalArrangement = Arrangement.Center  
) {  
    CounterButton("+") { count++ }  
  
    Text(  
        "$count",  
        style = MaterialTheme.typography.h1,  
        textAlign = TextAlign.Center,  
    )  
  
    CounterButton("-") { count-- }  
}

Here we simply have an Integer called count that we increment/decrement whenever the respective buttons are tapped.
This is the basis of the animations we shall add.

Step 2: Basic animation

Now we shall implement a quick animation using AnimatedContent.

AnimatedContent(  
    targetState = count,  
    transitionSpec = {  
        if (targetState > initialState) {  
            slideInVertically { -it } with slideOutVertically { it }  
        } else {  
            slideInVertically { it } with slideOutVertically { -it }  
        }  
    }  
) { count ->  
    Text(  
        "$count",  
        style = MaterialTheme.typography.h1,  
        textAlign = TextAlign.Center,  
    )  
}

We can wrap our Text composable with an AnimatedContent and define what type of animation should be applied on it.

We can define animation based on the current state (initialState) and the next state (targetState). If the next number is larger, we slide animate the new number from the top and slide animate the old number towards the bottom. And vice-versa for decrementing.

Step 3: Advanced animation

In the last section, we got quite close to the final animation, but things get weird once we get to numbers past 10. Even if only one digit changes in the number, it animates the whole text composable.

In this section, we shall animate the digits independently, in order to get a more realistic animation.

Row(  
    modifier = Modifier  
        .animateContentSize()  
        .padding(horizontal = 32.dp),  
    horizontalArrangement = Arrangement.Center,  
    verticalAlignment = Alignment.CenterVertically,  
) {  
    count.toString()
        .mapIndexed { index, c -> Digit(c, count, index) }
        .forEach { digit ->  
            AnimatedContent(  
                targetState = digit,  
                transitionSpec = {  
                    if (targetState > initialState) {  
                        slideInVertically { -it } with slideOutVertically { it }  
                    } else {  
                        slideInVertically { it } with slideOutVertically { -it }  
                    }  
                }  
            ) { digit ->  
                Text(  
                    "${digit.digitChar}",  
                    style = MaterialTheme.typography.h1,  
                    textAlign = TextAlign.Center,  
                )  
            }  
        }
}

In the code above, the slide animation remains the same but it is applied on all digits independently, which are arranged side by side in a Row.

We take the count Integer and transform it into a string and reverse it. It is reversed so that we feed the digits into our data object starting from the ones place digit. Afterwards, we will reverse it back so that the digits are arranged in the Row ending with the ones place digit.

The Digit data class is defined as follows:

data class Digit(val digitChar: Char, val fullNumber: Int, val place: Int) {  
    override fun equals(other: Any?): Boolean {  
        return when (other) {  
            is Digit -> digitChar == other.digitChar  
            else -> super.equals(other)  
        }  
    }  
}  
  
operator fun Digit.compareTo(other: Digit): Int {  
    return fullNumber.compareTo(other.fullNumber)  
}

We will use this to keep track of the digit character along with the full number and which place it's in (ones place = 0, tens place = 1, ... )


We can also define a custom comparators so that the AnimatedCounter knows how to animate specific digit.


To determine equality, we use the digitChar so that a digit does not need to animate if it stays the same as the whole number changed (For example: 189 -> 190, The 1 stays in the same place and should not animate)

In contrast, we use the fullNumber to compare which is larger. This is because the digit may go down in value but the animation should indicate a bigger number (For example: 319 -> 320, The 9 is bigger than the 0, but the whole number is going up in value)

And with that, we have an impressive counter animation.

Note: I am experimenting with interactive Jetpack Compose samples. This means the counter you can interact with is the exact same code that is being displayed. Play around with it and if you have any feedback or issues, let me know.

Thanks for reading and good luck!

Mastodon