fix(scaffold): keep bars visible on non-scrollable lists

The DisappearingScaffold hid its top/bottom bars purely from overscroll
gestures, so a feed with too few items to scroll would end up with two
empty bands where the chrome used to be. Pure overscroll (consumed.y == 0)
from the fully-visible state is now ignored; once the bars have started
moving we know the list is scrollable and edge overscroll keeps feeding
bar motion as before.
This commit is contained in:
Claude
2026-04-22 18:28:41 +00:00
parent e452cf8abd
commit b5b01f6eda
2 changed files with 43 additions and 0 deletions
@@ -36,6 +36,10 @@ import androidx.compose.ui.unit.Velocity
* - onPostScroll reads `consumed.y + available.y` (the total scroll attempt that entered
* the nested-scroll chain) and updates the bar offsets. Using the sum means the bars
* also respond to overscroll attempts at the list edges.
* - Pure overscroll (`consumed.y == 0`) from the fully-visible state is ignored. This
* prevents short, non-scrollable lists from hiding the bars purely on an overscroll
* gesture — which would leave blank padding at the top and bottom. Once the bars have
* started moving (list is clearly scrollable), edge overscroll keeps affecting them.
* - 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.
@@ -54,6 +58,14 @@ class DisappearingBarNestedScroll(
val totalY = consumed.y + available.y
if (totalY == 0f) 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
// feed would hide its chrome purely from overscroll gestures, leaving two empty
// bands at the top and bottom where the bars used to be.
val isPureOverscroll = consumed.y == 0f
val barsFullyVisible = state.topHeightOffset == 0f && state.bottomHeightOffset == 0f
if (isPureOverscroll && barsFullyVisible) return Offset.Zero
val deltaY = if (reverseLayout) -totalY else totalY
applyDelta(deltaY)
// Never consume: the content scrolls freely while the bars slide along.
@@ -106,6 +106,37 @@ class DisappearingBarNestedScrollTest {
assertEquals(-10f, state.bottomHeightOffset)
}
@Test
fun `pure overscroll from fully-visible state leaves the bars alone`() {
// A short list that cannot scroll: the LazyColumn consumes nothing and the whole
// drag comes through as `available`. We should not hide the bars from this, or we
// end up with two empty bands where the chrome used to be.
val state = state()
val connection = nsc(state)
connection.onPostScroll(Offset(0f, 0f), Offset(0f, -80f), NestedScrollSource.UserInput)
assertEquals(0f, state.topHeightOffset)
assertEquals(0f, state.bottomHeightOffset)
}
@Test
fun `pure overscroll still moves bars once they are already partially hidden`() {
// Scrollable list that reached an edge: the list stops consuming but the user keeps
// flinging. Because the bars are already mid-hide we know the list was scrolling,
// so overscroll keeps feeding the bar motion — matching the "bars ride along"
// philosophy.
val state = state(topLimit = 100f, bottomLimit = 50f)
state.topHeightOffset = -20f
state.bottomHeightOffset = -20f
val connection = nsc(state)
connection.onPostScroll(Offset(0f, 0f), Offset(0f, -40f), NestedScrollSource.UserInput)
assertEquals(-60f, state.topHeightOffset)
assertEquals(-50f, state.bottomHeightOffset)
}
@Test
fun `canScroll returning false freezes the bars`() {
val state = state()