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:
+23
-37
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-3
@@ -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 ->
|
||||
|
||||
+21
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user