<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: morozione</title>
    <description>The latest articles on DEV Community by morozione (@morozione_e85f4e0ed33e002).</description>
    <link>https://dev.to/morozione_e85f4e0ed33e002</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3780669%2F1ed60e22-550d-4583-8da4-a8438728557c.jpg</url>
      <title>DEV Community: morozione</title>
      <link>https://dev.to/morozione_e85f4e0ed33e002</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/morozione_e85f4e0ed33e002"/>
    <language>en</language>
    <item>
      <title>I Couldn't Find a Rolling Text Library for Compose, So I Built One</title>
      <dc:creator>morozione</dc:creator>
      <pubDate>Mon, 11 May 2026 22:38:06 +0000</pubDate>
      <link>https://dev.to/morozione_e85f4e0ed33e002/i-couldnt-find-a-rolling-text-library-for-compose-so-i-built-one-306b</link>
      <guid>https://dev.to/morozione_e85f4e0ed33e002/i-couldnt-find-a-rolling-text-library-for-compose-so-i-built-one-306b</guid>
      <description>&lt;p&gt;&lt;em&gt;A small library, a long journey through Compose animations, and a few bad ideas along the way.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Itch
&lt;/h2&gt;

&lt;p&gt;I work on a crypto-banking app. Crypto prices change every second, and a number that just &lt;em&gt;snaps&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;So I went looking for a library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Couldn't Find
&lt;/h2&gt;

&lt;p&gt;There are several "animated number" libraries for Compose. The popular ones use &lt;code&gt;AnimatedContent&lt;/code&gt; to &lt;strong&gt;flip&lt;/strong&gt; between two digits — the old digit fades or slides out, the new one slides in. It looks fine, but it skips everything in between.&lt;/p&gt;

&lt;p&gt;That's not what I wanted. I wanted &lt;code&gt;2 → 3 → 4 → 5&lt;/code&gt;, every digit visible on the way. If you change from &lt;code&gt;2&lt;/code&gt; to &lt;code&gt;7&lt;/code&gt;, you should &lt;em&gt;see&lt;/em&gt; &lt;code&gt;3&lt;/code&gt;, &lt;code&gt;4&lt;/code&gt;, &lt;code&gt;5&lt;/code&gt;, &lt;code&gt;6&lt;/code&gt; pass by.&lt;/p&gt;

&lt;p&gt;I searched, I asked around, I checked posts in different languages through Google Translate. Nothing did exactly this. So I started building.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/morozione/compose-rolling-text" rel="noopener noreferrer"&gt;compose-rolling-text&lt;/a&gt;. Here's how I got there — including the dead ends.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #1: The Naive Column
&lt;/h2&gt;

&lt;p&gt;My first idea was the obvious one: for each digit slot, render a vertical &lt;code&gt;Column&lt;/code&gt; of digits (&lt;code&gt;0..9&lt;/code&gt;), and scroll through it. Like an iOS picker.&lt;/p&gt;

&lt;p&gt;It worked. Kind of. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It was laggy.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centering was weird.&lt;/strong&gt; During the scroll, the text baseline shifted by tiny amounts. Numbers looked like they were jumping by half a pixel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-end devices suffered.&lt;/strong&gt; Scrolling several of these widgets at once dropped frames.
I needed something lighter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attempt #2: One Long String (The "Drum")
&lt;/h2&gt;

&lt;p&gt;The breakthrough was simple: forget composables for each digit. Use &lt;strong&gt;one multi-line string&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If I'm going from &lt;code&gt;2&lt;/code&gt; to &lt;code&gt;5&lt;/code&gt;, I build this string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5
4
3
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I put it inside a &lt;code&gt;Box&lt;/code&gt; that's only one line tall, with &lt;code&gt;clipToBounds()&lt;/code&gt;. I start the string positioned so &lt;code&gt;2&lt;/code&gt; is visible, and slide it up until &lt;code&gt;5&lt;/code&gt; is visible. Done.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;Text&lt;/code&gt; composable, one animation, no fighting with layout.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;buildDrumText&lt;/code&gt; does, and I'll show it in a moment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The graphicsLayer Trick
&lt;/h2&gt;

&lt;p&gt;Now, &lt;em&gt;how&lt;/em&gt; do you slide the string up?&lt;/p&gt;

&lt;p&gt;My first try was to animate a &lt;code&gt;Modifier.offset&lt;/code&gt; or padding. Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every animation frame caused a &lt;strong&gt;layout pass&lt;/strong&gt;. Compose had to remeasure things on every frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centering broke&lt;/strong&gt; 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 &lt;code&gt;Modifier.graphicsLayer&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;BasicText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drumText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;internalStyle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;overflow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TextOverflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maxLines&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_VALUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;graphicsLayer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;translationY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verticalOffset&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;graphicsLayer&lt;/code&gt; applies the translation at the &lt;strong&gt;drawing&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;This was the moment everything clicked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Easing: From Robot to Smooth
&lt;/h2&gt;

&lt;p&gt;A linear animation feels mechanical — like a robot. Real-world objects don't move at constant speed; they speed up and slow down.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;RollingEasing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CubicBezierEasing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one line is the difference between "okay" and "feels expensive."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Snap Back to Plain Text
&lt;/h2&gt;

&lt;p&gt;Here's a small but important detail.&lt;/p&gt;

&lt;p&gt;While the animation is running, the &lt;code&gt;Box&lt;/code&gt; contains the full drum string (&lt;code&gt;5\n4\n3\n2&lt;/code&gt;). When the animation finishes, the visible character is &lt;code&gt;5&lt;/code&gt; — but the composable still holds the whole string, just clipped.&lt;/p&gt;

&lt;p&gt;Why does that matter? Because of &lt;strong&gt;sub-pixel rendering&lt;/strong&gt;. Text engines, including Compose, do tiny adjustments based on surrounding glyphs. A &lt;code&gt;5&lt;/code&gt; that lives inside a multi-line drum is positioned slightly differently than a standalone &lt;code&gt;5&lt;/code&gt;. The difference is maybe one pixel — but you can see it.&lt;/p&gt;

&lt;p&gt;So at the end of the animation, I replace the drum string with just the final character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;isAnimating&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;snapTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animateTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;targetValue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;animationSpec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;durationMillis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;easing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RollingEasing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;drumText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// ← snap to clean state&lt;/span&gt;
    &lt;span class="n"&gt;previousChar&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt;
    &lt;span class="n"&gt;isAnimating&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;linePositions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;finally&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Autosize: Making It Fit
&lt;/h2&gt;

&lt;p&gt;In a crypto ticker, you never know how long the number will be. &lt;code&gt;$1.23&lt;/code&gt; and &lt;code&gt;$67,543.21&lt;/code&gt; may have to fit into the same box.&lt;/p&gt;

&lt;p&gt;I wanted the text to &lt;strong&gt;shrink automatically&lt;/strong&gt; when needed. The trick is &lt;code&gt;BoxWithConstraints&lt;/code&gt; plus a &lt;code&gt;TextMeasurer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;BoxWithConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;textMeasurer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rememberTextMeasurer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;maxWidth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxWidth&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;adjustedStyle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displayedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxWidth&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;Constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;calculateFontSizeToFit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;displayedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;maxWidth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;textMeasurer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;textMeasurer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;style&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;calculateFontSizeToFit&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inside buildDrumText
&lt;/h2&gt;

&lt;p&gt;This is the function that builds the drum string. It's tiny:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;buildDrumText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digitToInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;end&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digitToInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="n"&gt;downTo&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="n"&gt;downTo&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that &lt;strong&gt;both branches use &lt;code&gt;downTo&lt;/code&gt;&lt;/strong&gt;. The string is always ordered from highest to lowest, top-to-bottom. The &lt;em&gt;direction&lt;/em&gt; of the animation (rolling up or rolling down) is handled separately, by inverting the progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;adjustedProgress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isIncreasing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;1f&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the digit is &lt;strong&gt;increasing&lt;/strong&gt; (&lt;code&gt;2 → 5&lt;/code&gt;), I flip the progress so the drum slides from "bottom visible" to "top visible" — the new digit &lt;em&gt;falls down&lt;/em&gt; from above. When it's &lt;strong&gt;decreasing&lt;/strong&gt; (&lt;code&gt;5 → 2&lt;/code&gt;), the drum slides the natural way — the new digit &lt;em&gt;rises up&lt;/em&gt; from below. It matches how a real odometer behaves.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Evil Math: calculateVerticalOffset
&lt;/h2&gt;

&lt;p&gt;This one took me a while to get right.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;calculateVerticalOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;linePositions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;linesCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;linePositions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;linesCount&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;-(&lt;/span&gt;&lt;span class="n"&gt;linePositions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;linesCount&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;linePositions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;lineHeight&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0f&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;linesCount&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lineHeight&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0f&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two branches:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If we have real measured line positions&lt;/strong&gt; (from &lt;code&gt;TextLayoutResult.getLineTop()&lt;/code&gt;), use them. This is the accurate path — different fonts and glyphs have slightly different line heights, and measuring them gives a pixel-perfect offset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If we don't have line positions yet&lt;/strong&gt; (the very first frame, before the text has been laid out), fall back to &lt;code&gt;lineHeight × (linesCount - 1)&lt;/code&gt;. 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 &lt;strong&gt;downward&lt;/strong&gt;. To slide the text &lt;em&gt;up&lt;/em&gt; (so the next digit comes into view), we need a &lt;strong&gt;negative&lt;/strong&gt; translation.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;RollingAnimatedText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"$67,543.21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headlineLarge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;autoSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Drop it in, it rolls.&lt;/p&gt;

&lt;p&gt;The library is on GitHub: &lt;a href="https://github.com/morozione/compose-rolling-text" rel="noopener noreferrer"&gt;github.com/morozione/compose-rolling-text&lt;/a&gt;. 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?).&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Building this taught me three small things that I'll carry into the next project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;graphicsLayer&lt;/code&gt; is a superpower.&lt;/strong&gt; Anytime you can move pixels without touching layout, you should. It's faster, smoother, and side-effect-free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-pixel details matter.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the simplest mental model.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>showdev</category>
      <category>ui</category>
    </item>
  </channel>
</rss>
