From 73d52401cd0100b43318ce9308cfaaa41b47f2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:35:30 +0000 Subject: [PATCH 1/2] 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 From d96d0220fb673608ffff61be4b79a2f0950dac5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:32:03 +0000 Subject: [PATCH 2/2] feat: gap-safe settle, snappier spring, and jitter dead-zone for disappearing bars Three refinements to the disappearing top/bottom bar animations: - Gap-safe settle: settleToNearestEdge could snap a partially-collapsed bar to fully hidden whenever it was past the halfway point, even when the content had only scrolled part of a bar height (e.g. a gentle flick from the top). Because the content padding is fixed and the bar is translated, hiding it further than the content scrolled reopens the same blank band the reveal-damping fix removed. Each bar now latches whether it has actually reached its hidden edge through scrolling; the settle only commits to fully hidden when that latch is set, otherwise it settles back into view. This keeps the deliberate-reveal behavior (a sub-halfway reveal after fully hiding still snaps back hidden) without the gap. - Snappier settle spring: StiffnessMediumLow -> StiffnessMedium so the bars resolve to their edge with a quick native snap instead of a slow float. Still DampingRatioNoBouncy, and overshoot stays clamped by animateOne's bounds. - Micro-scroll dead-zone: ignore sub-pixel scroll attempts so jitter doesn't nudge the bars or flip the binary status-bar toggle. Kept tiny and symmetric so the reveal never lags the content enough to open a gap. Proportional top/bottom collapse was intentionally left out: both bars already move at the same pixel rate (visual lock-step for their shared travel), and forcing the shorter bar to finish at the same time as the taller one would push it off the 1:1 content track and reintroduce a gap. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH --- .../ui/layouts/DisappearingBarNestedScroll.kt | 15 ++++- .../ui/layouts/DisappearingBarState.kt | 58 +++++++++++++++++-- .../ui/layouts/DisappearingBarStateTest.kt | 45 ++++++++++++++ 3 files changed, 112 insertions(+), 6 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 20c7acda5e..7e81278143 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity +import kotlin.math.abs /** * Scroll-linked connection that hides/reveals the top and bottom bars together. @@ -66,7 +67,10 @@ class DisappearingBarNestedScroll( ): Offset { if (!canScroll()) return Offset.Zero val totalY = consumed.y + available.y - if (totalY == 0f) return Offset.Zero + // Dead-zone: ignore sub-pixel jitter so the bars (and the binary status-bar toggle that + // tracks their collapse fraction) don't twitch on scroll noise. Kept symmetric and tiny so + // it never makes the reveal lag the content enough to open a visible gap. + if (abs(totalY) < MIN_SCROLL_DELTA) return Offset.Zero // If the list did not consume any scroll and the bars are fully visible, treat // this as a non-scrollable list and keep the bars in place. Without this, a tiny @@ -101,4 +105,13 @@ class DisappearingBarNestedScroll( state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f) state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f) } + + companion object { + /** + * Sub-pixel dead-zone: scroll attempts smaller than this are ignored so jitter doesn't nudge + * the bars. Tiny on purpose — large enough to swallow fractional noise, small enough that the + * bar offset never measurably lags the content scroll. + */ + const val MIN_SCROLL_DELTA = 0.5f + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt index 8d743246db..13cddbc185 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarState.kt @@ -46,8 +46,46 @@ class DisappearingBarState( initialTopHeightOffset: Float = 0f, initialBottomHeightOffset: Float = 0f, ) { - var topHeightOffset by mutableFloatStateOf(initialTopHeightOffset) - var bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset) + private var _topHeightOffset by mutableFloatStateOf(initialTopHeightOffset) + private var _bottomHeightOffset by mutableFloatStateOf(initialBottomHeightOffset) + + /** + * Latches that record whether each bar has been driven all the way to its hidden edge by real + * scrolling since it was last fully in view. The settle reads them to tell a deliberate hide — + * where the content has scrolled at least a full bar height, so snapping the bar fully hidden + * leaves content (not a blank band) in the slot it vacates — apart from a small near-top + * collapse, where snapping hidden would expose the background because the content hasn't + * scrolled far enough to fill the bar's slot. + */ + private var topReachedHiddenEdge = false + private var bottomReachedHiddenEdge = false + + var topHeightOffset: Float + get() = _topHeightOffset + set(value) { + _topHeightOffset = value + updateLatch(value, topHeightLimit) { topReachedHiddenEdge = it } + } + + var bottomHeightOffset: Float + get() = _bottomHeightOffset + set(value) { + _bottomHeightOffset = value + updateLatch(value, bottomHeightLimit) { bottomReachedHiddenEdge = it } + } + + private inline fun updateLatch( + offset: Float, + limit: Float, + set: (Boolean) -> Unit, + ) { + if (limit <= 0f) return + if (offset >= 0f) { + set(false) + } else if (offset <= -limit) { + set(true) + } + } var topHeightLimit: Float = 0f set(value) { @@ -76,8 +114,8 @@ class DisappearingBarState( */ suspend fun settleToNearestEdge(initialVelocityY: Float = 0f) { coroutineScope { - launch { settleOne({ topHeightOffset }, topHeightLimit, initialVelocityY) { topHeightOffset = it } } - launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, initialVelocityY) { bottomHeightOffset = it } } + launch { settleOne({ topHeightOffset }, topHeightLimit, topReachedHiddenEdge, initialVelocityY) { topHeightOffset = it } } + launch { settleOne({ bottomHeightOffset }, bottomHeightLimit, bottomReachedHiddenEdge, initialVelocityY) { bottomHeightOffset = it } } } } @@ -94,6 +132,7 @@ class DisappearingBarState( private suspend fun settleOne( get: () -> Float, limit: Float, + canFullyHide: Boolean, initialVelocityY: Float, set: (Float) -> Unit, ) { @@ -105,6 +144,11 @@ class DisappearingBarState( val positionBiasToHide = -current > limit / 2f val target = when { + // Snapping to the hidden edge is only safe once the bar has actually been scrolled + // there (canFullyHide): the content has then moved at least a full bar height and + // fills the slot the bar vacates. From a small near-top collapse it hasn't, so + // snapping hidden would open a blank band — settle back into view instead. + !canFullyHide -> 0f initialVelocityY < -VELOCITY_BIAS_THRESHOLD -> -limit initialVelocityY > VELOCITY_BIAS_THRESHOLD -> 0f positionBiasToHide -> -limit @@ -142,10 +186,14 @@ class DisappearingBarState( companion object { private const val VELOCITY_BIAS_THRESHOLD = 200f + // Bounce-free so the chrome never wobbles past its edge, but stiff enough to feel like a + // quick native snap rather than a slow float once the finger lifts. Overshoot from a strong + // initial velocity is still caught by the bounds in animateOne, so a higher stiffness here + // only affects how briskly the bar resolves to its edge. private val SETTLE_SPRING = spring( dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, + stiffness = Spring.StiffnessMedium, ) val Saver: Saver = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt index bae001b849..52ba051e3e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarStateTest.kt @@ -93,4 +93,49 @@ class DisappearingBarStateTest { assertTrue("top bar overshot to $peakTop", peakTop <= 0.5f) assertTrue("bottom bar overshot to $peakBottom", peakBottom <= 0.5f) } + + @Test + fun `a partial collapse that never reached the hidden edge settles back into view`() = + runTest { + // The bar is past the halfway point but was only ever scrolled here from the top — it + // never reached -limit, so the content hasn't moved a full bar height. Snapping it fully + // hidden would expose a blank band, so it must settle back to visible instead. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -80f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(0f, state.topHeightOffset, 0.01f) + } + + @Test + fun `a small reveal after fully hiding settles back to hidden, not into view`() = + runTest { + // Drive the bar to its hidden edge first (content has now scrolled a full bar height), + // then nudge it back a little — the small reverse drag a finger makes catching a scroll. + // Because it genuinely reached the hidden edge, snapping back hidden leaves no gap, so a + // sub-halfway reveal must not pop the chrome back open. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -100f + state.topHeightOffset = -90f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(-100f, state.topHeightOffset, 0.01f) + } + + @Test + fun `returning fully into view re-arms the gap guard so the next near-top collapse settles open`() = + runTest { + // Hide fully (arms the latch), come all the way back to visible (disarms it), then do a + // small near-top collapse again. It must settle open, proving the latch resets at 0. + val state = state(topLimit = 100f, bottomLimit = 50f) + state.topHeightOffset = -100f + state.topHeightOffset = 0f + state.topHeightOffset = -80f + + peakOffsetsDuring(state) { state.settleToNearestEdge() } + + assertEquals(0f, state.topHeightOffset, 0.01f) + } }