From 54a29ef9d8332b1280830a845537c8baa837c103 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 10:50:31 -0400 Subject: [PATCH] fix(ui): match the iOS number roll in AnimatedNumberText Compared against a 60fps recording of the iOS market cap readout, which uses `.contentTransition(.numericText())` with `.snappy`: - Stagger: iOS starts each character about a frame after the one to its left. Android started every slot on the same frame, which read as abrupt. Each slot now waits 12ms per position, but only from rest; a slot already rolling retargets immediately so a scrub can't keep re-delaying it. - Quick scrubs held a digit: every value change restarted the slot's animation from rest, so a value changing each frame barely moved. Each slot now runs one spring loop and a change only updates its target, keeping velocity. - Mid-roll gaps: the outgoing glyph faded out before the incoming one faded in, so the slot went blank. The fades now overlap (out by 0.7, in over 0.2..0.9), leaving two dim smudges like iOS. - Settle: damping 0.85, from `.snappy`'s 0.15 bounce. Stiffness stays at 380, which matched iOS's per-digit timing in the recording. - New leading digits drew over the "$": the resize clip sat inside the slot's width layout, so it clipped to the glyph's full width. It now sits outside. - Blur cut off square: Android renders a RenderEffect into a layer the size of its node, so each glyph's blur stopped at its own edges, visible on the last digit. Glyph layers are padded by the blur radius without changing the cell size. --- .../ui/components/text/AnimatedNumberText.kt | 174 +++++++++++++----- .../ui/components/text/NumberRollMotion.kt | 12 +- .../components/text/AnimatedNumberTextTest.kt | 47 +++++ .../components/text/NumberRollMotionTest.kt | 4 +- 4 files changed, 191 insertions(+), 46 deletions(-) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt index 19225a408..775b47685 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt @@ -2,12 +2,12 @@ package com.getcode.ui.components.text import android.os.Build import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.FloatSpringSpec import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.material.LocalTextStyle import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -20,10 +20,10 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.GraphicsLayerScope @@ -37,6 +37,7 @@ import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.getcode.ui.utils.AutoSizeTextMeasurer import com.getcode.ui.utils.ConstraintMode @@ -49,7 +50,7 @@ import kotlin.math.roundToInt * A number whose digits roll like SwiftUI's `.contentTransition(.numericText())`. * * Characters are slots keyed by role (see [numberSlots]), so a longer or shorter string keeps each - * digit in its own slot. A digit slot's transition is an [Animatable] spring that is retargeted, + * digit in its own slot. A digit slot's transition is a spring stepped every frame and retargeted, * keeping its offset and velocity, whenever the digit changes again mid-roll, so a value that moves * every frame glides instead of restarting. Digits share the widest digit's width; symbols and * suffixes stay still unless they change; a slot appearing or disappearing animates its width. @@ -189,10 +190,11 @@ private fun RollingNumber( layout.absorb(chars) Row(verticalAlignment = Alignment.CenterVertically) { - for (slotKey in layout.keys()) { + for ((index, slotKey) in layout.keys().withIndex()) { key(slotKey) { NumberSlot( char = chars[slotKey], + staggerIndex = index, rising = direction.rising, digitWidthPx = digitWidthPx, style = style, @@ -235,6 +237,7 @@ private class SlotLayout { @Composable private fun NumberSlot( char: Char?, + staggerIndex: Int, rising: Boolean, digitWidthPx: Int, style: TextStyle, @@ -260,7 +263,7 @@ private fun NumberSlot( val inDigit = remember { mutableIntStateOf(initial) } val startOffset = remember { mutableFloatStateOf(0f) } val dir = remember { mutableFloatStateOf(1f) } - val progress = remember { Animatable(1f) } + val motion = remember { RollMotion() } val track = remember { SlotTrack(if (isDigit) shownChar.digitToInt() else -1) } LaunchedEffect(char) { if (char == null || !char.isDigit()) { @@ -271,33 +274,46 @@ private fun NumberSlot( if (track.digit < 0) { outDigit.intValue = next inDigit.intValue = next - progress.snapTo(1f) + motion.settle() } else if (next != inDigit.intValue) { - val p = progress.value - val vp = progress.velocity + // numericText starts its characters left to right, about a frame apart, not all at once. + // Only a settled slot waits: a slot already rolling (or already waiting) keeps its start + // time, so a scrub that changes it every frame doesn't keep pushing the start back. + if (!motion.active) motion.delayNanos = staggerIndex * StaggerNanos + val p = motion.progress + val vp = motion.velocity val dOut = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p val dIn = dir.floatValue * (1f - p) val inDominant = abs(dIn) <= abs(dOut) + val newDir = if (rising) 1f else -1f + if (motion.active && !inDominant && newDir == dir.floatValue) { + // The outgoing glyph is still the one showing: let it keep leaving at its own pace + // and swap in the newest digit. Restarting the roll from its offset would start it + // near rest, so a fast scrub would hold the old digit in place. + inDigit.intValue = next + track.digit = next + return@LaunchedEffect + } val d0 = if (inDominant) dIn else dOut val vDisp = if (inDominant) -dir.floatValue * vp else vp * (-dir.floatValue - startOffset.floatValue) outDigit.intValue = if (inDominant) inDigit.intValue else outDigit.intValue inDigit.intValue = next startOffset.floatValue = d0 - dir.floatValue = if (rising) 1f else -1f + dir.floatValue = newDir val denom = -dir.floatValue - d0 - val v0 = if (abs(denom) > 0.3f) vDisp / denom else 0f - progress.snapTo(0f) - progress.animateTo( - 1f, - spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 380f), - initialVelocity = v0, - ) + motion.retarget(velocity = if (abs(denom) > 0.3f) vDisp / denom else 0f) } track.digit = next } + // A change only retargets [motion]; this loop steps it every frame. Restarting an animation per + // change instead would cost a frame and the velocity each time, so a scrub that changes the value + // every frame would hold the old digit in place. + LaunchedEffect(motion) { + snapshotFlow { motion.kicks }.collect { motion.run(RollSpring) } + } // The outgoing glyph is invisible from OutgoingFadeEnd on, so it leaves the tree there: a settled // digit is one node, and TalkBack doesn't read the number twice. - val outgoingMounted by remember { derivedStateOf { progress.value < OutgoingFadeEnd } } + val outgoingMounted by remember { derivedStateOf { motion.progress < OutgoingFadeEnd } } val fade = remember { Animatable(1f) } LaunchedEffect(char) { @@ -315,6 +331,11 @@ private fun NumberSlot( Box( modifier = Modifier + // Clip only while the slot is growing or shrinking. A settled slot leaves its glyphs + // unclipped, so a digit's blur and overshoot spill past its cell the way numericText's do. + // The layer sits outside the layout below so it takes the shown width; inside, it would + // take the glyph's full width and a growing digit would draw over its neighbours. + .graphicsLayer { clip = presence.value < 1f } .layout { measurable, _ -> val p = measurable.measure(Constraints()) val w = if (isDigit) digitWidthPx else p.width @@ -323,7 +344,6 @@ private fun NumberSlot( p.place((shownWidth - p.width) / 2, 0) } } - .clipToBounds() .then(if (char == null) Modifier.clearAndSetSemantics { } else Modifier), contentAlignment = Alignment.Center, ) { @@ -331,12 +351,13 @@ private fun NumberSlot( if (outgoingMounted) { Text( modifier = Modifier - .digitCell(digitWidthPx) + .digitCell(digitWidthPx, bleed = MaxBlur) .graphicsLayer { - val p = progress.value + val p = motion.progress val d = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p applyGlyphMotion(d, outgoingFade(p), p, presence.value) - }, + } + .padding(MaxBlur), text = outDigit.intValue.toString(), style = style, color = color, @@ -344,20 +365,23 @@ private fun NumberSlot( softWrap = false, ) } - Text( - modifier = Modifier - .digitCell(digitWidthPx) - .graphicsLayer { - val p = progress.value - val d = dir.floatValue * (1f - p) - applyGlyphMotion(d, incomingFade(p), p, presence.value) - }, - text = inDigit.intValue.toString(), - style = style, - color = color, - maxLines = 1, - softWrap = false, - ) + if (!motion.waiting) { + Text( + modifier = Modifier + .digitCell(digitWidthPx, bleed = MaxBlur) + .graphicsLayer { + val p = motion.progress + val d = dir.floatValue * (1f - p) + applyGlyphMotion(d, incomingFade(p), p, presence.value) + } + .padding(MaxBlur), + text = inDigit.intValue.toString(), + style = style, + color = color, + maxLines = 1, + softWrap = false, + ) + } } else { Text( modifier = Modifier.graphicsLayer { alpha = fade.value * presence.value }, @@ -371,11 +395,17 @@ private fun NumberSlot( } } -/** Lays a glyph out [widthPx] wide, centred, so every digit takes the same room. */ -private fun Modifier.digitCell(widthPx: Int): Modifier = layout { measurable, _ -> +/** + * Lays a glyph out [widthPx] wide, centred, so every digit takes the same room. [bleed] is padding the + * content carries on every side that should not count towards the cell's size: a glyph's blur layer is + * padded by the blur radius because Android renders a `RenderEffect` into a layer the size of its + * node, so without the room a blurred glyph is cut off square at its own edges. + */ +private fun Modifier.digitCell(widthPx: Int, bleed: Dp = 0.dp): Modifier = layout { measurable, _ -> val p = measurable.measure(Constraints()) - layout(widthPx, p.height) { - p.place((widthPx - p.width) / 2, 0) + val bleedPx = bleed.roundToPx() + layout(widthPx, p.height - 2 * bleedPx) { + p.place((widthPx - p.width) / 2, -bleedPx) } } @@ -385,7 +415,8 @@ private fun Modifier.digitCell(widthPx: Int): Modifier = layout { measurable, _ */ private fun GraphicsLayerScope.applyGlyphMotion(d: Float, fade: Float, p: Float, presence: Float) { val motion = glyphMotion(d, fade, p, presence) - translationY = motion.travel * size.height + // The layer carries [MaxBlur] of padding on each side; travel is in heights of the glyph itself. + translationY = motion.travel * (size.height - 2 * MaxBlur.toPx()) alpha = motion.alpha if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val radius = MaxBlur.toPx() * motion.blur @@ -418,4 +449,65 @@ private class CharHolder(var value: Char) private class SlotTrack(var digit: Int, var symbol: Char? = null) +// Stiffness matches iOS's per-digit roll time measured off a 60fps recording of the chart readout. +// The damping is SwiftUI's `.snappy` (bounce 0.15), which that readout uses: a digit passes its rest +// slightly and settles back. +private val RollSpring = FloatSpringSpec(dampingRatio = 0.85f, stiffness = 380f) + +/** A digit slot's roll: progress from the outgoing glyph (0) to the incoming one (1), stepped by [run]. */ +private class RollMotion { + var progress by mutableFloatStateOf(1f) + var velocity = 0f + var active = false + var delayNanos = 0L + private var startAtNanos = -1L + + /** True while a roll is waiting out its stagger delay; the incoming glyph isn't drawn yet. */ + var waiting by mutableStateOf(false) + + /** Bumped on every [retarget], so [run] restarts after it has settled. */ + var kicks by mutableIntStateOf(0) + + fun settle() { + progress = 1f + velocity = 0f + active = false + waiting = false + delayNanos = 0L + startAtNanos = -1L + } + + fun retarget(velocity: Float) { + progress = 0f + this.velocity = velocity + if (!active) { + active = true + waiting = delayNanos > 0 + kicks++ + } + } + + suspend fun run(spec: FloatSpringSpec) { + var last = -1L + while (active) { + withFrameNanos { now -> + if (startAtNanos < 0) startAtNanos = now + delayNanos + if (now < startAtNanos || last < 0) { + last = now + return@withFrameNanos + } + waiting = false + val dt = now - last + last = now + val p = spec.getValueFromNanos(dt, progress, 1f, velocity) + val v = spec.getVelocityFromNanos(dt, progress, 1f, velocity) + if (abs(1f - p) < 0.001f && abs(v) < 0.01f) settle() else { + progress = p + velocity = v + } + } + } + } +} + private class DirectionHolder(var last: String, var rising: Boolean = true) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt index 3989df4ca..7a953d9ce 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt @@ -52,13 +52,19 @@ internal fun glyphMotion(d: Float, fade: Float, p: Float, presence: Float): Glyp /** The outgoing glyph is fully faded by p = [OutgoingFadeEnd]. */ internal fun outgoingFade(p: Float): Float = 1f - (p / OutgoingFadeEnd).coerceIn(0f, 1f) -internal const val OutgoingFadeEnd = 0.6f +internal const val OutgoingFadeEnd = 0.7f -/** The incoming glyph starts appearing at p = 0.4. */ -internal fun incomingFade(p: Float): Float = ((p - 0.4f) / 0.6f).coerceIn(0f, 1f) +/** + * The incoming glyph fades in over p = 0.2..0.9, overlapping the outgoing fade so mid-roll both glyphs + * show as dim smudges, as on iOS, instead of the slot going blank. + */ +internal fun incomingFade(p: Float): Float = ((p - 0.2f) / 0.7f).coerceIn(0f, 1f) internal const val MaxTravel = 0.3f +/** How much later each slot starts than the one to its left, measured off iOS's `.numericText()` at 60fps. */ +internal const val StaggerNanos = 12_000_000L + /** * The number a formatted readout stands for, enough to tell which way it moved: `$22.7K` is 22,700 * and `-$41.67` is below `-$44.02`. diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt index 1081018f2..894b8aff8 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt @@ -4,6 +4,7 @@ import androidx.activity.ComponentActivity import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import org.junit.Rule @@ -52,6 +53,52 @@ class AnimatedNumberTextTest { @Test fun `a later change rolls`() = assertRolls(from = "0", to = "2", changesBefore = 1) + /** + * Like numericText, the digits start left to right rather than all in one frame: going from `00` + * to `11`, there is a frame where only the left digit has started. + */ + @Test + fun `digits start left to right`() { + var value by mutableStateOf("00") + composeRule.mainClock.autoAdvance = false + composeRule.setContent { AnimatedNumberText(value = value) } + composeRule.mainClock.advanceTimeByFrame() + + composeRule.runOnUiThread { value = "11" } + + var staggered = false + repeat(FramesPerRoll) { + composeRule.mainClock.advanceTimeByFrame() + if (composeRule.onAllNodesWithText("1").fetchSemanticsNodes().size == 1) staggered = true + } + assertTrue(staggered, "both digits started in the same frame") + assertEquals(2, composeRule.onAllNodesWithText("1").fetchSemanticsNodes().size) + } + + /** + * A fast scrub changes the value every frame. The digit that was showing must still roll out + * rather than holding in place while each change restarts its roll. + */ + @Test + fun `a digit changing every frame still rolls out`() { + var value by mutableStateOf("0") + composeRule.mainClock.autoAdvance = false + composeRule.setContent { AnimatedNumberText(value = value) } + composeRule.mainClock.advanceTimeByFrame() + + repeat(FramesPerRoll / 2) { frame -> + composeRule.runOnUiThread { + value = "${frame % 9 + 1}" + Snapshot.sendApplyNotifications() + } + composeRule.mainClock.advanceTimeByFrame() + } + assertTrue( + composeRule.onAllNodesWithText("0").fetchSemanticsNodes().isEmpty(), + "the starting digit is still showing after ${FramesPerRoll / 2} frames of changes", + ) + } + private fun assertRolls(from: String, to: String, changesBefore: Int) { var value by mutableStateOf(from) composeRule.mainClock.autoAdvance = false diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt index b0a0e82f2..dc1e8ef74 100644 --- a/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/text/NumberRollMotionTest.kt @@ -82,10 +82,10 @@ class NumberRollMotionTest { @Test fun `outgoing glyph is gone by p 0_6 and incoming starts at p 0_4`() { assertEquals(1f, outgoingFade(0f), 1e-6f) - assertEquals(0f, outgoingFade(0.6f), 1e-6f) + assertEquals(0f, outgoingFade(0.7f), 1e-6f) assertEquals(0f, outgoingFade(1f), 1e-6f) assertEquals(0f, incomingFade(0f), 1e-6f) - assertEquals(0f, incomingFade(0.4f), 1e-6f) + assertEquals(0f, incomingFade(0.2f), 1e-6f) assertEquals(1f, incomingFade(1f), 1e-6f) }