From 19d53a66632ec32254140cf3fa24366b018d694b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 14:51:59 -0400 Subject: [PATCH 1/2] feat(menu): swipe to minimize the full-screen tip card Full screen on the "You" tab could only be left through the Close row. The expansion is now one 0..1 progress rather than a boolean: card size, its travel out of the slot, the page fading beneath it and the Close row all read the same value, so a drag can park the transition part-way through and hand it back where it found it. Every reader takes the progress inside a graphicsLayer lambda, so a drag redraws rather than recomposing the page. A release settles on the spring iOS already uses for this transition (response 0.45, damping fraction 0.85), carrying the gesture's velocity into it. Uncapped, the hardest flick sailed about 18% of the travel past the resting end, which lifts the card clear out of its slot and up under the status bar. Capping the carried velocity holds that to 5.6%, roughly 10dp, without flattening the bounce into a cut. TipCardOvershootTest drives the animation a frame at a time off a BroadcastFrameClock and holds both bounds: some bounce, and not much. The "Full Screen" caption is a later sibling than the card, so it paints on top of it while the growing card travels down across its slot. It now fades out over the first 8% of the expansion, ahead of the card reaching that spot at around 11%. One gap: the tab bar still switches on the committed intent rather than tracking the drag, because HideTabBar is boolean rather than progress-driven. --- apps/flipcash/features/menu/build.gradle.kts | 1 + .../app/menu/internal/MenuScreenContent.kt | 230 ++++++++++++------ .../app/menu/internal/TipCardExpansion.kt | 144 +++++++++++ .../app/menu/internal/TipCardDragTest.kt | 63 +++++ .../app/menu/internal/TipCardOvershootTest.kt | 116 +++++++++ .../app/menu/internal/TipCardSettleTest.kt | 58 +++++ 6 files changed, 536 insertions(+), 76 deletions(-) create mode 100644 apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt create mode 100644 apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt 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..be469d490 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,7 @@ 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.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -66,6 +64,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 +124,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,48 +184,93 @@ 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) { + { + if (cardSlotCenterY <= 0f) 0f + else (displayCenterY - cardSlotCenterY) * progress() + } } + // 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. + val density = LocalDensity.current + val minTravel = with(density) { MinDragTravel.toPx() } + val flingVelocity = with(density) { MinFlingVelocity.toPx() } + // 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, + onDragStopped = { velocity -> + expansion.settle(velocity / dragTravel, flingVelocity / dragTravel) + }, + ) + MenuList( modifier = Modifier.fillMaxSize(), state = listState, @@ -238,12 +283,13 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { YouHeader( tipCardState = state.tipCardState, enabled = !cardExpanded, - expansion = expansion, + expansion = progress, slideAway = slideAway, - cardWidth = cardWidth, + cardScale = cardScale, cardShift = cardShift, + cardDrag = cardDrag, 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 +318,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 +328,23 @@ 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) + .graphicsLayer { alpha = progress().coerceIn(0f, 1f) } .navigationBarsPadding() .padding(bottom = CloseBottomSpacing) - .noRippleClickable { cardExpanded = false }, + .noRippleClickable(enabled = cardExpanded) { expansion.collapse() }, ) } } @@ -330,15 +377,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 +400,11 @@ 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, + cardDrag: Modifier, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -367,7 +416,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,8 +427,9 @@ private fun YouHeader( enabled = enabled, expansion = expansion, slideAway = slideAway, - cardWidth = cardWidth, + cardScale = cardScale, cardShift = cardShift, + cardDrag = cardDrag, onCardSlotPositioned = onCardSlotPositioned, onToggleFullScreen = onToggleFullScreen, onCopyLink = onCopyLink, @@ -391,10 +442,16 @@ 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. + * + * [cardDrag] goes on the card itself: the swipe that puts an expanded card away has to be attached + * to the thing it moves, so the card is what the finger is holding rather than a hotspot near it. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -402,10 +459,11 @@ private fun ClaimedTipCard( card: Scannable.TipCard, link: String?, enabled: Boolean, - expansion: Float, + expansion: () -> Float, slideAway: Modifier, - cardWidth: Dp, - cardShift: Float, + cardScale: () -> Float, + cardShift: () -> Float, + cardDrag: Modifier, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -428,15 +486,32 @@ 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() + } + .then(cardDrag) .noRippleClickable { onToggleFullScreen() }, ) { - ScannableRenderer(scannable = card, tipCardWidth = cardWidth) + ScannableRenderer(scannable = card, tipCardWidth = FullScreenCardWidth) } } } @@ -444,12 +519,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..554b700cb --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt @@ -0,0 +1,144 @@ +package com.flipcash.app.menu.internal + +import androidx.compose.animation.core.Animatable +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 + +/** + * 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) + + /** 0 at rest, 1 full screen; anywhere in between while dragging or springing. */ + val progress: Float get() = animatable.value + + var isExpanded by mutableStateOf(false) + private set + + fun toggle() { + if (isExpanded) collapse() else expand() + } + + fun expand(velocity: Float = 0f) { + isExpanded = true + animate(target = 1f, velocity = velocity) + } + + fun collapse(velocity: Float = 0f) { + isExpanded = false + animate(target = 0f, velocity = velocity) + } + + /** + * Moves the card by [deltaProgress] under the finger, cancelling whatever spring was running. + * + * Clamped rather than rubber-banded, and unlike a settle it is allowed no overshoot at all: the + * card is under the finger, and a finger that has run out of card should feel the end. + */ + fun dragBy(deltaProgress: Float) { + scope.launch { animatable.snapTo((animatable.value + deltaProgress).coerceIn(0f, 1f)) } + } + + /** Springs to whichever end [settlesExpanded] picks, carrying the release velocity into it. */ + fun settle(velocity: Float, flingThreshold: Float) { + if (settlesExpanded(animatable.value, velocity, flingThreshold)) { + expand(velocity) + } else { + collapse(velocity) + } + } + + private fun animate(target: Float, velocity: Float) { + scope.launch { + animatable.animateTo( + targetValue = target, + animationSpec = ExpansionSpring, + 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 than that falls to whichever end it is nearer. + * + * [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 >= HalfWay + } + +private const val HalfWay = 0.5f + +/** + * 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) + +/** + * The ceiling on the release velocity a settle carries into [ExpansionSpring], 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 has no swipe — its spring always starts from a standstill, + * where the same 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..bc1d97ceb --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.menu.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +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) + } +} + +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..1854438a7 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt @@ -0,0 +1,58 @@ +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 to the nearer end. + */ +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 falls to whichever end it is nearer`() { + assertFalse(settle(progress = 0.2f, velocity = slow)) + assertFalse(settle(progress = 0.49f, velocity = -slow)) + assertTrue(settle(progress = 0.51f, velocity = -slow)) + assertTrue(settle(progress = 0.9f, velocity = slow)) + } + + @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 card held exactly half way stays open`() { + // Ties go to the state the user asked for; putting it away needs a decision, not a draw. + assertTrue(settle(progress = 0.5f, velocity = 0f)) + } + + @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 + } +} From 7cbc6e20c4b1c1a8dbd5ecb9a66cfb0c3fe969ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 15:33:53 -0400 Subject: [PATCH 2/2] fix(menu): match iOS's pull-to-minimize thresholds and feel The Android swipe was ours, not a port: the gesture sat on the card, it took half the travel to close, and pushing an expanded card down did nothing. iOS PR #639 settled all three differently. Bringing them across: - Attach the drag to the page, not the card. Expanded, the card is 302dp of a wider display, so a pull that lands in the margin missed entirely. Nothing scrolls while the card is up, so there is no scroll to contend with. - Close at 0.3 of the travel rather than halfway. - Give the card 0.25 resistance when it is pushed down past full screen, and return it on the shorter spring iOS uses for a pull that fell short (response 0.3, so stiffness 439). - Hand back the touch slop the recogniser swallowed on the first delta, so the card starts where the finger did instead of jumping the slop distance. Two divergences stay. The settle still carries a capped release velocity, because Android has a swipe where iOS only has a tap and so has no velocity to carry. The page content still fades on progress rather than on the boolean, because our card is opaque at this call site and a boolean fade shows through a translucent one. --- .../app/menu/internal/MenuScreenContent.kt | 31 +++-- .../app/menu/internal/TipCardExpansion.kt | 111 ++++++++++++++---- .../app/menu/internal/TipCardDragTest.kt | 55 +++++++++ .../app/menu/internal/TipCardSettleTest.kt | 29 +++-- 4 files changed, 184 insertions(+), 42 deletions(-) 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 be469d490..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 @@ -55,6 +55,7 @@ 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 @@ -242,10 +243,13 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // 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 = remember(displayCenterY) { + val cardShift = remember(displayCenterY, expansion) { { if (cardSlotCenterY <= 0f) 0f - else (displayCenterY - cardSlotCenterY) * progress() + // 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) } } @@ -255,9 +259,16 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { // 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. @@ -266,13 +277,16 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { 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, @@ -287,7 +301,6 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { slideAway = slideAway, cardScale = cardScale, cardShift = cardShift, - cardDrag = cardDrag, onCardSlotPositioned = { cardSlotCenterY = it }, onToggleFullScreen = { expansion.toggle() }, onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, @@ -341,6 +354,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { 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) @@ -404,7 +420,6 @@ private fun YouHeader( slideAway: Modifier, cardScale: () -> Float, cardShift: () -> Float, - cardDrag: Modifier, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -429,7 +444,6 @@ private fun YouHeader( slideAway = slideAway, cardScale = cardScale, cardShift = cardShift, - cardDrag = cardDrag, onCardSlotPositioned = onCardSlotPositioned, onToggleFullScreen = onToggleFullScreen, onCopyLink = onCopyLink, @@ -449,9 +463,6 @@ private fun YouHeader( * * 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. - * - * [cardDrag] goes on the card itself: the swipe that puts an expanded card away has to be attached - * to the thing it moves, so the card is what the finger is holding rather than a hotspot near it. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -463,7 +474,6 @@ private fun ClaimedTipCard( slideAway: Modifier, cardScale: () -> Float, cardShift: () -> Float, - cardDrag: Modifier, onCardSlotPositioned: (Float) -> Unit, onToggleFullScreen: () -> Unit, onCopyLink: () -> Unit, @@ -508,7 +518,6 @@ private fun ClaimedTipCard( scaleY = scale translationY = cardShift() } - .then(cardDrag) .noRippleClickable { onToggleFullScreen() }, ) { ScannableRenderer(scannable = card, tipCardWidth = FullScreenCardWidth) 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 index 554b700cb..4dd06b4f4 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -12,6 +13,7 @@ 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. @@ -30,9 +32,30 @@ 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 @@ -40,40 +63,64 @@ internal class TipCardExpansion(private val scope: CoroutineScope) { if (isExpanded) collapse() else expand() } - fun expand(velocity: Float = 0f) { + fun expand(velocity: Float = 0f, spec: AnimationSpec = ExpansionSpring) { isExpanded = true - animate(target = 1f, velocity = velocity) + animate(target = 1f, velocity = velocity, spec = spec) } fun collapse(velocity: Float = 0f) { isExpanded = false - animate(target = 0f, velocity = velocity) + 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. * - * Clamped rather than rubber-banded, and unlike a settle it is allowed no overshoot at all: the - * card is under the finger, and a finger that has run out of card should feel the end. + * 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) { - scope.launch { animatable.snapTo((animatable.value + deltaProgress).coerceIn(0f, 1f)) } + 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) { - if (settlesExpanded(animatable.value, velocity, flingThreshold)) { - expand(velocity) - } else { - collapse(velocity) - } + 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) { + private fun animate(target: Float, velocity: Float, spec: AnimationSpec) { scope.launch { animatable.animateTo( targetValue = target, - animationSpec = ExpansionSpring, + animationSpec = spec, initialVelocity = velocity.coerceIn(-MaxSettleVelocity, MaxSettleVelocity), ) } @@ -88,7 +135,7 @@ internal fun rememberTipCardExpansion(): TipCardExpansion { /** * Where a released swipe lands: a flick wins on velocity alone, however far it got, and anything - * slower than that falls to whichever end it is nearer. + * 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 @@ -98,10 +145,25 @@ internal fun settlesExpanded(progress: Float, velocity: Float, flingThreshold: F when { velocity <= -flingThreshold -> false velocity >= flingThreshold -> true - else -> progress >= HalfWay + else -> progress > 1f - CollapseThreshold } -private const val HalfWay = 0.5f +/** + * 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 @@ -125,9 +187,16 @@ internal val MinDragTravel = 120.dp private val ExpansionSpring = spring(dampingRatio = 0.85f, stiffness = 195f) /** - * The ceiling on the release velocity a settle carries into [ExpansionSpring], 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. + * 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 @@ -138,7 +207,7 @@ private val ExpansionSpring = spring(dampingRatio = 0.85f, stiffness = 19 * 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 has no swipe — its spring always starts from a standstill, - * where the same damping ratio overshoots half a percent. + * 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 index bc1d97ceb..37bd34cac 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -58,6 +59,60 @@ class TipCardDragTest { 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/TipCardSettleTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt index 1854438a7..1a186781c 100644 --- 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 @@ -9,7 +9,8 @@ import kotlin.test.assertTrue * * 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 to the nearer end. + * 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 { @@ -17,13 +18,27 @@ class TipCardSettleTest { private val slow = FlingThreshold / 4 @Test - fun `a slow release falls to whichever end it is nearer`() { + 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.49f, velocity = -slow)) - assertTrue(settle(progress = 0.51f, 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. @@ -36,12 +51,6 @@ class TipCardSettleTest { assertTrue(settle(progress = 0.02f, velocity = FlingThreshold)) } - @Test - fun `a card held exactly half way stays open`() { - // Ties go to the state the user asked for; putting it away needs a decision, not a draw. - assertTrue(settle(progress = 0.5f, velocity = 0f)) - } - @Test fun `a motionless card is left where the tap put it`() { assertTrue(settle(progress = 1f, velocity = 0f))