diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index b9e55abbd..b995b55ad 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) implementation(libs.bundles.haze) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index edf91312a..c207c9e57 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -1,19 +1,15 @@ package com.flipcash.app.menu.internal import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -34,6 +30,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsIgnoringVisibility import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -41,11 +38,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -57,6 +54,8 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -66,6 +65,7 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer @@ -125,16 +125,17 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // Full screen is a state of *this* screen, not a destination: the card grows into the middle of // the display and everything else — rows, footer, tab bar — animates out from under it // (node 9277:121410). Pushing a route would cross-fade a second copy of the card in instead. - var cardExpanded by remember { mutableStateOf(false) } + val expansion = rememberTipCardExpansion() + val cardExpanded = expansion.isExpanded // Only a claimed card expands — the unclaimed stand-in is decoration behind the prompt. val canExpand = isNewUi && state.tipCard != null LaunchedEffect(canExpand) { // Losing the card (a v1 build, or sign-out) must not strand the page expanded. - if (!canExpand) cardExpanded = false + if (!canExpand) expansion.collapse() } HideTabBar(hidden = cardExpanded) - BackHandler(enabled = cardExpanded) { cardExpanded = false } + BackHandler(enabled = cardExpanded) { expansion.collapse() } LaunchedEffect(Unit) { viewModel.eventFlow @@ -184,50 +185,108 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { FullScreenCardWidth, maxWidth - PageHorizontalInset * 2, ) - val cardWidth by animateDpAsState( - targetValue = if (cardExpanded) expandedCardWidth else YouCardWidth, - animationSpec = expansionSpring(Dp.VisibilityThreshold), - label = "tipCardWidth", - ) + // How far into the expansion we are. One progress drives all of it — the card's + // size and position, the page fading out beneath it, the Close row — so a swipe that + // stops part-way is as legal a state as either end, and none of it can drift out of + // step with the rest. + // + // Handed down as a lambda and never read here: every consumer below reads it inside a + // graphicsLayer block, which the frame re-runs on its own when the value changes. + // Reading it in composition instead recomposed this entire page — settings list + // included — on every frame of the animation. + val progress = remember(expansion) { { expansion.progress } } + // The card is scaled, not re-laid-out. Animating its width made every frame re-measure + // the card and everything inside it: a new corner radius to clip to, a new font size + // for the name (a full text re-layout), and a new size for the interop View that draws + // the code, which re-derives the code's geometry from scratch on each draw. That is + // work for the UI thread, and there is more of it than a frame has time for. iOS hit + // the same wall and settled on one scale over a card drawn once: laid out, each of the + // card's metrics is a separate animatable value — and the name's font size is not + // animatable at all — so the parts arrive at their new sizes at different moments and + // overlap mid-flight. Scaled, the card travels as one figure. + // + // The card is drawn at [FullScreenCardWidth], the widest it ever gets, so the scale + // only ever samples the drawing down. + val cardScale = remember(expandedCardWidth) { + { lerp(YouCardWidth, expandedCardWidth, progress()) / FullScreenCardWidth } + } // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as // bottom content padding — the list then scrolls clear of the bar instead of running // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, - // which is only non-zero for tab homes. v1 has no such bar; expanding gives the bar back - // its space because HideTabBar has taken the bar away. - val tabBarInset = LocalTabBarPadding.current.calculateBottomPadding() - val bottomInset by animateDpAsState( - targetValue = if (cardExpanded) 0.dp else tabBarInset, - animationSpec = expansionSpring(Dp.VisibilityThreshold), - label = "tabBarInset", - ) - // How far into the expansion we are: everything but the card fades out on it and slides - // down out of the way, rather than being removed. Keeping the rows in the layout means - // nothing reflows on the way back (iOS does the same with opacity + offset). - val expansion by animateFloatAsState( - targetValue = if (cardExpanded) 1f else 0f, - animationSpec = expansionSpring(), - label = "expansion", - ) - val slideAway = Modifier.graphicsLayer { - // Same overshoot: keep the fade inside a legal alpha range. - alpha = (1f - expansion).coerceIn(0f, 1f) - translationY = ContentSlideDistance.toPx() * expansion + // which is only non-zero for tab homes. v1 has no such bar. + // + // It does not animate away with the expansion. It is content padding, so every frame of + // it relaid the whole list — and by the time the released space could be seen, the list + // has already faded out and slid away under the card. + val bottomInset = LocalTabBarPadding.current.calculateBottomPadding() + // Everything but the card fades out on the expansion and slides down out of the way, + // rather than being removed. Keeping the rows in the layout means nothing reflows on + // the way back (iOS does the same with opacity + offset). + val slideAway = remember(progress) { + Modifier.graphicsLayer { + val fraction = progress() + // A settled flick overshoots its end a little, so keep the fade in a legal + // alpha range rather than assuming the progress is one. + alpha = (1f - fraction).coerceIn(0f, 1f) + translationY = ContentSlideDistance.toPx() * fraction + } } // The card doesn't hand off to a second copy of itself: the one in the list keeps its // slot and is drawn travelling out of it, the way iOS offsets the card from its own // measured frame. Measuring the slot (which never carries the offset) rather than the - // card keeps the measurement out of its own feedback loop, and because the slot grows - // with the card, the card is exactly centred by the time the spring settles. + // card keeps the measurement out of its own feedback loop. + // + // The slot keeps its resting size for the whole expansion: it is what the card scales + // out of, not something that grows with it. A slot that grew would move its own centre + // mid-flight, and that centre is what the shift measures from, so the card would travel + // against itself — a frame behind its own size the whole way. var cardSlotCenterY by remember { mutableFloatStateOf(0f) } val displayCenterY = LocalWindowInfo.current.containerSize.height / 2f - val cardShift = when { - cardSlotCenterY <= 0f -> 0f - else -> (displayCenterY - cardSlotCenterY) * expansion + val cardShift = remember(displayCenterY, expansion) { + { + if (cardSlotCenterY <= 0f) 0f + // The overdrag rides on the card's position alone: pushed down past full + // screen the card has nowhere left to go, so it gives a little where it + // stands while its size and every fade hold where the expansion left them. + else (displayCenterY - cardSlotCenterY) * (progress() + expansion.overdrag) + } } + // Swiping the expanded card back up puts it away. The card's own travel is the + // gesture's travel: expanding walks the card DOWN out of its slot into the middle of + // the display — which is what the "Full Screen" chevron points at, and why "Close" + // points back up — so the way out is up, and a finger that covers the distance the card + // has left to go closes it exactly. The card stays under the finger the whole way and + // springs to whichever end the release picks. + // + // It goes on the page rather than on the card, as iOS's does: expanded, the card IS the + // page, and a pull that has to land on the card exactly is a pull that misses — the + // card is 302dp of a display wider than that, with live margin either side. Nothing + // here scrolls while the card is up (see userScrollEnabled below), so there is no + // scroll for the drag to take events from. + val density = LocalDensity.current + val minTravel = with(density) { MinDragTravel.toPx() } + val flingVelocity = with(density) { MinFlingVelocity.toPx() } + val touchSlop = LocalViewConfiguration.current.touchSlop + // The same distance cardShift moves the card, so the card keeps pace with the finger + // exactly. It holds still for the length of the gesture without having to be pinned: + // the slot it measures from no longer grows with the card. + val dragTravel = (displayCenterY - cardSlotCenterY).coerceAtLeast(minTravel) + val cardDrag = Modifier.draggable( + state = rememberDraggableState { delta -> expansion.dragBy(delta / dragTravel) }, + orientation = Orientation.Vertical, + enabled = cardExpanded, + onDragStarted = { expansion.startDrag(touchSlop / dragTravel) }, + onDragStopped = { velocity -> + expansion.settle(velocity / dragTravel, flingVelocity / dragTravel) + }, + ) + MenuList( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(cardDrag), state = listState, items = state.items, showChevrons = isNewUi, @@ -238,12 +297,12 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { YouHeader( tipCardState = state.tipCardState, enabled = !cardExpanded, - expansion = expansion, + expansion = progress, slideAway = slideAway, - cardWidth = cardWidth, + cardScale = cardScale, cardShift = cardShift, onCardSlotPositioned = { cardSlotCenterY = it }, - onToggleFullScreen = { cardExpanded = !cardExpanded }, + onToggleFullScreen = { expansion.toggle() }, onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, @@ -272,10 +331,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { }, contentPadding = PaddingValues( top = if (isNewUi) restingTop else CodeTheme.dimens.grid.x3, - // Clamped: the spring is underdamped, so it undershoots past the target on the - // way to 0, and PaddingValues throws on a negative — taking the Recomposer, and - // with it the whole UI, down with it. - bottom = bottomInset.coerceAtLeast(0.dp), + bottom = bottomInset, ), onItemClick = { // The faded-out rows are still laid out under the expanded card; don't let them @@ -285,19 +341,26 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { ) // Close sits at the foot of the display rather than under the card (node 9277:121410). - AnimatedVisibility( - visible = cardExpanded, - modifier = Modifier.align(Alignment.BottomCenter), - enter = fadeIn(expansionSpring()), - exit = fadeOut(expansionSpring()), - ) { + // It fades on the same progress as everything else rather than on a transition of its + // own, so a swipe held half-way leaves it half-faded instead of fully drawn. + // Gated on a derived boolean rather than on the progress itself: the row has to leave + // the layout when the card is down (nothing invisible left at the foot of the page for + // a tap or TalkBack to find), but reading the progress here would recompose the page + // every frame. Derived, it only recomposes when the answer flips. + val closeVisible by remember { derivedStateOf { expansion.progress > 0f } } + if (closeVisible) { FullScreenToggle( label = stringResource(R.string.action_closeFullScreen), chevronRotation = 180f, modifier = Modifier + .align(Alignment.BottomCenter) + // Drawn over the list, so it needs the pull too — a hand reaching for + // "Close" and pulling instead is the likeliest place to start one. + .then(cardDrag) + .graphicsLayer { alpha = progress().coerceIn(0f, 1f) } .navigationBarsPadding() .padding(bottom = CloseBottomSpacing) - .noRippleClickable { cardExpanded = false }, + .noRippleClickable(enabled = cardExpanded) { expansion.collapse() }, ) } } @@ -330,15 +393,16 @@ private val VersionFooterTopSpacing = 44.dp private val ContentSlideDistance = 60.dp /** - * The whole expansion — card size, card position, the content sliding away, the Close row — runs on - * one spring, as iOS does: `.spring(response: 0.45, dampingFraction: 0.85)`. SwiftUI's `response` is - * the undamped period, so the equivalent Compose stiffness is `(2 * PI / 0.45) ^ 2`. + * How much of the expansion the "Full Screen" caption has to be gone within — a fraction of the + * progress, not of the duration, so a slow drag fades it on exactly the terms a spring does. + * + * It is short because the card is chasing it. The card's lower edge travels down from the slot at + * the sum of its own growth and its shift to the middle of the display — about 224dp of travel per + * unit of progress on a 1080x2424 screen, and more on a taller one — against the 24dp of clearance + * between the two. So the card is over this spot by a tenth of the way out, and the caption cannot + * still be legible when it gets there. */ -private fun expansionSpring(visibilityThreshold: T? = null) = spring( - dampingRatio = 0.85f, - stiffness = 195f, - visibilityThreshold = visibilityThreshold, -) +private const val CaptionFadeTravel = 0.08f /** * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance, @@ -352,10 +416,10 @@ private fun expansionSpring(visibilityThreshold: T? = null) = spring( private fun YouHeader( tipCardState: TipCardState, enabled: Boolean, - expansion: Float, + expansion: () -> Float, slideAway: Modifier, - cardWidth: Dp, - cardShift: Float, + cardScale: () -> Float, + cardShift: () -> Float, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -367,7 +431,8 @@ private fun YouHeader( TipCardState.Unknown -> Unit is TipCardState.Unclaimed -> UnclaimedTipCardPrompt( placeholder = tipCardState.placeholder, - cardWidth = cardWidth, + // An unclaimed stand-in never expands, so it is only ever the resting card. + cardWidth = YouCardWidth, enabled = enabled, onClaim = onClaim, ) @@ -377,7 +442,7 @@ private fun YouHeader( enabled = enabled, expansion = expansion, slideAway = slideAway, - cardWidth = cardWidth, + cardScale = cardScale, cardShift = cardShift, onCardSlotPositioned = onCardSlotPositioned, onToggleFullScreen = onToggleFullScreen, @@ -391,10 +456,13 @@ private fun YouHeader( /** * The claimed card and everything that hangs off it. * - * The caller drives the full-screen state: it sizes the card ([cardWidth]) and draws it out of its + * The caller drives the full-screen state: it scales the card ([cardScale]) and draws it out of its * slot towards the middle of the display ([cardShift], off the slot position reported by * [onCardSlotPositioned]). It also hands down [slideAway] — the fade-and-slide every non-card * element shares — plus [expansion] for the caption, which iOS fades in place rather than sliding. + * + * All four arrive as lambdas so they are read inside the graphics layers that use them, off the + * composition. Read as values, the whole page would recompose on every frame of the animation. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -402,10 +470,10 @@ private fun ClaimedTipCard( card: Scannable.TipCard, link: String?, enabled: Boolean, - expansion: Float, + expansion: () -> Float, slideAway: Modifier, - cardWidth: Dp, - cardShift: Float, + cardScale: () -> Float, + cardShift: () -> Float, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -428,15 +496,31 @@ private fun ClaimedTipCard( // list's content padding already owns that clearance, so consume the inset // rather than paying it twice. .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility) + // The slot is the card at rest, pinned: the card grows by scaling out of it, + // so the slot's centre — which cardShift measures from — has to hold still. + .size(YouCardWidth, YouCardWidth * TipCardAspectRatio) .onGloballyPositioned { onCardSlotPositioned(it.boundsInRoot().center.y) }, contentAlignment = Alignment.Center, ) { Box( modifier = Modifier - .graphicsLayer { translationY = cardShift } + // Measured unbounded so the card can be drawn at its full width inside the + // smaller slot; the slot's constraints would otherwise squeeze it back down + // to the resting size and there would be nothing to scale up to. + .wrapContentSize(unbounded = true) + // Ahead of the gesture modifiers, so the drag and the tap travel with the + // card rather than staying behind at the slot it left. Both reads happen + // here rather than in composition: the layer re-runs this block by itself + // when they change, which is a render-node transform and no relayout. + .graphicsLayer { + val scale = cardScale() + scaleX = scale + scaleY = scale + translationY = cardShift() + } .noRippleClickable { onToggleFullScreen() }, ) { - ScannableRenderer(scannable = card, tipCardWidth = cardWidth) + ScannableRenderer(scannable = card, tipCardWidth = FullScreenCardWidth) } } } @@ -444,12 +528,15 @@ private fun ClaimedTipCard( Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) // The caption belongs to the card, so it fades where it stands instead of sliding off with - // the rest of the page. + // the rest of the page — and it is gone well before the card is over it. The caption is a + // later sibling than the card and so paints on top of it, while the card grows and travels + // down across this very spot; faded over the whole expansion it would still be legible at + // the point it ends up printed across the card's face. See [CaptionFadeTravel]. FullScreenToggle( label = stringResource(R.string.action_viewFullScreen), chevronRotation = 0f, modifier = Modifier - .graphicsLayer { alpha = (1f - expansion).coerceIn(0f, 1f) } + .graphicsLayer { alpha = (1f - expansion() / CaptionFadeTravel).coerceIn(0f, 1f) } .noRippleClickable { onToggleFullScreen() }, ) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt new file mode 100644 index 000000000..4dd06b4f4 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt @@ -0,0 +1,213 @@ +package com.flipcash.app.menu.internal + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.sign + +/** + * The "You" tab's full-screen state, as one continuous 0..1 progress rather than a boolean. + * + * Everything the expansion touches — the card's width, how far it has travelled out of its slot, + * the page fading away beneath it, the Close row — is a reading of [progress], so a gesture can + * park the whole page part-way through the transition and hand it back where it found it. The tap + * affordances just drive the same progress to an end with a spring. + * + * [isExpanded] is the *intent*, not the position: it flips the moment a direction is committed to + * (tap, or the release of a swipe) and stays put for the length of a drag, so the tab bar and the + * list's scrolling don't flicker in and out as the finger crosses the half-way mark. + */ +@Stable +internal class TipCardExpansion(private val scope: CoroutineScope) { + + private val animatable = Animatable(0f) + + private val overdragAnimatable = Animatable(0f) + + /** 0 at rest, 1 full screen; anywhere in between while dragging or springing. */ + val progress: Float get() = animatable.value + + /** + * How far a downward drag has pushed the card past full screen, in the same units as + * [progress] — resisted by [OverdragResistance], and deliberately not part of the progress + * itself. Down is where expanding already put the card, so there is nothing there to scrub: + * only the card's position gives, and its size and everything reading the progress hold still. + */ + val overdrag: Float get() = overdragAnimatable.value + + /** + * The part of the drag that has run off the end, before resistance. Kept unresisted so the + * finger has to give all of it back before the card starts moving home again — the alternative + * is a card that leaves the end as soon as the finger turns around, a quarter of the way behind + * where it was pushed. + */ + private var overflow = 0f + + /** The recogniser's slack, waiting for the first delta to hand it back to. See [startDrag]. */ + private var pendingSlack = 0f + + var isExpanded by mutableStateOf(false) + private set + + fun toggle() { + if (isExpanded) collapse() else expand() + } + + fun expand(velocity: Float = 0f, spec: AnimationSpec = ExpansionSpring) { + isExpanded = true + animate(target = 1f, velocity = velocity, spec = spec) + } + + fun collapse(velocity: Float = 0f) { + isExpanded = false + animate(target = 0f, velocity = velocity, spec = ExpansionSpring) + } + + /** + * Opens a drag, with the [slack] the recogniser swallowed getting there — touch slop, in + * progress units. It is handed back on the first delta so the card picks up from where it + * stands rather than jumping that distance the moment the gesture is recognised. iOS hands back + * its own 10pt activation distance for the same reason. + */ + fun startDrag(slack: Float) { + pendingSlack = slack + overflow = 0f + } + + /** + * Moves the card by [deltaProgress] under the finger, cancelling whatever spring was running. + * + * Pulled up past its slot the card simply stops: it has arrived, and a finger that has run out + * of card should feel the end. Pushed down past full screen it gives a little instead — see + * [overdrag] — because there the card has nowhere to go but is still being asked to move. + */ + fun dragBy(deltaProgress: Float) { + val delta = deltaProgress + pendingSlack * sign(deltaProgress) + if (deltaProgress != 0f) pendingSlack = 0f + scope.launch { + val moved = animatable.value + overflow + delta + animatable.snapTo(moved.coerceIn(0f, 1f)) + overflow = (moved - 1f).coerceAtLeast(0f) + overdragAnimatable.snapTo(overflow * OverdragResistance) + } + } + + /** Springs to whichever end [settlesExpanded] picks, carrying the release velocity into it. */ + fun settle(velocity: Float, flingThreshold: Float) { + overflow = 0f + pendingSlack = 0f + + val staysExpanded = settlesExpanded(animatable.value, velocity, flingThreshold) + // A pull that fell short has a short way back, so it takes the short spring; one that + // committed is running the whole transition and takes the transition's own. + val spec = if (staysExpanded) ReturnSpring else ExpansionSpring + + scope.launch { overdragAnimatable.animateTo(0f, spec) } + if (staysExpanded) expand(velocity, spec) else collapse(velocity) + } + + private fun animate(target: Float, velocity: Float, spec: AnimationSpec) { + scope.launch { + animatable.animateTo( + targetValue = target, + animationSpec = spec, + initialVelocity = velocity.coerceIn(-MaxSettleVelocity, MaxSettleVelocity), + ) + } + } +} + +@Composable +internal fun rememberTipCardExpansion(): TipCardExpansion { + val scope = rememberCoroutineScope() + return remember(scope) { TipCardExpansion(scope) } +} + +/** + * Where a released swipe lands: a flick wins on velocity alone, however far it got, and anything + * slower goes on whether the pull covered enough of the way home ([CollapseThreshold]). + * + * [velocity] and [flingThreshold] are both in progress-per-second, so the caller divides the + * gesture's pixel velocity by the distance the card actually has left to travel — a flick means + * the same thing on a tall display as on a short one. + */ +internal fun settlesExpanded(progress: Float, velocity: Float, flingThreshold: Float): Boolean = + when { + velocity <= -flingThreshold -> false + velocity >= flingThreshold -> true + else -> progress > 1f - CollapseThreshold + } + +/** + * How much of the card's travel home a pull has to cover for the release to finish the job, as + * iOS's `collapseThreshold` does. + * + * Well short of half, because the two ends are not equally likely: the card is only ever dragged + * from one of them, by someone who has already decided to put it away. Asking for half of the + * display's height before that counts made the card feel like it was resisting. + */ +private const val CollapseThreshold = 0.3f + +/** + * The fraction of a downward drag the card follows past full screen. Down is where expanding + * already took the card, so the pull has nowhere to take it and gives only enough to show that the + * drag is being felt. iOS's `overdragResistance`. + */ +private const val OverdragResistance = 0.25f + +/** + * How far a swipe must be moving at release to decide the outcome on its own, regardless of how far + * it travelled. Material's own swipe threshold — low enough that a flick of the card is enough, + * high enough that letting go of a slow drag doesn't count as one. + */ +internal val MinFlingVelocity = 125.dp + +/** + * The floor on the drag's travel distance, for the frames before the card's slot has been measured + * (and for the pathological case of a display too short to move the card at all). Without it a drag + * would divide by a travel of zero and snap the card shut on the first pixel. + */ +internal val MinDragTravel = 120.dp + +/** + * The whole expansion — card size, card position, the content sliding away, the Close row — runs on + * one spring, as iOS does: `.spring(response: 0.45, dampingFraction: 0.85)`. SwiftUI's `response` is + * the undamped period, so the equivalent Compose stiffness is `(2 * PI / 0.45) ^ 2`. + */ +private val ExpansionSpring = spring(dampingRatio = 0.85f, stiffness = 195f) + +/** + * Puts a pull that fell short back where it started — iOS's `settle`, `.spring(response: 0.3, + * dampingFraction: 0.85)`. Shorter than [ExpansionSpring] because the card has barely moved, and + * spending the full transition on a few dp of travel reads as a stall rather than a return. + */ +private val ReturnSpring = spring(dampingRatio = 0.85f, stiffness = 439f) + +/** + * The ceiling on the release velocity a settle carries into its spring, in progress-per-second — + * which is really a ceiling on how far the card bounces past the end it landed on, since that is + * what an underdamped spring does with speed it is handed. + * + * Not clamped to no overshoot at all: the bounce is the card arriving somewhere and settling into + * it, and a flick that stops dead reads as a dropped frame. What it can't be is the ~18% of the + * travel an uncapped flick paid for — a card lifted clear out of its slot and up under the status + * bar, taking the page with it. This cap costs at most 5.6% (measured, across every release point), + * which is a bounce of about 10dp. [TipCardOvershootTest] holds both halves: some, and not much. + * + * It also floors how quickly a settle can be over. Uncapped, the hardest flick collapsed the card + * in three frames, which is less a transition than a cut. + * + * iOS never needs the cap because it never carries the gesture's velocity into the spring at all: + * its settle starts from a standstill, where this damping ratio overshoots half a percent. + */ +private const val MaxSettleVelocity = 4f diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt new file mode 100644 index 000000000..37bd34cac --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt @@ -0,0 +1,118 @@ +package com.flipcash.app.menu.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A drag has to move the card by every pixel the finger covered. + * + * Touch events arrive in batches, one batch per frame, so a slow frame hands the drag several + * deltas in a row before any of them can be applied — which is exactly when the animation is + * already struggling and the card can least afford to lose ground. Each delta is applied in a + * coroutine of its own, and `Animatable` serialises those through a mutex that cancels whatever it + * finds running, so "the batch adds up" is a property worth holding on to rather than assuming: a + * drag that read its base value and wrote it back across a cancellation point would lose the + * cancelled delta, and the card would stick under the finger and then lurch. + * + * The dispatcher here queues rather than running inline, which is what reproduces the batch. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TipCardDragTest { + + @Test + fun `a delta a frame moves the card by all of them`() = runTest { + val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) + + repeat(5) { + expansion.dragBy(0.1f) + advanceUntilIdle() + } + + assertEquals(0.5f, expansion.progress, Tolerance) + } + + @Test + fun `five deltas in one frame's batch move the card by all of them`() = runTest { + val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) + + repeat(5) { expansion.dragBy(0.1f) } + advanceUntilIdle() + + assertEquals(0.5f, expansion.progress, Tolerance) + } + + @Test + fun `a drag past either end stops there`() = runTest { + val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) + + repeat(20) { expansion.dragBy(0.1f) } + advanceUntilIdle() + assertEquals(1f, expansion.progress, Tolerance) + + repeat(40) { expansion.dragBy(-0.1f) } + advanceUntilIdle() + assertEquals(0f, expansion.progress, Tolerance) + } + + @Test + fun `pushing the card down past full screen gives, a quarter of the way`() = runTest { + val expansion = expandedCard() + + expansion.dragBy(0.4f) + advanceUntilIdle() + + assertEquals(1f, expansion.progress, Tolerance) + assertEquals(0.1f, expansion.overdrag, Tolerance) + } + + @Test + fun `a finger that turns around gives back all it pushed before the card moves`() = runTest { + val expansion = expandedCard() + + expansion.dragBy(0.4f) + expansion.dragBy(-0.3f) + advanceUntilIdle() + + // Still home, a quarter of what is left of the push behind it. Unwinding the overdrag at + // its resisted size instead would leave the card moving home while the finger is still + // below where it pushed from. + assertEquals(1f, expansion.progress, Tolerance) + assertEquals(0.025f, expansion.overdrag, Tolerance) + + expansion.dragBy(-0.2f) + advanceUntilIdle() + + assertEquals(0.9f, expansion.progress, Tolerance) + assertEquals(0f, expansion.overdrag, Tolerance) + } + + @Test + fun `the slack the recogniser swallowed is handed back on the first delta only`() = runTest { + val expansion = expandedCard() + + expansion.startDrag(slack = 0.05f) + expansion.dragBy(-0.1f) + advanceUntilIdle() + assertEquals(0.85f, expansion.progress, Tolerance) + + expansion.dragBy(-0.1f) + advanceUntilIdle() + assertEquals(0.75f, expansion.progress, Tolerance) + } + + /** A card dragged all the way out, which is the only state the swipe exists in. */ + private suspend fun TestScope.expandedCard(): TipCardExpansion { + val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) + expansion.dragBy(1f) + advanceUntilIdle() + return expansion + } +} + +private const val Tolerance = 1e-4f diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt new file mode 100644 index 000000000..912d16df1 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt @@ -0,0 +1,116 @@ +package com.flipcash.app.menu.internal + +import androidx.compose.runtime.BroadcastFrameClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * A released swipe has to bounce, and not much. + * + * The spring carries the release velocity into the settle, and a flick can hand it far more speed + * than the spring would ever have picked up on its own — enough that it sails a fifth of the travel + * past the end it was aiming for, which on the resting end lifts the card clear out of its slot and + * up under the status bar. Capping that velocity is what keeps the bounce to a settle; the cap is + * only meaningful if it leaves a bounce there at all, so both bounds are held here. iOS meets + * neither, having no swipe: its spring always starts from a standstill. + * + * Driven a frame at a time off a [BroadcastFrameClock], so these are the values the card is + * actually drawn at, not a reading of where the animation was aimed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TipCardOvershootTest { + + @Test + fun `no flick bounces the card far past full screen`() = runTest { + for (from in ReleasePoints) { + val overshoot = flingTravel(from, velocity = HardFlick).endInclusive - 1f + + assertTrue( + overshoot <= MaxBounce, + "released at $from, the card bounced $overshoot past full screen", + ) + } + } + + @Test + fun `no flick lifts the card far out of its slot`() = runTest { + for (from in ReleasePoints) { + val overshoot = -flingTravel(from, velocity = -HardFlick).start + + assertTrue( + overshoot <= MaxBounce, + "released at $from, the card bounced $overshoot above its resting place", + ) + } + } + + @Test + fun `a flick does still bounce`() = runTest { + assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive > 1f + Tolerance) + assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start < -Tolerance) + } + + @Test + fun `a flick still comes to rest where it was aimed`() = runTest { + assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive >= 1f - Tolerance) + assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start <= Tolerance) + } +} + +/** Every tenth of the way out, since where a flick is released changes how far it carries past. */ +private val ReleasePoints = (1..9).map { it / 10f } + +/** + * The bounce budget, as a fraction of the card's travel — about 10dp of settle on a 1080x2424 + * display, against the 33dp an uncapped flick spent. + */ +private const val MaxBounce = 0.06f + +/** + * Settles the card from [from] at [velocity] and reports the range it was drawn across on the way + * to rest — which is wider than the range between its two ends if the spring overshoots one. + */ +private suspend fun TestScope.flingTravel( + from: Float, + velocity: Float, +): ClosedFloatingPointRange { + val clock = BroadcastFrameClock() + val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler) + clock)) + + expansion.dragBy(from) + advanceUntilIdle() + expansion.settle(velocity, FlingThreshold) + advanceUntilIdle() + + var lowest = expansion.progress + var highest = lowest + repeat(FramesToSettle) { frame -> + clock.sendFrame((frame + 1) * NanosPerFrame) + advanceUntilIdle() + lowest = minOf(lowest, expansion.progress) + highest = maxOf(highest, expansion.progress) + } + return lowest..highest +} + +/** + * Android's maximum fling velocity (8000 px/s) over the ~560 px the card travels between its slot + * and the middle of a 1080x2424 display — the fastest a release can ever hand the spring. + */ +private const val HardFlick = 14.2f + +/** [MinFlingVelocity] over that same travel. */ +private const val FlingThreshold = 0.58f + +private const val NanosPerFrame = 16_666_667L + +/** Two seconds of frames — several times over what this spring needs to come to rest. */ +private const val FramesToSettle = 120 + +private const val Tolerance = 1e-3f diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt new file mode 100644 index 000000000..1a186781c --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.menu.internal + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Where a swipe on the expanded tip card lands when the finger lifts. + * + * The gesture drags the card back up towards its slot on the "You" tab, so the release has to + * decide between putting it away and springing it back out — and it has to agree with what the + * hand just did. A flick counts on its own, however short; anything slower goes on how much of the + * way home the pull covered. iOS asks the same two questions of its own release. + */ +class TipCardSettleTest { + + /** A velocity well inside the threshold: a drag that was drifting, not thrown. */ + private val slow = FlingThreshold / 4 + + @Test + fun `a slow release goes on how much of the way home the pull covered`() { + assertFalse(settle(progress = 0.2f, velocity = slow)) + assertFalse(settle(progress = 0.69f, velocity = -slow)) + assertTrue(settle(progress = 0.71f, velocity = -slow)) + assertTrue(settle(progress = 0.9f, velocity = slow)) + } + + @Test + fun `a pull that covered under a third of the way home springs back`() { + // Barely started, and nothing in the release says otherwise: the card belongs where it was. + assertTrue(settle(progress = 0.71f, velocity = 0f)) + } + + @Test + fun `a pull that covered a third of the way home finishes the job`() { + // Short of half the travel, deliberately. The card is only ever dragged from one end, by + // someone who has already decided to put it away. + assertFalse(settle(progress = 0.7f, velocity = 0f)) + assertFalse(settle(progress = 0.5f, velocity = 0f)) + } + + @Test + fun `an upward flick closes the card however little of it was dragged`() { + // The whole point of a flick: barely moved, but thrown at the slot it came out of. + assertFalse(settle(progress = 0.98f, velocity = -FlingThreshold)) + } + + @Test + fun `a downward flick puts the card back even from almost closed`() { + // Changed their mind mid-swipe — the card belongs back on screen, not shut. + assertTrue(settle(progress = 0.02f, velocity = FlingThreshold)) + } + + @Test + fun `a motionless card is left where the tap put it`() { + assertTrue(settle(progress = 1f, velocity = 0f)) + assertFalse(settle(progress = 0f, velocity = 0f)) + } + + private fun settle(progress: Float, velocity: Float) = + settlesExpanded(progress, velocity, FlingThreshold) + + private companion object { + /** Stand-in for the density-derived threshold the screen computes; the units cancel. */ + const val FlingThreshold = 2f + } +}