From 73d52401cd0100b43318ce9308cfaaa41b47f2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:35:30 +0000 Subject: [PATCH] fix: remove reveal damping so the top bar stays glued to content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.5 reveal-sensitivity damping in DisappearingBarNestedScroll made the bars reveal at half the rate they hide. Because the content scrolls 1:1, the bar offset would lag behind the content scroll: after hiding the chrome and scrolling back up to the top, the list reaches its top while the bar is still only half revealed, leaving a blank band between the bar and the first item. The bar's translationY must mirror the content scroll offset exactly so its bottom edge stays glued to the first item's top edge. Any persistent reveal damping breaks that invariant and opens the gap, so revealing now tracks the finger 1:1 like hiding does. The original goal of the damping — keeping the chrome from popping back on the tiny reverse drag a finger makes when it catches a fast scroll — is still met without breaking the 1:1 invariant: a partial reveal that doesn't cross the halfway point is snapped back to the hidden edge by settleToNearestEdge on fling/lift, and the binary OS status bar is already debounced by the show/hide hysteresis in the scaffold. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH --- .../ui/layouts/DisappearingBarNestedScroll.kt | 32 ++++++++----------- .../DisappearingBarNestedScrollTest.kt | 21 ++++++------ 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt index e49922f349..20c7acda5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -43,10 +43,16 @@ import androidx.compose.ui.unit.Velocity * - onPostFling snaps a mid-way bar to the nearest edge, using the fling's remaining * velocity as the spring's initial velocity so the settle feels continuous. No velocity * is returned upward to avoid phantom scrolls on parent containers. - * - Hiding tracks the finger 1:1, but revealing is damped by [REVEAL_SENSITIVITY]. Once the - * bars are hidden, the small reverse drag a finger naturally makes when it catches/stops a - * fast scroll would otherwise be enough to snap the chrome (and the OS status bar) back. - * Damping the reveal direction makes bringing the bars back a more deliberate gesture. + * - Both hiding and revealing track the finger 1:1. The bar offset must equal the content's + * scroll offset so the bar's bottom edge stays glued to the first item's top edge; any + * asymmetry (e.g. a damped reveal) leaves the bar lagging behind the content and opens a + * blank band between the bar and the first item when the list returns to the top. + * + * Making the reveal a *deliberate* gesture — so the tiny reverse drag a finger makes when it + * catches/stops a fast scroll doesn't pop the chrome back — is handled without breaking that + * 1:1 invariant: a partial reveal that doesn't cross the halfway point is snapped back to the + * hidden edge by [DisappearingBarState.settleToNearestEdge] on fling/lift, and the binary OS + * status bar is debounced by the show/hide hysteresis in the scaffold. */ class DisappearingBarNestedScroll( private val state: DisappearingBarState, @@ -90,19 +96,9 @@ class DisappearingBarNestedScroll( private fun applyDelta(deltaY: Float) { val topLimit = state.topHeightLimit val bottomLimit = state.bottomHeightLimit - // Positive delta reveals the bars; negative delta hides them. Hiding stays 1:1 with the - // finger, while revealing is damped so a stray reverse drag doesn't bring the chrome back. - val effectiveDelta = if (deltaY > 0f) deltaY * REVEAL_SENSITIVITY else deltaY - state.topHeightOffset = (state.topHeightOffset + effectiveDelta).coerceIn(-topLimit, 0f) - state.bottomHeightOffset = (state.bottomHeightOffset + effectiveDelta).coerceIn(-bottomLimit, 0f) - } - - companion object { - /** - * Fraction of scroll distance applied when revealing the bars (1.0 = same rate as hiding). - * Lower values require a more deliberate downward scroll to bring the chrome back, so the - * tiny reverse movement of a finger stopping a fast scroll no longer pops the bars open. - */ - const val REVEAL_SENSITIVITY = 0.5f + // 1:1 in both directions: the bar offset mirrors the content scroll so the bar stays glued + // to the first item. Deliberate reveal is enforced on settle, not by damping the delta here. + state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) + state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt index 5f3a00a834..cad98a6229 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScrollTest.kt @@ -79,17 +79,17 @@ class DisappearingBarNestedScrollTest { } @Test - fun `scrolling content down reveals both bars from a hidden state, damped`() { + fun `scrolling content down reveals both bars from a hidden state`() { val state = state(topLimit = 100f, bottomLimit = 50f) state.topHeightOffset = -100f state.bottomHeightOffset = -50f val connection = nsc(state) - // Reveal is damped by REVEAL_SENSITIVITY (0.5), so a 30px drag only reveals 15px. + // Reveal tracks the finger 1:1, so a 30px drag reveals 30px. connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-85f, state.topHeightOffset) - assertEquals(-35f, state.bottomHeightOffset) + assertEquals(-70f, state.topHeightOffset) + assertEquals(-20f, state.bottomHeightOffset) } @Test @@ -100,26 +100,27 @@ class DisappearingBarNestedScrollTest { val connection = nsc(state) // The list consumed 20px of a 40px reveal drag; 20 more was left as overscroll. - // The bars should move by the total 40 (damped to 20 on reveal), not just one half. + // The bars should move by the total 40, not just one of the halves. connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput) - assertEquals(-30f, state.topHeightOffset) - assertEquals(-30f, state.bottomHeightOffset) + assertEquals(-10f, state.topHeightOffset) + assertEquals(-10f, state.bottomHeightOffset) } @Test - fun `revealing is less sensitive than hiding for the same drag distance`() { + fun `revealing tracks the finger 1 to 1, matching the hide rate so the bar stays glued to content`() { // Hiding a 40px drag moves the bars the full 40px... val hiding = state(topLimit = 100f, bottomLimit = 100f) nsc(hiding).onPostScroll(Offset(0f, -40f), Offset(0f, 0f), NestedScrollSource.UserInput) assertEquals(-40f, hiding.topHeightOffset) - // ...while revealing the same 40px from fully hidden only brings back 20px. + // ...and revealing the same 40px from fully hidden brings back the full 40px, so when the + // list returns to the top the bar is fully revealed with no blank band beneath it. val revealing = state(topLimit = 100f, bottomLimit = 100f) revealing.topHeightOffset = -100f revealing.bottomHeightOffset = -100f nsc(revealing).onPostScroll(Offset(0f, 40f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-80f, revealing.topHeightOffset) + assertEquals(-60f, revealing.topHeightOffset) } @Test