Use snapshotFlow inside a LaunchedEffect, improve pre-fetching strategy

This commit is contained in:
Jorge Martín 2025-03-19 11:41:34 +01:00
parent f95a959ed1
commit 784f00feb8
2 changed files with 32 additions and 19 deletions

View file

@ -76,11 +76,16 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun TimelineView( fun TimelineView(
@ -139,6 +144,10 @@ fun TimelineView(
) )
} }
fun prefetchMoreItems() {
state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) { Box(modifier) {
@ -185,9 +194,10 @@ fun TimelineView(
onClearFocusRequestState = ::clearFocusRequestState onClearFocusRequestState = ::clearFocusRequestState
) )
TimelinePrefetchingHelper(lazyListState = lazyListState) { TimelinePrefetchingHelper(
state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) lazyListState = lazyListState,
} prefetch = ::prefetchMoreItems
)
TimelineScrollHelper( TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent, hasAnyEvent = state.hasAnyEvent,
@ -217,7 +227,7 @@ private fun MessageShieldDialog(state: TimelineState) {
) )
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
private fun TimelinePrefetchingHelper( private fun TimelinePrefetchingHelper(
lazyListState: LazyListState, lazyListState: LazyListState,
@ -225,25 +235,25 @@ private fun TimelinePrefetchingHelper(
) { ) {
val latestPrefetch by rememberUpdatedState(prefetch) val latestPrefetch by rememberUpdatedState(prefetch)
// We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough LaunchedEffect(Unit) {
val firstVisibleItemIndexFlow = snapshotFlow { // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough
lazyListState.firstVisibleItemIndex val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex }
} val layoutInfoFlow = snapshotFlow { lazyListState.layoutInfo }
val layoutInfoFlow = snapshotFlow { val isScrollingFlow = snapshotFlow { lazyListState.isScrollInProgress }
lazyListState.layoutInfo // This value changes too frequently, so we debounce it to avoid unnecessary prefetching. It's the equivalent of a conditional 'throttleLatest'
} .conflate()
val isScrollingFlow = snapshotFlow { .transform { isScrolling ->
lazyListState.isScrollInProgress emit(isScrolling)
} if (isScrolling) delay(100.milliseconds)
}
LaunchedEffect(latestPrefetch) {
val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex -> val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex ->
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
} }
combine( combine(
isCloseToStartOfLoadedTimelineFlow, isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
isScrollingFlow, isScrollingFlow.distinctUntilChanged(),
) { needsPrefetch, isScrolling -> ) { needsPrefetch, isScrolling ->
needsPrefetch && isScrolling needsPrefetch && isScrolling
} }

View file

@ -142,7 +142,7 @@ class TimelineViewTest {
@Test @Test
fun `scrolling near to the start of the loaded items triggers a pre-fetch`() { fun `scrolling near to the start of the loaded items triggers a pre-fetch`() {
val eventsRecorder = EventsRecorder<TimelineEvents>() val eventsRecorder = EventsRecorder<TimelineEvents>()
val items = List<TimelineItem>(20) { val items = List<TimelineItem>(200) {
aTimelineItemEvent( aTimelineItemEvent(
eventId = EventId("\$event_$it"), eventId = EventId("\$event_$it"),
content = aTimelineItemUnknownContent(), content = aTimelineItemUnknownContent(),
@ -158,7 +158,10 @@ class TimelineViewTest {
), ),
) )
rule.onNodeWithTag("timeline").performScrollToIndex(10) rule.onNodeWithTag("timeline").performScrollToIndex(180)
rule.mainClock.advanceTimeBy(1000)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
TimelineEvents.OnScrollFinished(firstIndex = 0), TimelineEvents.OnScrollFinished(firstIndex = 0),