A small library, a long journey through Compose animations, and a few bad ideas along the way.
The Itch
I work on a crypto-banking app. Crypto prices change every second, and a number that just snaps to a new value looks cheap. I wanted that satisfying "odometer" feeling — when a digit rolls through every value on the way to the next one. Like a slot machine, or like the kilometer counter in an old car.
So I went looking for a library.
What I Couldn't Find
There are several "animated number" libraries for Compose. The popular ones use AnimatedContent to flip between two digits — the old digit fades or slides out, the new one slides in. It looks fine, but it skips everything in between.
That's not what I wanted. I wanted 2 → 3 → 4 → 5, every digit visible on the way. If you change from 2 to 7, you should see 3, 4, 5, 6 pass by.
I searched, I asked around, I checked posts in different languages through Google Translate. Nothing did exactly this. So I started building.
The result is compose-rolling-text. Here's how I got there — including the dead ends.
Attempt #1: The Naive Column
My first idea was the obvious one: for each digit slot, render a vertical Column of digits (0..9), and scroll through it. Like an iOS picker.
It worked. Kind of. But:
- It was laggy. Each digit slot was a tall column of 10 children. With 6 or 7 digits on screen, every animation frame triggered a heavy layout pass.
- Centering was weird. During the scroll, the text baseline shifted by tiny amounts. Numbers looked like they were jumping by half a pixel.
- Low-end devices suffered. Scrolling several of these widgets at once dropped frames. I needed something lighter.
Attempt #2: One Long String (The "Drum")
The breakthrough was simple: forget composables for each digit. Use one multi-line string.
If I'm going from 2 to 5, I build this string:
5
4
3
2
Then I put it inside a Box that's only one line tall, with clipToBounds(). I start the string positioned so 2 is visible, and slide it up until 5 is visible. Done.
One Text composable, one animation, no fighting with layout.
This is what buildDrumText does, and I'll show it in a moment.
The graphicsLayer Trick
Now, how do you slide the string up?
My first try was to animate a Modifier.offset or padding. Two problems:
- Every animation frame caused a layout pass. Compose had to remeasure things on every frame.
-
Centering broke during the animation. The container shifted, the parent's alignment recalculated, and digits drifted left or right by a pixel or two.
The fix was
Modifier.graphicsLayer:
BasicText(
text = drumText,
style = internalStyle,
overflow = TextOverflow.Visible,
maxLines = Int.MAX_VALUE,
modifier = Modifier.graphicsLayer {
translationY = verticalOffset
}
)
graphicsLayer applies the translation at the drawing stage, on the GPU. No layout pass, no remeasure. The text composable thinks it's still in the same place — only its pixels move. Smooth as butter, and centering stays rock-solid.
This was the moment everything clicked.
Easing: From Robot to Smooth
A linear animation feels mechanical — like a robot. Real-world objects don't move at constant speed; they speed up and slow down.
Material Design has a curve called "emphasized easing" that gives a natural settling effect: fast at the start, gentle at the end. I copied its values:
private val RollingEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
This one line is the difference between "okay" and "feels expensive."
The Snap Back to Plain Text
Here's a small but important detail.
While the animation is running, the Box contains the full drum string (5\n4\n3\n2). When the animation finishes, the visible character is 5 — but the composable still holds the whole string, just clipped.
Why does that matter? Because of sub-pixel rendering. Text engines, including Compose, do tiny adjustments based on surrounding glyphs. A 5 that lives inside a multi-line drum is positioned slightly differently than a standalone 5. The difference is maybe one pixel — but you can see it.
So at the end of the animation, I replace the drum string with just the final character:
try {
isAnimating = true
progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 500,
easing = RollingEasing,
)
)
} finally {
drumText = char.toString() // ← snap to clean state
previousChar = char
isAnimating = false
linePositions = emptyList()
}
The finally block makes sure this clean-up happens even if the animation is cancelled — for example, when the user changes the value before the previous roll has finished.
Autosize: Making It Fit
In a crypto ticker, you never know how long the number will be. $1.23 and $67,543.21 may have to fit into the same box.
I wanted the text to shrink automatically when needed. The trick is BoxWithConstraints plus a TextMeasurer:
BoxWithConstraints(modifier = modifier) {
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
val adjustedStyle = remember(displayedValue, maxWidth, style) {
if (maxWidth > 0 && maxWidth != Constraints.Infinity) {
calculateFontSizeToFit(
text = displayedValue,
style = style,
maxWidth = maxWidth,
textMeasurer = textMeasurer,
)
} else {
style
}
}
// ...
}
calculateFontSizeToFit starts with the requested font size and steps down by 1sp until the text fits. It's not a clever algorithm — but font sizes are small numbers (roughly 10 to 60), so it's fast enough.
Inside buildDrumText
This is the function that builds the drum string. It's tiny:
private fun buildDrumText(from: Char, to: Char): String {
val start = from.digitToInt()
val end = to.digitToInt()
return if (end > start) {
(end downTo start).joinToString("\n")
} else {
(start downTo end).joinToString("\n")
}
}
Notice that both branches use downTo. The string is always ordered from highest to lowest, top-to-bottom. The direction of the animation (rolling up or rolling down) is handled separately, by inverting the progress:
val adjustedProgress = if (isIncreasing) 1f - progress.value else progress.value
When the digit is increasing (2 → 5), I flip the progress so the drum slides from "bottom visible" to "top visible" — the new digit falls down from above. When it's decreasing (5 → 2), the drum slides the natural way — the new digit rises up from below. It matches how a real odometer behaves.
The Evil Math: calculateVerticalOffset
This one took me a while to get right.
private fun calculateVerticalOffset(
progress: Float,
linePositions: List<Float>,
lineHeight: Float,
linesCount: Int,
): Float = when {
linePositions.size >= linesCount -> {
-(linePositions[linesCount - 1] - linePositions[0]) * progress
}
lineHeight > 0f -> {
-progress * (linesCount - 1) * lineHeight
}
else -> 0f
}
Two branches:
-
If we have real measured line positions (from
TextLayoutResult.getLineTop()), use them. This is the accurate path — different fonts and glyphs have slightly different line heights, and measuring them gives a pixel-perfect offset. -
If we don't have line positions yet (the very first frame, before the text has been laid out), fall back to
lineHeight × (linesCount - 1). It's an estimate, but it's close, and the next frame will already use the real measurements. Why the minus sign? Because Compose's Y axis grows downward. To slide the text up (so the next digit comes into view), we need a negative translation.
Try It
RollingAnimatedText(
text = "$67,543.21",
style = MaterialTheme.typography.headlineLarge,
autoSize = true,
)
That's it. Drop it in, it rolls.
The library is on GitHub: github.com/morozione/compose-rolling-text. Stars, issues, and pull requests are very welcome — especially if you find a use case I didn't think of (a countdown timer? a stock ticker? a leaderboard score?).
What I Learned
Building this taught me three small things that I'll carry into the next project:
-
graphicsLayeris a superpower. Anytime you can move pixels without touching layout, you should. It's faster, smoother, and side-effect-free. - Sub-pixel details matter. The post-animation snap is the difference between "good" and "polished." Users won't be able to name what's wrong without it, but they'll feel it.
- Look for the simplest mental model. A single multi-line string was much better than ten stacked composables. The first idea isn't always the right idea. Thanks for reading. Now go build something smooth.
Top comments (0)