From f9cc86f5af44c90a8b3e5a81611e2d490455df83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 14:22:24 +0000 Subject: [PATCH] fix(ui): scroll-linked bars, content renders behind hiding bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/layouts/DisappearingBarNestedScroll.kt | 60 +++++++------------ .../loggedIn/discover/DiscoverScreen.kt | 40 ++++++++++++- .../ui/screen/loggedIn/home/HomeScreen.kt | 24 +++++++- 3 files changed, 81 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt index 5d2db649d..acab585c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBarNestedScroll.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 4620d750a..dd047f583 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355..2e838b109 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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) {