Merge pull request #4399 from element-hq/feat/add-timeline-prefetching-mechanism
Add timeline item prefetching
This commit is contained in:
commit
f85abe27fc
5 changed files with 133 additions and 7 deletions
|
|
@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
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.designsystem.utils.animateScrollToItemCenter
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.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 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 kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineView(
|
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
|
// 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) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.nestedScroll(nestedScrollConnection),
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.testTag(TestTags.timeline),
|
||||||
state = lazyListState,
|
state = lazyListState,
|
||||||
reverseLayout = useReverseLayout,
|
reverseLayout = useReverseLayout,
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
|
@ -175,6 +194,11 @@ fun TimelineView(
|
||||||
onClearFocusRequestState = ::clearFocusRequestState
|
onClearFocusRequestState = ::clearFocusRequestState
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TimelinePrefetchingHelper(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
prefetch = ::prefetchMoreItems
|
||||||
|
)
|
||||||
|
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
hasAnyEvent = state.hasAnyEvent,
|
hasAnyEvent = state.hasAnyEvent,
|
||||||
lazyListState = lazyListState,
|
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
|
@Composable
|
||||||
private fun BoxScope.TimelineScrollHelper(
|
private fun BoxScope.TimelineScrollHelper(
|
||||||
hasAnyEvent: Boolean,
|
hasAnyEvent: Boolean,
|
||||||
|
|
@ -228,7 +292,7 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (lazyListState.firstVisibleItemIndex > 10) {
|
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||||
lazyListState.scrollToItem(0)
|
lazyListState.scrollToItem(0)
|
||||||
} else {
|
} else if (lazyListState.firstVisibleItemIndex != 0) {
|
||||||
lazyListState.animateScrollToItem(0)
|
lazyListState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.TimelineItemRoomBeginningModel
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||||
import io.element.android.features.messages.impl.typing.TypingNotificationView
|
import io.element.android.features.messages.impl.typing.TypingNotificationView
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemVirtualRow(
|
fun TimelineItemVirtualRow(
|
||||||
|
|
@ -45,6 +46,7 @@ fun TimelineItemVirtualRow(
|
||||||
TimelineLoadingMoreIndicator(virtual.model.direction)
|
TimelineLoadingMoreIndicator(virtual.model.direction)
|
||||||
val latestEventSink by rememberUpdatedState(eventSink)
|
val latestEventSink by rememberUpdatedState(eventSink)
|
||||||
LaunchedEffect(virtual.model.timestamp) {
|
LaunchedEffect(virtual.model.timestamp) {
|
||||||
|
Timber.d("Pagination triggered by load more indicator")
|
||||||
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
|
latestEventSink(TimelineEvents.LoadMore(virtual.model.direction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
|
||||||
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
|
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||||
|
|
@ -50,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
|
@ -126,6 +128,9 @@ class MessagesViewTest {
|
||||||
fun `clicking on an Event invoke expected callback`() {
|
fun `clicking on an Event invoke expected callback`() {
|
||||||
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
|
||||||
val state = aMessagesState(
|
val state = aMessagesState(
|
||||||
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
)
|
)
|
||||||
val timelineItem = state.timelineState.timelineItems.first()
|
val timelineItem = state.timelineState.timelineItems.first()
|
||||||
|
|
@ -182,6 +187,9 @@ class MessagesViewTest {
|
||||||
canSendReaction = userHasPermissionToSendReaction,
|
canSendReaction = userHasPermissionToSendReaction,
|
||||||
canPinUnpin = userCanPinEvent,
|
canPinUnpin = userCanPinEvent,
|
||||||
),
|
),
|
||||||
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
||||||
rule.setMessagesView(
|
rule.setMessagesView(
|
||||||
|
|
@ -349,7 +357,10 @@ class MessagesViewTest {
|
||||||
fun `clicking on a reaction emits the expected Event`() {
|
fun `clicking on a reaction emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<MessagesEvents>()
|
val eventsRecorder = EventsRecorder<MessagesEvents>()
|
||||||
val state = aMessagesState(
|
val state = aMessagesState(
|
||||||
eventSink = eventsRecorder
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
)
|
)
|
||||||
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
||||||
rule.setMessagesView(
|
rule.setMessagesView(
|
||||||
|
|
@ -363,6 +374,9 @@ class MessagesViewTest {
|
||||||
fun `long clicking on a reaction emits the expected Event`() {
|
fun `long clicking on a reaction emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<ReactionSummaryEvents>()
|
val eventsRecorder = EventsRecorder<ReactionSummaryEvents>()
|
||||||
val state = aMessagesState(
|
val state = aMessagesState(
|
||||||
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
reactionSummaryState = aReactionSummaryState(
|
reactionSummaryState = aReactionSummaryState(
|
||||||
target = null,
|
target = null,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
|
|
@ -380,6 +394,9 @@ class MessagesViewTest {
|
||||||
fun `clicking on more reaction emits the expected Event`() {
|
fun `clicking on more reaction emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
|
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
|
||||||
val state = aMessagesState(
|
val state = aMessagesState(
|
||||||
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
customReactionState = aCustomReactionState(
|
customReactionState = aCustomReactionState(
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
|
|
@ -396,7 +413,11 @@ class MessagesViewTest {
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on more reaction from action list emits the expected Event`() {
|
fun `clicking on more reaction from action list emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
|
val eventsRecorder = EventsRecorder<CustomReactionEvents>()
|
||||||
val state = aMessagesState()
|
val state = aMessagesState(
|
||||||
|
timelineState = aTimelineState(
|
||||||
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
|
),
|
||||||
|
)
|
||||||
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
|
||||||
val stateWithActionListState = state.copy(
|
val stateWithActionListState = state.copy(
|
||||||
actionListState = anActionListState(
|
actionListState = anActionListState(
|
||||||
|
|
@ -538,7 +559,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
||||||
onCreatePollClick = onCreatePollClick,
|
onCreatePollClick = onCreatePollClick,
|
||||||
onJoinCallClick = onJoinCallClick,
|
onJoinCallClick = onJoinCallClick,
|
||||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||||
knockRequestsBannerView = {}
|
knockRequestsBannerView = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,18 @@ import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollToIndex
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
|
||||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
@ -31,6 +35,7 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.setSafeContent
|
import io.element.android.tests.testutils.setSafeContent
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
|
|
@ -114,8 +119,6 @@ class TimelineViewTest {
|
||||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||||
eventsRecorder.assertList(
|
eventsRecorder.assertList(
|
||||||
listOf(
|
listOf(
|
||||||
TimelineEvents.OnScrollFinished(0),
|
|
||||||
TimelineEvents.OnScrollFinished(0),
|
|
||||||
TimelineEvents.OnScrollFinished(0),
|
TimelineEvents.OnScrollFinished(0),
|
||||||
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
|
TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)),
|
||||||
)
|
)
|
||||||
|
|
@ -135,6 +138,37 @@ class TimelineViewTest {
|
||||||
rule.clickOn(CommonStrings.action_ok)
|
rule.clickOn(CommonStrings.action_ok)
|
||||||
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
|
eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scrolling near to the start of the loaded items triggers a pre-fetch`() {
|
||||||
|
val eventsRecorder = EventsRecorder<TimelineEvents>()
|
||||||
|
val items = List<TimelineItem>(200) {
|
||||||
|
aTimelineItemEvent(
|
||||||
|
eventId = EventId("\$event_$it"),
|
||||||
|
content = aTimelineItemUnknownContent(),
|
||||||
|
)
|
||||||
|
}.toPersistentList()
|
||||||
|
|
||||||
|
rule.setTimelineView(
|
||||||
|
state = aTimelineState(
|
||||||
|
timelineItems = items,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
focusedEventIndex = -1,
|
||||||
|
isLive = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rule.onNodeWithTag("timeline").performScrollToIndex(180)
|
||||||
|
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
|
||||||
|
eventsRecorder.assertList(
|
||||||
|
listOf(
|
||||||
|
TimelineEvents.OnScrollFinished(firstIndex = 0),
|
||||||
|
TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView(
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,11 @@ object TestTags {
|
||||||
*/
|
*/
|
||||||
val floatingActionButton = TestTag("floating-action-button")
|
val floatingActionButton = TestTag("floating-action-button")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeline.
|
||||||
|
*/
|
||||||
|
val timeline = TestTag("timeline")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeline item.
|
* Timeline item.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue