fix(ui): scroll-linked bars, content renders behind hiding bars

Two regressions from the previous rewrite:

1. "Nothing scrolls until the bars are gone" — the previous pass had the
   bar consume scroll delta in onPreScroll, so the content couldn't
   advance until the bar finished hiding. Switched to a scroll-linked
   model (Twitter/Instagram/Bluesky style): onPostScroll reads
   `consumed + available` (the total scroll attempt) and slides the
   bars by that delta without consuming anything. Content keeps
   full-speed scrolling; the bars just ride along at the same rate.
2. "Black/white strip where the bars used to be" — HomeScreen and
   DiscoverScreen were passing the scaffold padding to
   HorizontalPager's contentPadding, which shrinks the pages so they
   don't extend behind the bars. The scaffold padding now threads
   through to the inner LazyColumn/LazyVerticalGrid as its
   contentPadding instead, so pages fill the full screen and items
   scroll behind the bar layer. As the bar translates off-screen, the
   items previously hidden behind it become visible.

onPostFling still snaps a mid-way bar to the nearest edge using the
fling's tail velocity, and swallows residual velocity so parents don't
get a phantom kick.

Other screens that use `Modifier.padding(it)` around a scrollable (e.g.
NotificationScreen, Video, Search) still have the strip when their
bars hide; each will need the same "padding on inner LazyColumn"
migration. Left for follow-ups.

https://claude.ai/code/session_01M3Bj24jLc9aVhMuvn55jXa
This commit is contained in:
Claude
2026-04-18 14:22:24 +00:00
parent 6c7e2bd67f
commit f9cc86f5af
3 changed files with 81 additions and 43 deletions
@@ -26,32 +26,38 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
/**
* Single nested-scroll connection that hides/reveals the top and bottom bars together.
* Scroll-linked connection that hides/reveals the top and bottom bars together.
*
* Behaviour:
* - onPreScroll: consumes the portion of the scroll delta used to move the bars
* (both hide and reveal), returning the exact amount absorbed so the list never
* "loses pixels" at the edge of the bars' travel range.
* - onPostFling: snaps a mid-way bar to the nearest edge, continuing the fling's
* tail velocity so the settle motion feels like part of the fling, not a second
* animation after it. No velocity is returned upward.
* Philosophy: the bars never consume scroll input. They simply ride along with the
* content — their offset changes at the same rate as the scroll, so the user keeps full
* control of the list with their finger. This mirrors the behaviour of Twitter, Instagram,
* Bluesky, etc., where content scrolling is never delayed by the chrome.
*
* - 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.
* - 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.
*/
class DisappearingBarNestedScroll(
private val state: DisappearingBarState,
private val canScroll: () -> Boolean,
private val reverseLayout: Boolean,
) : NestedScrollConnection {
override fun onPreScroll(
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!canScroll()) return Offset.Zero
if (available.y == 0f) return Offset.Zero
val totalY = consumed.y + available.y
if (totalY == 0f) return Offset.Zero
val deltaY = if (reverseLayout) -available.y else available.y
val applied = applyDelta(deltaY)
if (applied == 0f) return Offset.Zero
return Offset(0f, if (reverseLayout) -applied else applied)
val deltaY = if (reverseLayout) -totalY else totalY
applyDelta(deltaY)
// Never consume: the content scrolls freely while the bars slide along.
return Offset.Zero
}
override suspend fun onPostFling(
@@ -59,36 +65,16 @@ class DisappearingBarNestedScroll(
available: Velocity,
): Velocity {
if (canScroll()) {
// Feed the fling's remaining velocity into the settle so the bar keeps
// moving in the same direction rather than starting a fresh animation.
val velocityY = if (reverseLayout) -available.y else available.y
state.settleToNearestEdge(initialVelocityY = velocityY)
}
// Swallow any residual velocity so parents don't get a phantom fling kick.
return Velocity.Zero
}
/**
* Applies the given delta (in content-space negative hides, positive reveals) to
* both bar offsets, clamped to their travel range.
*
* Returns the delta that was actually absorbed, using the side with the larger
* absorption so consumption reporting remains accurate when the two bars have
* different remaining travel.
*/
private fun applyDelta(deltaY: Float): Float {
val prevTop = state.topHeightOffset
val prevBottom = state.bottomHeightOffset
private fun applyDelta(deltaY: Float) {
val topLimit = state.topHeightLimit
val bottomLimit = state.bottomHeightLimit
val newTop = (prevTop + deltaY).coerceIn(-topLimit, 0f)
val newBottom = (prevBottom + deltaY).coerceIn(-bottomLimit, 0f)
state.topHeightOffset = newTop
state.bottomHeightOffset = newBottom
val topDelta = newTop - prevTop
val bottomDelta = newBottom - prevBottom
return if (deltaY < 0f) minOf(topDelta, bottomDelta) else maxOf(topDelta, bottomDelta)
state.topHeightOffset = (state.topHeightOffset + deltaY).coerceIn(-topLimit, 0f)
state.bottomHeightOffset = (state.bottomHeightOffset + deltaY).coerceIn(-bottomLimit, 0f)
}
}
@@ -23,7 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -52,6 +55,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -264,7 +268,7 @@ private fun DiscoverPages(
},
accountViewModel = accountViewModel,
) {
HorizontalPager(state = pagerState, contentPadding = it) { page ->
HorizontalPager(state = pagerState) { page ->
if (page >= 0 && page < feedTabs.size) {
val tab = feedTabs[page]
RefresheableBox(tab.feedState, true) {
@@ -275,6 +279,7 @@ private fun DiscoverPages(
routeForLastRead = tab.routeForLastRead,
forceEventKind = tab.forceEventKind,
listState = listState,
scaffoldPadding = it,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -286,6 +291,7 @@ private fun DiscoverPages(
routeForLastRead = tab.routeForLastRead,
forceEventKind = tab.forceEventKind,
listState = listState,
scaffoldPadding = it,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -303,6 +309,7 @@ private fun RenderDiscoverFeed(
routeForLastRead: String?,
forceEventKind: Int?,
listState: LazyGridState,
scaffoldPadding: PaddingValues,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -329,6 +336,7 @@ private fun RenderDiscoverFeed(
routeForLastRead,
listState,
forceEventKind,
scaffoldPadding,
accountViewModel,
nav,
)
@@ -391,6 +399,7 @@ private fun RenderDiscoverFeed(
routeForLastRead: String?,
forceEventKind: Int?,
listState: LazyListState,
scaffoldPadding: PaddingValues,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -417,6 +426,7 @@ private fun RenderDiscoverFeed(
routeForLastRead,
listState,
forceEventKind,
scaffoldPadding,
accountViewModel,
nav,
)
@@ -460,13 +470,25 @@ private fun DiscoverFeedLoaded(
routeForLastRead: String?,
listState: LazyListState,
forceEventKind: Int?,
scaffoldPadding: PaddingValues,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val layoutDirection = LocalLayoutDirection.current
val listPadding =
remember(scaffoldPadding, layoutDirection) {
PaddingValues(
start = scaffoldPadding.calculateStartPadding(layoutDirection),
top = scaffoldPadding.calculateTopPadding() + FeedPadding.calculateTopPadding(),
end = scaffoldPadding.calculateEndPadding(layoutDirection),
bottom = scaffoldPadding.calculateBottomPadding() + FeedPadding.calculateBottomPadding(),
)
}
LazyColumn(
contentPadding = FeedPadding,
contentPadding = listPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
@@ -495,14 +517,26 @@ private fun DiscoverFeedColumnsLoaded(
routeForLastRead: String?,
listState: LazyGridState,
forceEventKind: Int?,
scaffoldPadding: PaddingValues,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val layoutDirection = LocalLayoutDirection.current
val gridPadding =
remember(scaffoldPadding, layoutDirection) {
PaddingValues(
start = scaffoldPadding.calculateStartPadding(layoutDirection),
top = scaffoldPadding.calculateTopPadding() + FeedPadding.calculateTopPadding(),
end = scaffoldPadding.calculateEndPadding(layoutDirection),
bottom = scaffoldPadding.calculateBottomPadding() + FeedPadding.calculateBottomPadding(),
)
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = FeedPadding,
contentPadding = gridPadding,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
@@ -24,8 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
@@ -51,7 +54,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -211,7 +216,6 @@ private fun HomePages(
accountViewModel = accountViewModel,
) {
HorizontalPager(
contentPadding = it,
state = pagerState,
userScrollEnabled = true,
modifier =
@@ -225,6 +229,7 @@ private fun HomePages(
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey,
liveSection = tabs[page].liveSection,
scaffoldPadding = it,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -278,6 +283,7 @@ fun HomeFeeds(
enablePullRefresh: Boolean = true,
scrollStateKey: String? = null,
liveSection: ChannelFeedContentState? = null,
scaffoldPadding: PaddingValues = PaddingValues(0.dp),
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -289,7 +295,7 @@ fun HomeFeeds(
listState = listState,
nav = nav,
routeForLastRead = routeForLastRead,
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, scaffoldPadding, accountViewModel, nav) },
onEmpty = { HomeFeedEmpty(feedState::invalidateData) },
)
}
@@ -303,13 +309,25 @@ fun FeedLoaded(
listState: LazyListState,
routeForLastRead: String?,
liveSection: ChannelFeedContentState? = null,
scaffoldPadding: PaddingValues = PaddingValues(0.dp),
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val layoutDirection = LocalLayoutDirection.current
val listPadding =
remember(scaffoldPadding, layoutDirection) {
PaddingValues(
start = scaffoldPadding.calculateStartPadding(layoutDirection),
top = scaffoldPadding.calculateTopPadding() + FeedPadding.calculateTopPadding(),
end = scaffoldPadding.calculateEndPadding(layoutDirection),
bottom = scaffoldPadding.calculateBottomPadding() + FeedPadding.calculateBottomPadding(),
)
}
LazyColumn(
contentPadding = FeedPadding,
contentPadding = listPadding,
state = listState,
) {
if (liveSection != null) {