Timeline : improve auto-scroll
This commit is contained in:
parent
d7aef3818a
commit
8f2afbd797
4 changed files with 87 additions and 47 deletions
|
|
@ -22,21 +22,25 @@ import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
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.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val backPaginationEventLimit = 20
|
private const val backPaginationEventLimit = 20
|
||||||
|
|
@ -45,42 +49,57 @@ private const val backPaginationPageSize = 50
|
||||||
class TimelinePresenter @Inject constructor(
|
class TimelinePresenter @Inject constructor(
|
||||||
private val timelineItemsFactory: TimelineItemsFactory,
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val appScope: CoroutineScope,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
private val timeline = room.timeline
|
private val timeline = room.timeline
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineState {
|
override fun present(): TimelineState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localScope = rememberCoroutineScope()
|
||||||
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
var lastReadReceiptIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
||||||
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
|
var lastReadReceiptId by rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||||
|
|
||||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||||
val paginationState by timeline.paginationState.collectAsState()
|
val paginationState by timeline.paginationState.collectAsState()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
|
|
||||||
|
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val hasNewItems = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun CoroutineScope.sendReadReceiptIfNeeded(firstVisibleIndex: Int) = launch(dispatchers.computation) {
|
||||||
|
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
||||||
|
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
|
||||||
|
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex && eventId != lastReadReceiptId) {
|
||||||
|
lastReadReceiptIndex = firstVisibleIndex
|
||||||
|
lastReadReceiptId = eventId
|
||||||
|
timeline.sendReadReceipt(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
|
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||||
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
|
||||||
is TimelineEvents.OnScrollFinished -> {
|
is TimelineEvents.OnScrollFinished -> {
|
||||||
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
|
if (event.firstIndex == 0) {
|
||||||
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
|
hasNewItems.value = false
|
||||||
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
|
|
||||||
lastReadMarkerIndex = event.firstIndex
|
|
||||||
lastReadMarkerId = eventId
|
|
||||||
localCoroutineScope.sendReadReceipt(eventId)
|
|
||||||
}
|
}
|
||||||
|
appScope.sendReadReceiptIfNeeded(event.firstIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(timelineItems.size) {
|
||||||
|
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
timeline
|
timeline
|
||||||
.timelineItems
|
.timelineItems
|
||||||
|
|
@ -98,10 +117,26 @@ class TimelinePresenter @Inject constructor(
|
||||||
canReply = userHasPermissionToSendMessage,
|
canReply = userHasPermissionToSendMessage,
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
|
hasNewItems = hasNewItems.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun computeHasNewItems(
|
||||||
|
timelineItems: ImmutableList<TimelineItem>,
|
||||||
|
prevMostRecentItemId: MutableState<String?>,
|
||||||
|
hasNewItemsState: MutableState<Boolean>
|
||||||
|
) = withContext(dispatchers.computation) {
|
||||||
|
val newMostRecentItem = timelineItems.firstOrNull()
|
||||||
|
val prevMostRecentItemIdValue = prevMostRecentItemId.value
|
||||||
|
val newMostRecentItemId = newMostRecentItem?.identifier()
|
||||||
|
hasNewItemsState.value = prevMostRecentItemIdValue != null &&
|
||||||
|
newMostRecentItem is TimelineItem.Event &&
|
||||||
|
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
|
||||||
|
newMostRecentItemId != prevMostRecentItemIdValue
|
||||||
|
prevMostRecentItemId.value = newMostRecentItemId
|
||||||
|
}
|
||||||
|
|
||||||
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
|
||||||
for (item in items.subList(index, items.count())) {
|
for (item in items.subList(index, items.count())) {
|
||||||
if (item is TimelineItem.Event) {
|
if (item is TimelineItem.Event) {
|
||||||
|
|
@ -114,8 +149,4 @@ class TimelinePresenter @Inject constructor(
|
||||||
private fun CoroutineScope.paginateBackwards() = launch {
|
private fun CoroutineScope.paginateBackwards() = launch {
|
||||||
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
|
|
||||||
timeline.sendReadReceipt(eventId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,6 @@ data class TimelineState(
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
val canReply: Boolean,
|
val canReply: Boolean,
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
|
val hasNewItems: Boolean,
|
||||||
val eventSink: (TimelineEvents) -> Unit
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
||||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
|
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
eventSink = {}
|
hasNewItems = false,
|
||||||
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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.shadow
|
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -72,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
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.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -141,8 +139,8 @@ fun TimelineView(
|
||||||
|
|
||||||
TimelineScrollHelper(
|
TimelineScrollHelper(
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
timelineItems = state.timelineItems,
|
hasNewItems = state.hasNewItems,
|
||||||
onScrollFinishedAt = ::onScrollFinishedAt,
|
onScrollFinishedAt = ::onScrollFinishedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,53 +236,62 @@ fun TimelineItemRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BoxScope.TimelineScrollHelper(
|
private fun BoxScope.TimelineScrollHelper(
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
timelineItems: ImmutableList<TimelineItem>,
|
hasNewItems: Boolean,
|
||||||
onScrollFinishedAt: (Int) -> Unit = {},
|
onScrollFinishedAt: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
|
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
|
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
|
||||||
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
|
|
||||||
|
|
||||||
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
|
LaunchedEffect(canAutoScroll, hasNewItems) {
|
||||||
if (!isScrollFinished) return@LaunchedEffect
|
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
|
||||||
|
if (shouldAutoScroll) {
|
||||||
// Auto-scroll when new timeline items appear
|
|
||||||
if (shouldAutoScrollToBottom) {
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItem(0)
|
lazyListState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(isScrollFinished) {
|
|
||||||
if (!isScrollFinished) return@LaunchedEffect
|
|
||||||
|
|
||||||
// Notify the parent composable about the first visible item index when scrolling finishes
|
LaunchedEffect(isScrollFinished) {
|
||||||
onScrollFinishedAt(firstVisibleItemIndex)
|
if (isScrollFinished) {
|
||||||
|
// Notify the parent composable about the first visible item index when scrolling finishes
|
||||||
|
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump to bottom button (display also in previews)
|
JumpToBottomButton(
|
||||||
AnimatedVisibility(
|
isVisible = !canAutoScroll,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
visible = showScrollToBottomButton || LocalInspectionMode.current,
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (lazyListState.firstVisibleItemIndex > 10) {
|
||||||
|
lazyListState.scrollToItem(0)
|
||||||
|
} else {
|
||||||
|
lazyListState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JumpToBottomButton(
|
||||||
|
isVisible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
modifier = modifier,
|
||||||
|
visible = isVisible || LocalInspectionMode.current,
|
||||||
enter = scaleIn(animationSpec = tween(100)),
|
enter = scaleIn(animationSpec = tween(100)),
|
||||||
exit = scaleOut(animationSpec = tween(100)),
|
exit = scaleOut(animationSpec = tween(100)),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = onClick,
|
||||||
coroutineScope.launch {
|
|
||||||
if (firstVisibleItemIndex > 10) {
|
|
||||||
lazyListState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue