Subscribe for UI recipes
Animated Counter in Jetpack Compose
Create a realistic number counter animation 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!