mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+18
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+27
-8
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user