Merge pull request #3268 from vitorpamplona/claude/scroll-sensitivity-nav-statusbar-g2jo95

Add hysteresis to status bar hide/show and dampen bar reveal
This commit is contained in:
Vitor Pamplona
2026-06-18 15:42:09 -04:00
committed by GitHub
3 changed files with 67 additions and 16 deletions
@@ -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
}
}
@@ -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())
}
}
}
}
@@ -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