Merge pull request #4399 from element-hq/feat/add-timeline-prefetching-mechanism

Add timeline item prefetching
This commit is contained in:
ganfra 2025-03-20 11:11:57 +01:00 committed by GitHub
commit f85abe27fc
5 changed files with 133 additions and 7 deletions

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@ -70,8 +71,21 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun TimelineView(
@ -130,13 +144,18 @@ 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
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
.nestedScroll(nestedScrollConnection)
.testTag(TestTags.timeline),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
@ -175,6 +194,11 @@ fun TimelineView(
onClearFocusRequestState = ::clearFocusRequestState
)
TimelinePrefetchingHelper(
lazyListState = lazyListState,
prefetch = ::prefetchMoreItems
)
TimelineScrollHelper(
hasAnyEvent = state.hasAnyEvent,
lazyListState = lazyListState,
@ -203,6 +227,46 @@ private fun MessageShieldDialog(state: TimelineState) {
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
private fun TimelinePrefetchingHelper(
lazyListState: LazyListState,
prefetch: () -> Unit,
) {
val latestPrefetch by rememberUpdatedState(prefetch)
LaunchedEffect(Unit) {
// We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough
val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex }
val layoutInfoFlow = snapshotFlow { lazyListState.layoutInfo }
val isScrollingFlow = snapshotFlow { lazyListState.isScrollInProgress }
// This value changes too frequently, so we debounce it to avoid unnecessary prefetching. It's the equivalent of a conditional 'throttleLatest'
.conflate()
.transform { isScrolling ->
emit(isScrolling)
if (isScrolling) delay(100.milliseconds)
}
val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex ->
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
}
combine(
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
isScrollingFlow.distinctUntilChanged(),
) { needsPrefetch, isScrolling ->
needsPrefetch && isScrolling
}
.distinctUntilChanged()
.collectLatest { needsPrefetch ->
if (needsPrefetch) {
Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items")
latestPrefetch()
}
}
}
}
@Composable
private fun BoxScope.TimelineScrollHelper(
hasAnyEvent: Boolean,
@ -228,7 +292,7 @@ private fun BoxScope.TimelineScrollHelper(
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
} else if (lazyListState.firstVisibleItemIndex != 0) {
lazyListState.animateScrollToItem(0)
}
}

View file

@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationView
import timber.log.Timber
@Composable
fun TimelineItemVirtualRow(
@ -45,6 +46,7 @@ fun TimelineItemVirtualRow(
TimelineLoadingMoreIndicator(virtual.model.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(virtual.model.timestamp) {
Timber.d("Pagination triggered by load more indicator")
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
}
}