fix: remove reveal damping so the top bar stays glued to content

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
This commit is contained in:
Claude
2026-06-21 19:35:30 +00:00
parent abd540e3c2
commit 73d52401cd
2 changed files with 25 additions and 28 deletions
@@ -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)
}
}
@@ -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