mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
refactor(feeds): hoist auto-stick into Saveable* wrappers
Pulls StickToTopOnPrepend out of every per-feed callsite and into SaveableFeedContentState, SaveableGridFeedContentState, SaveableFeedState, and SaveableGridFeedState — the same wrappers that already own WatchScrollToTop. A new FeedContentState-keyed overload derives the head key from feedContent → Loaded.feed → list.firstOrNull()?.idHex, so the wrappers can wire auto-stick without any per-feed plumbing. Removes the explicit StickToTopOnPrepend calls from FeedLoaded, PictureFeedLoaded, ArticlesFeedLoaded, NestsFeedLoaded, WebBookmarksFeedLoaded, GalleryFeedLoaded, DiscoverFeedLoaded, DiscoverFeedColumnsLoaded, and ChatroomListFeedView — they all consume listStates created by one of the four wrappers above. Kept as explicit calls: - UserFeedView (custom listState, no wrapper) - CardFeedView (CardFeedContentState — different type) - TabNotesNewThreads (custom listState, no wrapper) - BrowseEmojiSetsScreen (doesn't use SaveableGridFeedContentState) Also extracts the list/grid bodies to a private stickToTopOnPrepend core that takes the state object as the LaunchedEffect key plus lambdas for sampling / scroll, and switches the cached flag from mutableStateOf to a plain BooleanArray holder (read only from effects, never composition — no snapshot tracking needed). Adds the missing "why isScrollInProgress gating is safe" line to the KDoc.
This commit is contained in:
@@ -63,6 +63,7 @@ fun SaveableFeedContentState(
|
||||
}
|
||||
|
||||
WatchScrollToTop(feedContentState, listState)
|
||||
StickToTopOnPrepend(feedContentState, listState)
|
||||
|
||||
content(listState)
|
||||
}
|
||||
@@ -81,6 +82,7 @@ fun SaveableGridFeedContentState(
|
||||
}
|
||||
|
||||
WatchScrollToTop(feedContentState, gridState)
|
||||
StickToTopOnPrepend(feedContentState, gridState)
|
||||
|
||||
content(gridState)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ fun FeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
|
||||
@@ -26,11 +26,16 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun WatchScrollToTop(
|
||||
@@ -107,37 +112,40 @@ fun WatchScrollToTop(
|
||||
*
|
||||
* The trick: track "was at top" continuously via [snapshotFlow], but
|
||||
* only flip it from true → false when [LazyListState.isScrollInProgress]
|
||||
* is true (i.e. the user is actively scrolling). Data-driven index
|
||||
* shifts happen with `isScrollInProgress == false`, so they never poison
|
||||
* the cached value. When [firstItemKey] changes (head of the list moved),
|
||||
* if the cached value is still true, snap back to 0 with an instant
|
||||
* (non-animated) scroll so the prepend appears as in-place growth rather
|
||||
* than a visible jump-then-scroll.
|
||||
* is true (i.e. the user is actively scrolling). Compose's keyed-item
|
||||
* shift after a data update does not set that flag — only real touch
|
||||
* gestures and `animate*` calls do — so data-driven index shifts can
|
||||
* never poison the cached value. When [firstItemKey] changes (head of
|
||||
* the list moved), if the cached value is still true, snap back to 0
|
||||
* with an instant (non-animated) scroll so the prepend appears as
|
||||
* in-place growth rather than a visible jump-then-scroll.
|
||||
*
|
||||
* Most callers should not invoke this directly: [SaveableFeedContentState],
|
||||
* [SaveableGridFeedContentState], and the analogous wrappers in
|
||||
* `ui/screen/FeedView.kt` already apply auto-stick to every feed they
|
||||
* own. Invoke the explicit overload only when the listState is
|
||||
* constructed outside one of those wrappers, or when the key that
|
||||
* should trigger the snap is not the default `items.list[0].idHex`
|
||||
* (e.g. notifications, chats, or feeds keyed on something other than a
|
||||
* Note's hex id).
|
||||
*/
|
||||
@Composable
|
||||
fun StickToTopOnPrepend(
|
||||
listState: LazyListState,
|
||||
firstItemKey: Any?,
|
||||
) {
|
||||
val wasAtTop = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}.collect { atTop ->
|
||||
if (atTop) {
|
||||
wasAtTop.value = true
|
||||
} else if (listState.isScrollInProgress) {
|
||||
wasAtTop.value = false
|
||||
stickToTopOnPrepend(
|
||||
stateKey = listState,
|
||||
firstItemKey = firstItemKey,
|
||||
sampler = {
|
||||
snapshotFlow {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(firstItemKey) {
|
||||
if (firstItemKey != null && wasAtTop.value && listState.firstVisibleItemIndex > 0) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
isScrollInProgress = { listState.isScrollInProgress },
|
||||
firstVisibleItemIndex = { listState.firstVisibleItemIndex },
|
||||
scrollToTop = { listState.scrollToItem(0) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -145,23 +153,84 @@ fun StickToTopOnPrepend(
|
||||
gridState: LazyGridState,
|
||||
firstItemKey: Any?,
|
||||
) {
|
||||
val wasAtTop = remember { mutableStateOf(true) }
|
||||
stickToTopOnPrepend(
|
||||
stateKey = gridState,
|
||||
firstItemKey = firstItemKey,
|
||||
sampler = {
|
||||
snapshotFlow {
|
||||
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
},
|
||||
isScrollInProgress = { gridState.isScrollInProgress },
|
||||
firstVisibleItemIndex = { gridState.firstVisibleItemIndex },
|
||||
scrollToTop = { gridState.scrollToItem(0) },
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow {
|
||||
gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0
|
||||
}.collect { atTop ->
|
||||
/**
|
||||
* Auto-stick wired straight to a [FeedContentState]: derives the head
|
||||
* key from `feedContent → Loaded.feed → list.firstOrNull()?.idHex` so
|
||||
* callers don't have to collect the inner feed flow themselves. Used
|
||||
* by the Saveable* wrappers; suitable for any Note-keyed feed.
|
||||
*/
|
||||
@Composable
|
||||
fun StickToTopOnPrepend(
|
||||
feedContentState: FeedContentState,
|
||||
listState: LazyListState,
|
||||
) {
|
||||
StickToTopOnPrepend(listState, rememberFirstItemIdHex(feedContentState))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StickToTopOnPrepend(
|
||||
feedContentState: FeedContentState,
|
||||
gridState: LazyGridState,
|
||||
) {
|
||||
StickToTopOnPrepend(gridState, rememberFirstItemIdHex(feedContentState))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? {
|
||||
val flow =
|
||||
remember(feedContentState) {
|
||||
feedContentState.feedContent.flatMapLatest { state ->
|
||||
when (state) {
|
||||
is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex }
|
||||
else -> flowOf(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
val key by flow.collectAsStateWithLifecycle(initialValue = null)
|
||||
return key
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun stickToTopOnPrepend(
|
||||
stateKey: Any,
|
||||
firstItemKey: Any?,
|
||||
sampler: () -> Flow<Boolean>,
|
||||
isScrollInProgress: () -> Boolean,
|
||||
firstVisibleItemIndex: () -> Int,
|
||||
scrollToTop: suspend () -> Unit,
|
||||
) {
|
||||
// Plain holder instead of mutableStateOf — we only read this inside
|
||||
// effects, never in composition, so we don't need snapshot tracking.
|
||||
val wasAtTop = remember { booleanArrayOf(true) }
|
||||
|
||||
LaunchedEffect(stateKey) {
|
||||
sampler().collect { atTop ->
|
||||
if (atTop) {
|
||||
wasAtTop.value = true
|
||||
} else if (gridState.isScrollInProgress) {
|
||||
wasAtTop.value = false
|
||||
wasAtTop[0] = true
|
||||
} else if (isScrollInProgress()) {
|
||||
wasAtTop[0] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(firstItemKey) {
|
||||
if (firstItemKey != null && wasAtTop.value && gridState.firstVisibleItemIndex > 0) {
|
||||
gridState.scrollToItem(0)
|
||||
if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) {
|
||||
scrollToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
|
||||
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState
|
||||
@@ -72,6 +73,7 @@ fun SaveableFeedState(
|
||||
}
|
||||
|
||||
WatchScrollToTop(feedContentState, listState)
|
||||
StickToTopOnPrepend(feedContentState, listState)
|
||||
|
||||
content(listState)
|
||||
}
|
||||
@@ -90,6 +92,7 @@ fun SaveableGridFeedState(
|
||||
}
|
||||
|
||||
WatchScrollToTop(viewModel.feedState, gridState)
|
||||
StickToTopOnPrepend(viewModel.feedState, gridState)
|
||||
|
||||
content(gridState)
|
||||
}
|
||||
|
||||
-3
@@ -31,7 +31,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -49,8 +48,6 @@ fun ArticlesFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
|
||||
-3
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -115,8 +114,6 @@ private fun FeedLoaded(
|
||||
|
||||
val myPubKey = accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.let { chatroomLazyKey(it, myPubKey) })
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
|
||||
-5
@@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableGridFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
@@ -477,8 +476,6 @@ private fun DiscoverFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
@@ -514,8 +511,6 @@ private fun DiscoverFeedColumnsLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
|
||||
-3
@@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -128,8 +127,6 @@ fun NestsFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
// The DAL emits items pre-sorted in bucket order (LIVE → SCHEDULED
|
||||
// → ENDED). Walk the list once to find bucket boundaries so we
|
||||
// can inject sticky section headers without re-sorting on every
|
||||
|
||||
-3
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -49,8 +48,6 @@ fun PictureFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
|
||||
-3
@@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -92,8 +91,6 @@ private fun GalleryFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
val ratio =
|
||||
if (accountViewModel.settings.modernGalleryStyle()) {
|
||||
0.8f
|
||||
|
||||
-3
@@ -74,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
@@ -192,8 +191,6 @@ private fun WebBookmarksFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
StickToTopOnPrepend(listState, items.list.firstOrNull()?.idHex)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
|
||||
Reference in New Issue
Block a user