From a349ebbbc667dab9f4016cf76b2bd14534e6523a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 03:03:29 +0000 Subject: [PATCH] feat: reduce sensitivity for bringing back nav and status bar on scroll When immersive scrolling hides the bottom navigation and the OS status bar, the small reverse drag a finger makes while catching/stopping a fast scroll was enough to immediately bring the chrome back. Two changes make the reveal a more deliberate gesture: - Damp the reveal direction in DisappearingBarNestedScroll: hiding still tracks the finger 1:1, while revealing applies REVEAL_SENSITIVITY (0.5), so bringing the in-app bars back needs twice the scroll distance. - Add hysteresis to the OS status bar toggle: it hides once the chrome is fully settled (>= 0.999) but only reappears after the chrome is pulled back below STATUS_BAR_SHOW_THRESHOLD (0.7), so a stray reverse reveal no longer flips the binary status bar. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SN3JhbbvjE7BVKuSRMZmsU --- .../ui/layouts/DisappearingBarNestedScroll.kt | 20 +++++++++-- .../ui/layouts/DisappearingScaffold.kt | 35 ++++++++++++++----- .../DisappearingBarNestedScrollTest.kt | 28 +++++++++++---- 3 files changed, 67 insertions(+), 16 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 9c426379fc..e49922f349 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,6 +43,10 @@ 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. */ class DisappearingBarNestedScroll( private val state: DisappearingBarState, @@ -86,7 +90,19 @@ class DisappearingBarNestedScroll( private fun applyDelta(deltaY: Float) { val topLimit = state.topHeightLimit val bottomLimit = state.bottomHeightLimit - state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) - state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) + // 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 72e6b1886b..38689c571a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -269,6 +269,14 @@ private fun ResetBarsOnResume(state: DisappearingBarState) { /** Fraction at which the tracked in-app bar is considered fully settled into the hidden edge. */ private const val STATUS_BAR_HIDE_THRESHOLD = 0.999f +/** + * Fraction the chrome must fall back below before the OS status bar reappears. Lower than + * [STATUS_BAR_HIDE_THRESHOLD] on purpose: this hysteresis gap means the finger that stops a fast + * scroll — which makes a tiny reverse reveal — does not immediately pop the status bar back. The + * user has to deliberately scroll the chrome back by ~30% before the status bar returns. + */ +private const val STATUS_BAR_SHOW_THRESHOLD = 0.7f + /** * Hides the OS status bar once the tracked in-app bar has settled into the hidden edge, and shows it * the moment it starts coming back. The OS status bar is binary (cannot slide), so it is driven off @@ -295,16 +303,27 @@ private fun ImmersiveStatusBarEffect(state: DisappearingBarState) { LaunchedEffect(controller, state) { controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + var statusBarHidden = false snapshotFlow { - val fraction = - if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction - fraction >= STATUS_BAR_HIDE_THRESHOLD + if (state.topHeightLimit > 0f) state.topCollapsedFraction else state.bottomCollapsedFraction }.distinctUntilChanged() - .collect { shouldHide -> - if (shouldHide) { - controller.hide(WindowInsetsCompat.Type.statusBars()) - } else { - controller.show(WindowInsetsCompat.Type.statusBars()) + .collect { fraction -> + // Hysteresis: hide once fully settled, but only show again after the chrome has + // been pulled back past STATUS_BAR_SHOW_THRESHOLD. Between the two thresholds the + // status bar keeps its current state, so a stray reverse reveal can't flip it. + val shouldHide = + when { + fraction >= STATUS_BAR_HIDE_THRESHOLD -> true + fraction <= STATUS_BAR_SHOW_THRESHOLD -> false + else -> statusBarHidden + } + if (shouldHide != statusBarHidden) { + statusBarHidden = shouldHide + if (shouldHide) { + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + controller.show(WindowInsetsCompat.Type.statusBars()) + } } } } 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 aa8fd6e0af..5f3a00a834 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,16 +79,17 @@ class DisappearingBarNestedScrollTest { } @Test - fun `scrolling content down reveals both bars from a hidden state`() { + fun `scrolling content down reveals both bars from a hidden state, damped`() { 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. connection.onPostScroll(Offset(0f, 30f), Offset(0f, 0f), NestedScrollSource.UserInput) - assertEquals(-70f, state.topHeightOffset) - assertEquals(-20f, state.bottomHeightOffset) + assertEquals(-85f, state.topHeightOffset) + assertEquals(-35f, state.bottomHeightOffset) } @Test @@ -99,11 +100,26 @@ 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, not just one of the halves. + // The bars should move by the total 40 (damped to 20 on reveal), not just one half. connection.onPostScroll(Offset(0f, 20f), Offset(0f, 20f), NestedScrollSource.UserInput) - assertEquals(-10f, state.topHeightOffset) - assertEquals(-10f, state.bottomHeightOffset) + assertEquals(-30f, state.topHeightOffset) + assertEquals(-30f, state.bottomHeightOffset) + } + + @Test + fun `revealing is less sensitive than hiding for the same drag distance`() { + // 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. + 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) } @Test