@@ -2,12 +2,12 @@ package com.getcode.ui.components.text
22
33import android.os.Build
44import 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
76import androidx.compose.animation.core.tween
87import androidx.compose.foundation.layout.Box
98import androidx.compose.foundation.layout.BoxWithConstraints
109import androidx.compose.foundation.layout.Row
10+ import androidx.compose.foundation.layout.padding
1111import androidx.compose.material.LocalTextStyle
1212import androidx.compose.material.Text
1313import androidx.compose.runtime.Composable
@@ -20,10 +20,10 @@ import androidx.compose.runtime.mutableIntStateOf
2020import androidx.compose.runtime.mutableStateOf
2121import androidx.compose.runtime.remember
2222import androidx.compose.runtime.setValue
23+ import androidx.compose.runtime.snapshotFlow
2324import androidx.compose.runtime.withFrameNanos
2425import androidx.compose.ui.Alignment
2526import androidx.compose.ui.Modifier
26- import androidx.compose.ui.draw.clipToBounds
2727import androidx.compose.ui.graphics.BlurEffect
2828import androidx.compose.ui.graphics.Color
2929import androidx.compose.ui.graphics.GraphicsLayerScope
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.TextMeasurer
3737import androidx.compose.ui.text.TextStyle
3838import androidx.compose.ui.text.rememberTextMeasurer
3939import androidx.compose.ui.unit.Constraints
40+ import androidx.compose.ui.unit.Dp
4041import androidx.compose.ui.unit.dp
4142import com.getcode.ui.utils.AutoSizeTextMeasurer
4243import 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
236238private 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 */
386416private 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
419450private 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+
421513private class DirectionHolder (var last : String , var rising : Boolean = true )
0 commit comments