Skip to content

Commit d36f66b

Browse files
authored
fix(ui): match the iOS number roll in AnimatedNumberText (#1662)
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.
1 parent a1e40d5 commit d36f66b

4 files changed

Lines changed: 191 additions & 46 deletions

File tree

‎ui/components/src/main/kotlin/com/getcode/ui/components/text/AnimatedNumberText.kt‎

Lines changed: 133 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ package com.getcode.ui.components.text
22

33
import android.os.Build
44
import androidx.compose.animation.core.Animatable
5-
import androidx.compose.animation.core.Spring
6-
import androidx.compose.animation.core.spring
5+
import androidx.compose.animation.core.FloatSpringSpec
76
import androidx.compose.animation.core.tween
87
import androidx.compose.foundation.layout.Box
98
import androidx.compose.foundation.layout.BoxWithConstraints
109
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.padding
1111
import androidx.compose.material.LocalTextStyle
1212
import androidx.compose.material.Text
1313
import androidx.compose.runtime.Composable
@@ -20,10 +20,10 @@ import androidx.compose.runtime.mutableIntStateOf
2020
import androidx.compose.runtime.mutableStateOf
2121
import androidx.compose.runtime.remember
2222
import androidx.compose.runtime.setValue
23+
import androidx.compose.runtime.snapshotFlow
2324
import androidx.compose.runtime.withFrameNanos
2425
import androidx.compose.ui.Alignment
2526
import androidx.compose.ui.Modifier
26-
import androidx.compose.ui.draw.clipToBounds
2727
import androidx.compose.ui.graphics.BlurEffect
2828
import androidx.compose.ui.graphics.Color
2929
import androidx.compose.ui.graphics.GraphicsLayerScope
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.TextMeasurer
3737
import androidx.compose.ui.text.TextStyle
3838
import androidx.compose.ui.text.rememberTextMeasurer
3939
import androidx.compose.ui.unit.Constraints
40+
import androidx.compose.ui.unit.Dp
4041
import androidx.compose.ui.unit.dp
4142
import com.getcode.ui.utils.AutoSizeTextMeasurer
4243
import com.getcode.ui.utils.ConstraintMode
@@ -49,7 +50,7 @@ import kotlin.math.roundToInt
4950
* A number whose digits roll like SwiftUI's `.contentTransition(.numericText())`.
5051
*
5152
* Characters are slots keyed by role (see [numberSlots]), so a longer or shorter string keeps each
52-
* digit in its own slot. A digit slot's transition is an [Animatable] spring that is retargeted,
53+
* digit in its own slot. A digit slot's transition is a spring stepped every frame and retargeted,
5354
* keeping its offset and velocity, whenever the digit changes again mid-roll, so a value that moves
5455
* every frame glides instead of restarting. Digits share the widest digit's width; symbols and
5556
* suffixes stay still unless they change; a slot appearing or disappearing animates its width.
@@ -189,10 +190,11 @@ private fun RollingNumber(
189190
layout.absorb(chars)
190191

191192
Row(verticalAlignment = Alignment.CenterVertically) {
192-
for (slotKey in layout.keys()) {
193+
for ((index, slotKey) in layout.keys().withIndex()) {
193194
key(slotKey) {
194195
NumberSlot(
195196
char = chars[slotKey],
197+
staggerIndex = index,
196198
rising = direction.rising,
197199
digitWidthPx = digitWidthPx,
198200
style = style,
@@ -235,6 +237,7 @@ private class SlotLayout {
235237
@Composable
236238
private fun NumberSlot(
237239
char: Char?,
240+
staggerIndex: Int,
238241
rising: Boolean,
239242
digitWidthPx: Int,
240243
style: TextStyle,
@@ -260,7 +263,7 @@ private fun NumberSlot(
260263
val inDigit = remember { mutableIntStateOf(initial) }
261264
val startOffset = remember { mutableFloatStateOf(0f) }
262265
val dir = remember { mutableFloatStateOf(1f) }
263-
val progress = remember { Animatable(1f) }
266+
val motion = remember { RollMotion() }
264267
val track = remember { SlotTrack(if (isDigit) shownChar.digitToInt() else -1) }
265268
LaunchedEffect(char) {
266269
if (char == null || !char.isDigit()) {
@@ -271,33 +274,46 @@ private fun NumberSlot(
271274
if (track.digit < 0) {
272275
outDigit.intValue = next
273276
inDigit.intValue = next
274-
progress.snapTo(1f)
277+
motion.settle()
275278
} else if (next != inDigit.intValue) {
276-
val p = progress.value
277-
val vp = progress.velocity
279+
// numericText starts its characters left to right, about a frame apart, not all at once.
280+
// Only a settled slot waits: a slot already rolling (or already waiting) keeps its start
281+
// time, so a scrub that changes it every frame doesn't keep pushing the start back.
282+
if (!motion.active) motion.delayNanos = staggerIndex * StaggerNanos
283+
val p = motion.progress
284+
val vp = motion.velocity
278285
val dOut = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p
279286
val dIn = dir.floatValue * (1f - p)
280287
val inDominant = abs(dIn) <= abs(dOut)
288+
val newDir = if (rising) 1f else -1f
289+
if (motion.active && !inDominant && newDir == dir.floatValue) {
290+
// The outgoing glyph is still the one showing: let it keep leaving at its own pace
291+
// and swap in the newest digit. Restarting the roll from its offset would start it
292+
// near rest, so a fast scrub would hold the old digit in place.
293+
inDigit.intValue = next
294+
track.digit = next
295+
return@LaunchedEffect
296+
}
281297
val d0 = if (inDominant) dIn else dOut
282298
val vDisp = if (inDominant) -dir.floatValue * vp else vp * (-dir.floatValue - startOffset.floatValue)
283299
outDigit.intValue = if (inDominant) inDigit.intValue else outDigit.intValue
284300
inDigit.intValue = next
285301
startOffset.floatValue = d0
286-
dir.floatValue = if (rising) 1f else -1f
302+
dir.floatValue = newDir
287303
val denom = -dir.floatValue - d0
288-
val v0 = if (abs(denom) > 0.3f) vDisp / denom else 0f
289-
progress.snapTo(0f)
290-
progress.animateTo(
291-
1f,
292-
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 380f),
293-
initialVelocity = v0,
294-
)
304+
motion.retarget(velocity = if (abs(denom) > 0.3f) vDisp / denom else 0f)
295305
}
296306
track.digit = next
297307
}
308+
// A change only retargets [motion]; this loop steps it every frame. Restarting an animation per
309+
// change instead would cost a frame and the velocity each time, so a scrub that changes the value
310+
// every frame would hold the old digit in place.
311+
LaunchedEffect(motion) {
312+
snapshotFlow { motion.kicks }.collect { motion.run(RollSpring) }
313+
}
298314
// The outgoing glyph is invisible from OutgoingFadeEnd on, so it leaves the tree there: a settled
299315
// digit is one node, and TalkBack doesn't read the number twice.
300-
val outgoingMounted by remember { derivedStateOf { progress.value < OutgoingFadeEnd } }
316+
val outgoingMounted by remember { derivedStateOf { motion.progress < OutgoingFadeEnd } }
301317

302318
val fade = remember { Animatable(1f) }
303319
LaunchedEffect(char) {
@@ -315,6 +331,11 @@ private fun NumberSlot(
315331

316332
Box(
317333
modifier = Modifier
334+
// Clip only while the slot is growing or shrinking. A settled slot leaves its glyphs
335+
// unclipped, so a digit's blur and overshoot spill past its cell the way numericText's do.
336+
// The layer sits outside the layout below so it takes the shown width; inside, it would
337+
// take the glyph's full width and a growing digit would draw over its neighbours.
338+
.graphicsLayer { clip = presence.value < 1f }
318339
.layout { measurable, _ ->
319340
val p = measurable.measure(Constraints())
320341
val w = if (isDigit) digitWidthPx else p.width
@@ -323,41 +344,44 @@ private fun NumberSlot(
323344
p.place((shownWidth - p.width) / 2, 0)
324345
}
325346
}
326-
.clipToBounds()
327347
.then(if (char == null) Modifier.clearAndSetSemantics { } else Modifier),
328348
contentAlignment = Alignment.Center,
329349
) {
330350
if (isDigit) {
331351
if (outgoingMounted) {
332352
Text(
333353
modifier = Modifier
334-
.digitCell(digitWidthPx)
354+
.digitCell(digitWidthPx, bleed = MaxBlur)
335355
.graphicsLayer {
336-
val p = progress.value
356+
val p = motion.progress
337357
val d = startOffset.floatValue + (-dir.floatValue - startOffset.floatValue) * p
338358
applyGlyphMotion(d, outgoingFade(p), p, presence.value)
339-
},
359+
}
360+
.padding(MaxBlur),
340361
text = outDigit.intValue.toString(),
341362
style = style,
342363
color = color,
343364
maxLines = 1,
344365
softWrap = false,
345366
)
346367
}
347-
Text(
348-
modifier = Modifier
349-
.digitCell(digitWidthPx)
350-
.graphicsLayer {
351-
val p = progress.value
352-
val d = dir.floatValue * (1f - p)
353-
applyGlyphMotion(d, incomingFade(p), p, presence.value)
354-
},
355-
text = inDigit.intValue.toString(),
356-
style = style,
357-
color = color,
358-
maxLines = 1,
359-
softWrap = false,
360-
)
368+
if (!motion.waiting) {
369+
Text(
370+
modifier = Modifier
371+
.digitCell(digitWidthPx, bleed = MaxBlur)
372+
.graphicsLayer {
373+
val p = motion.progress
374+
val d = dir.floatValue * (1f - p)
375+
applyGlyphMotion(d, incomingFade(p), p, presence.value)
376+
}
377+
.padding(MaxBlur),
378+
text = inDigit.intValue.toString(),
379+
style = style,
380+
color = color,
381+
maxLines = 1,
382+
softWrap = false,
383+
)
384+
}
361385
} else {
362386
Text(
363387
modifier = Modifier.graphicsLayer { alpha = fade.value * presence.value },
@@ -371,11 +395,17 @@ private fun NumberSlot(
371395
}
372396
}
373397

374-
/** Lays a glyph out [widthPx] wide, centred, so every digit takes the same room. */
375-
private fun Modifier.digitCell(widthPx: Int): Modifier = layout { measurable, _ ->
398+
/**
399+
* Lays a glyph out [widthPx] wide, centred, so every digit takes the same room. [bleed] is padding the
400+
* content carries on every side that should not count towards the cell's size: a glyph's blur layer is
401+
* padded by the blur radius because Android renders a `RenderEffect` into a layer the size of its
402+
* node, so without the room a blurred glyph is cut off square at its own edges.
403+
*/
404+
private fun Modifier.digitCell(widthPx: Int, bleed: Dp = 0.dp): Modifier = layout { measurable, _ ->
376405
val p = measurable.measure(Constraints())
377-
layout(widthPx, p.height) {
378-
p.place((widthPx - p.width) / 2, 0)
406+
val bleedPx = bleed.roundToPx()
407+
layout(widthPx, p.height - 2 * bleedPx) {
408+
p.place((widthPx - p.width) / 2, -bleedPx)
379409
}
380410
}
381411

@@ -385,7 +415,8 @@ private fun Modifier.digitCell(widthPx: Int): Modifier = layout { measurable, _
385415
*/
386416
private fun GraphicsLayerScope.applyGlyphMotion(d: Float, fade: Float, p: Float, presence: Float) {
387417
val motion = glyphMotion(d, fade, p, presence)
388-
translationY = motion.travel * size.height
418+
// The layer carries [MaxBlur] of padding on each side; travel is in heights of the glyph itself.
419+
translationY = motion.travel * (size.height - 2 * MaxBlur.toPx())
389420
alpha = motion.alpha
390421
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
391422
val radius = MaxBlur.toPx() * motion.blur
@@ -418,4 +449,65 @@ private class CharHolder(var value: Char)
418449

419450
private class SlotTrack(var digit: Int, var symbol: Char? = null)
420451

452+
// Stiffness matches iOS's per-digit roll time measured off a 60fps recording of the chart readout.
453+
// The damping is SwiftUI's `.snappy` (bounce 0.15), which that readout uses: a digit passes its rest
454+
// slightly and settles back.
455+
private val RollSpring = FloatSpringSpec(dampingRatio = 0.85f, stiffness = 380f)
456+
457+
/** A digit slot's roll: progress from the outgoing glyph (0) to the incoming one (1), stepped by [run]. */
458+
private class RollMotion {
459+
var progress by mutableFloatStateOf(1f)
460+
var velocity = 0f
461+
var active = false
462+
var delayNanos = 0L
463+
private var startAtNanos = -1L
464+
465+
/** True while a roll is waiting out its stagger delay; the incoming glyph isn't drawn yet. */
466+
var waiting by mutableStateOf(false)
467+
468+
/** Bumped on every [retarget], so [run] restarts after it has settled. */
469+
var kicks by mutableIntStateOf(0)
470+
471+
fun settle() {
472+
progress = 1f
473+
velocity = 0f
474+
active = false
475+
waiting = false
476+
delayNanos = 0L
477+
startAtNanos = -1L
478+
}
479+
480+
fun retarget(velocity: Float) {
481+
progress = 0f
482+
this.velocity = velocity
483+
if (!active) {
484+
active = true
485+
waiting = delayNanos > 0
486+
kicks++
487+
}
488+
}
489+
490+
suspend fun run(spec: FloatSpringSpec) {
491+
var last = -1L
492+
while (active) {
493+
withFrameNanos { now ->
494+
if (startAtNanos < 0) startAtNanos = now + delayNanos
495+
if (now < startAtNanos || last < 0) {
496+
last = now
497+
return@withFrameNanos
498+
}
499+
waiting = false
500+
val dt = now - last
501+
last = now
502+
val p = spec.getValueFromNanos(dt, progress, 1f, velocity)
503+
val v = spec.getVelocityFromNanos(dt, progress, 1f, velocity)
504+
if (abs(1f - p) < 0.001f && abs(v) < 0.01f) settle() else {
505+
progress = p
506+
velocity = v
507+
}
508+
}
509+
}
510+
}
511+
}
512+
421513
private class DirectionHolder(var last: String, var rising: Boolean = true)

‎ui/components/src/main/kotlin/com/getcode/ui/components/text/NumberRollMotion.kt‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,19 @@ internal fun glyphMotion(d: Float, fade: Float, p: Float, presence: Float): Glyp
5252
/** The outgoing glyph is fully faded by p = [OutgoingFadeEnd]. */
5353
internal fun outgoingFade(p: Float): Float = 1f - (p / OutgoingFadeEnd).coerceIn(0f, 1f)
5454

55-
internal const val OutgoingFadeEnd = 0.6f
55+
internal const val OutgoingFadeEnd = 0.7f
5656

57-
/** The incoming glyph starts appearing at p = 0.4. */
58-
internal fun incomingFade(p: Float): Float = ((p - 0.4f) / 0.6f).coerceIn(0f, 1f)
57+
/**
58+
* The incoming glyph fades in over p = 0.2..0.9, overlapping the outgoing fade so mid-roll both glyphs
59+
* show as dim smudges, as on iOS, instead of the slot going blank.
60+
*/
61+
internal fun incomingFade(p: Float): Float = ((p - 0.2f) / 0.7f).coerceIn(0f, 1f)
5962

6063
internal const val MaxTravel = 0.3f
6164

65+
/** How much later each slot starts than the one to its left, measured off iOS's `.numericText()` at 60fps. */
66+
internal const val StaggerNanos = 12_000_000L
67+
6268
/**
6369
* The number a formatted readout stands for, enough to tell which way it moved: `$22.7K` is 22,700
6470
* and `-$41.67` is below `-$44.02`.

‎ui/components/src/test/kotlin/com/getcode/ui/components/text/AnimatedNumberTextTest.kt‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.activity.ComponentActivity
44
import androidx.compose.runtime.getValue
55
import androidx.compose.runtime.mutableStateOf
66
import androidx.compose.runtime.setValue
7+
import androidx.compose.runtime.snapshots.Snapshot
78
import androidx.compose.ui.test.junit4.createAndroidComposeRule
89
import androidx.compose.ui.test.onAllNodesWithText
910
import org.junit.Rule
@@ -52,6 +53,52 @@ class AnimatedNumberTextTest {
5253
@Test
5354
fun `a later change rolls`() = assertRolls(from = "0", to = "2", changesBefore = 1)
5455

56+
/**
57+
* Like numericText, the digits start left to right rather than all in one frame: going from `00`
58+
* to `11`, there is a frame where only the left digit has started.
59+
*/
60+
@Test
61+
fun `digits start left to right`() {
62+
var value by mutableStateOf("00")
63+
composeRule.mainClock.autoAdvance = false
64+
composeRule.setContent { AnimatedNumberText(value = value) }
65+
composeRule.mainClock.advanceTimeByFrame()
66+
67+
composeRule.runOnUiThread { value = "11" }
68+
69+
var staggered = false
70+
repeat(FramesPerRoll) {
71+
composeRule.mainClock.advanceTimeByFrame()
72+
if (composeRule.onAllNodesWithText("1").fetchSemanticsNodes().size == 1) staggered = true
73+
}
74+
assertTrue(staggered, "both digits started in the same frame")
75+
assertEquals(2, composeRule.onAllNodesWithText("1").fetchSemanticsNodes().size)
76+
}
77+
78+
/**
79+
* A fast scrub changes the value every frame. The digit that was showing must still roll out
80+
* rather than holding in place while each change restarts its roll.
81+
*/
82+
@Test
83+
fun `a digit changing every frame still rolls out`() {
84+
var value by mutableStateOf("0")
85+
composeRule.mainClock.autoAdvance = false
86+
composeRule.setContent { AnimatedNumberText(value = value) }
87+
composeRule.mainClock.advanceTimeByFrame()
88+
89+
repeat(FramesPerRoll / 2) { frame ->
90+
composeRule.runOnUiThread {
91+
value = "${frame % 9 + 1}"
92+
Snapshot.sendApplyNotifications()
93+
}
94+
composeRule.mainClock.advanceTimeByFrame()
95+
}
96+
assertTrue(
97+
composeRule.onAllNodesWithText("0").fetchSemanticsNodes().isEmpty(),
98+
"the starting digit is still showing after ${FramesPerRoll / 2} frames of changes",
99+
)
100+
}
101+
55102
private fun assertRolls(from: String, to: String, changesBefore: Int) {
56103
var value by mutableStateOf(from)
57104
composeRule.mainClock.autoAdvance = false

0 commit comments

Comments
 (0)