From cd20dc0a04336e5ea6e44c890fa9139ee625031a Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:11:53 +0700 Subject: [PATCH] android, desktop: enhancements to floating buttons (#5763) * android, desktop: enhancements to floating buttons * size * size --- .../simplex/common/views/chat/ChatView.kt | 242 +++++++++++++----- 1 file changed, 178 insertions(+), 64 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 46965d07fc..94a5fd3549 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1185,6 +1185,22 @@ fun BoxScope.ChatItemsList( developerTools: Boolean, showViaProxy: Boolean ) { + val loadingTopItems = remember { mutableStateOf(false) } + val loadingBottomItems = remember { mutableStateOf(false) } + // just for changing local var here based on request + val loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit = { chatId, pagination, visibleItemIndexesNonReversed -> + val loadingSide = when (pagination) { + is ChatPagination.Before -> loadingTopItems + is ChatPagination.Last -> loadingBottomItems + is ChatPagination.After, is ChatPagination.Around, is ChatPagination.Initial -> null + } + loadingSide?.value = true + try { + loadMessages(chatId, pagination, visibleItemIndexesNonReversed) + } finally { + loadingSide?.value = false + } + } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } @@ -1582,7 +1598,25 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(reversedChatItems, chatInfoUpdated, topPaddingToContent, topPaddingToContentPx, contentTag, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState, loadMessages) + FloatingButtons( + reversedChatItems, + chatInfoUpdated, + topPaddingToContent, + topPaddingToContentPx, + contentTag, + loadingMoreItems, + loadingTopItems, + loadingBottomItems, + animatedScrollingInProgress, + mergedItems, + unreadCount, + maxHeight, + composeViewHeight, + searchValue, + markChatRead, + listState, + loadMessages + ) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) LaunchedEffect(Unit) { @@ -1607,14 +1641,17 @@ private suspend fun loadLastItems(chatId: State, contentTag: MsgContentT val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size if (!itemsCanCoverScreen) return - val chatState = chatModel.chatStateForContent(contentTag) - val lastItemsLoaded = chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id - if (lastItemsLoaded) return + if (lastItemsLoaded(contentTag)) return delay(500) loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } +private fun lastItemsLoaded(contentTag: MsgContentTag?): Boolean { + val chatState = chatModel.chatStateForContent(contentTag) + return chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id +} + // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message // can be reproduced by forwarding a message to notes that is (ChatPagination.INITIAL_COUNT - 1) away from bottom and going to that message @Composable @@ -1681,6 +1718,8 @@ fun BoxScope.FloatingButtons( topPaddingToContentPx: State, contentTag: MsgContentTag?, loadingMoreItems: MutableState, + loadingTopItems: MutableState, + loadingBottomItems: MutableState, animatedScrollingInProgress: MutableState, mergedItems: State, unreadCount: State, @@ -1692,6 +1731,40 @@ fun BoxScope.FloatingButtons( loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit ) { val scope = rememberCoroutineScope() + fun scrollToBottom() { + scope.launch { + animatedScrollingInProgress.value = true + tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + } + } + fun scrollToTopUnread() { + scope.launch { + tryBlockAndSetLoadingMore(loadingMoreItems) { + if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { + val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + val oldSize = reversedChatItems.value.size + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) + } + var repeatsLeft = 100 + while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { + delay(10) + repeatsLeft-- + } + if (oldSize == reversedChatItems.value.size) { + return@tryBlockAndSetLoadingMore + } + } + val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + if (index != -1) { + // scroll to the top unread item + animatedScrollingInProgress.value = true + listState.value.animateScrollToItem(index + 1, -maxHeight.value) + } + } + } + } + val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1717,19 +1790,48 @@ fun BoxScope.FloatingButtons( allowToShowBottomWithArrow.value = shouldShow shouldShow && allow } } + + val requestedTopScroll = remember { mutableStateOf(false) } + val requestedBottomScroll = remember { mutableStateOf(false) } + BottomEndFloatingButton( bottomUnreadCount, showBottomButtonWithCounter, showBottomButtonWithArrow, + requestedBottomScroll, animatedScrollingInProgress, composeViewHeight, onClick = { - scope.launch { - animatedScrollingInProgress.value = true - tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } + if (loadingBottomItems.value || !lastItemsLoaded(contentTag)) { + requestedTopScroll.value = false + requestedBottomScroll.value = true + } else { + scrollToBottom() } } ) + LaunchedEffect(Unit) { + launch { + snapshotFlow { loadingTopItems.value } + .drop(1) + .collect { top -> + if (!top && requestedTopScroll.value) { + requestedTopScroll.value = false + scrollToTopUnread() + } + } + } + launch { + snapshotFlow { loadingBottomItems.value } + .drop(1) + .collect { bottom -> + if (!bottom && requestedBottomScroll.value) { + requestedBottomScroll.value = false + scrollToBottom() + } + } + } + } // Don't show top FAB if is in search if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp @@ -1741,33 +1843,15 @@ fun BoxScope.FloatingButtons( TopEndFloatingButton( Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, + requestedTopScroll, animatedScrollingInProgress, onClick = { - scope.launch { - tryBlockAndSetLoadingMore(loadingMoreItems) { - if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) { - val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) - val oldSize = reversedChatItems.value.size - loadMessages(chatInfo.value.id, pagination) { - visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) - } - var repeatsLeft = 100 - while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) { - delay(10) - repeatsLeft-- - } - if (oldSize == reversedChatItems.value.size) { - return@tryBlockAndSetLoadingMore - } - } - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index != -1) { - // scroll to the top unread item - animatedScrollingInProgress.value = true - listState.value.animateScrollToItem(index + 1, -maxHeight.value) - } - } - } + if (loadingTopItems.value) { + requestedBottomScroll.value = false + requestedTopScroll.value = true + } else { + scrollToTopUnread() + } }, onLongClick = { showDropDown.value = true } ) @@ -1896,6 +1980,7 @@ fun MemberImage(member: GroupMember) { private fun TopEndFloatingButton( modifier: Modifier = Modifier, unreadCount: State, + requestedTopScroll: State, animatedScrollingInProgress: State, onClick: () -> Unit, onLongClick: () -> Unit @@ -1909,11 +1994,15 @@ private fun TopEndFloatingButton( elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp), interactionSource = interactionSource, ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) + if (requestedTopScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } } } } @@ -2281,39 +2370,50 @@ private fun BoxScope.BottomEndFloatingButton( unreadCount: State, showButtonWithCounter: State, showButtonWithArrow: State, + requestedBottomScroll: State, animatedScrollingInProgress: State, composeViewHeight: State, onClick: () -> Unit -) = when { - showButtonWithCounter.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Text( - unreadCountStr(unreadCount.value), - color = MaterialTheme.colors.primary, - fontSize = 14.sp, - ) +) { + when { + showButtonWithCounter.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Text( + unreadCountStr(unreadCount.value), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } } - } - showButtonWithArrow.value && !animatedScrollingInProgress.value -> { - FloatingActionButton( - onClick = onClick, - elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), - modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), - backgroundColor = MaterialTheme.colors.secondaryVariant, - ) { - Icon( - painter = painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) + showButtonWithArrow.value && !animatedScrollingInProgress.value -> { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp), + backgroundColor = MaterialTheme.colors.secondaryVariant, + ) { + if (requestedBottomScroll.value) { + LoadingProgressIndicator() + } else { + Icon( + painter = painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } } + else -> {} } - else -> {} } @Composable @@ -2339,6 +2439,20 @@ fun SelectedListItem( ) } +@Composable +private fun LoadingProgressIndicator() { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.dp + ) + } +} + private fun selectUnselectChatItem( select: Boolean, ci: ChatItem,