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) }