Floating button with unread counter and go to bottom action (#929)

* Floating buttons with unread counters and go to bottom action

* Fixed marking read of long chats without preloaded messages

* Apply suggestions from code review

* Counters fix

* update button size/color

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2022-08-14 00:00:26 +03:00
committed by GitHub
parent aac80dacf7
commit 5d8d636adc
5 changed files with 251 additions and 39 deletions

View File

@@ -211,7 +211,7 @@ class ChatModel(val controller: ChatController) {
}
}
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null) {
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
val markedRead = markItemsReadInCurrentChat(cInfo, range)
// update preview
val chatIdx = getChatIndex(cInfo.id)
@@ -221,7 +221,7 @@ class ChatModel(val controller: ChatController) {
if (lastId != null) {
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = if (range != null) chat.chatStats.unreadCount - markedRead else 0,
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
// Can't use minUnreadItemId currently since chat items can have unread items between read items
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
)

View File

@@ -1279,8 +1279,8 @@ sealed class ChatPagination {
companion object {
const val INITIAL_COUNT = 100
const val PRELOAD_COUNT = 20
const val UNTIL_PRELOAD_COUNT = 10
const val PRELOAD_COUNT = 100
const val UNTIL_PRELOAD_COUNT = 50
}
}

View File

@@ -4,12 +4,14 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
@@ -24,8 +26,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
@@ -57,9 +58,18 @@ fun ChatView(chatModel: ChatModel) {
val chat = activeChat!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
composeView = {
if (chat.chatInfo.sendMsgEnabled) {
@@ -167,8 +177,8 @@ fun ChatView(chatModel: ChatModel) {
}
}
},
markRead = { range ->
chatModel.markChatItemsRead(chat.chatInfo, range)
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withApi {
chatModel.controller.apiChatRead(
@@ -186,6 +196,7 @@ fun ChatView(chatModel: ChatModel) {
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
@@ -203,7 +214,7 @@ fun ChatLayout(
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
) {
Surface(
Modifier
@@ -223,15 +234,19 @@ fun ChatLayout(
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = floatingButton.value,
) { contentPadding ->
BoxWithConstraints(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems,
ChatItemsList(
user, chat, unreadCount, composeState, chatItems,
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead)
receiveFile, joinGroup, acceptCall, markRead, floatingButton
)
}
}
}
@@ -337,6 +352,7 @@ val CIListStateSaver = run {
fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
useLinkPreviews: Boolean,
@@ -346,28 +362,20 @@ fun BoxWithConstraintsScope.ChatItemsList(
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
floatingButton: MutableState<@Composable () -> Unit>
) {
val firstVisibleOffset = -with(LocalDensity.current) { maxHeight.roundToPx() }
// Places first unread message at the top of a screen
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0),
initialFirstVisibleItemScrollOffset = firstVisibleOffset
)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
// Prevent scrolling to bottom on orientation change
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType) {
val firstUnreadIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0)
if (shouldAutoScroll && listState.firstVisibleItemIndex != firstUnreadIndex) {
scope.launch {
// Places first unread message at the top of a screen after moving from group to direct chat
listState.scrollToItem(firstUnreadIndex, firstVisibleOffset)
}
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
scope.launch { listState.scrollToItem(0) }
}
// Don't autoscroll next time until it will be needed
shouldAutoScroll = false
@@ -432,12 +440,89 @@ fun BoxWithConstraintsScope.ChatItemsList(
LaunchedEffect(cItem.id) {
scope.launch {
delay(750)
markRead(CC.ItemRange(cItem.id, cItem.id))
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
}
}
}
}
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, markRead, floatingButton, listState)
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>,
unreadCount: State<Int>,
minUnreadItemId: Long,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
floatingButton: MutableState<@Composable () -> Unit>,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
val bottomUnreadCount by remember { derivedStateOf {
chatItems.subList(
chatItems.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex,
chatItems.size
).count { it.isRcvNew } }
}
val firstItemIsVisible by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
floatingButton.value = bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
)
}
val fabSize = 56.dp
val topUnreadCount by remember {
derivedStateOf { unreadCount.value - bottomUnreadCount }
}
val showButtonWithCounter = topUnreadCount > 0
val height = with(LocalDensity.current) { maxHeight.toPx() }
var showDropDown by remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
topUnreadCount,
showButtonWithCounter,
onClick = { scope.launch { listState.animateScrollBy(height) } },
onLongClick = { showDropDown = true }
)
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
Modifier.width(220.dp),
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
) {
DropdownMenuItem(
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown = false
}
) {
Text(
generalGetString(R.string.mark_read),
maxLines = 1,
)
}
}
}
@Composable
@@ -480,6 +565,75 @@ fun MemberImage(member: GroupMember) {
ProfileImage(38.dp, member.memberProfile.image)
}
@Composable
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: Int,
showButtonWithCounter: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) = when {
showButtonWithCounter -> {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
modifier.size(48.dp),
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
else -> {
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
showButtonWithArrow: Boolean,
onClickArrowDown: () -> Unit,
onClickCounter: () -> Unit
): @Composable () -> Unit = when {
showButtonWithCounter -> {
{
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
}
showButtonWithArrow -> {
{
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
}
else -> {
{}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -507,6 +661,7 @@ fun PreviewChatLayout() {
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
ChatLayout(
user = User.sampleData,
chat = Chat(
@@ -514,6 +669,7 @@ fun PreviewChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
@@ -531,7 +687,7 @@ fun PreviewChatLayout() {
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _ -> },
markRead = { _, _ -> },
)
}
}
@@ -558,6 +714,7 @@ fun PreviewGroupChatLayout() {
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
)
)
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
ChatLayout(
user = User.sampleData,
chat = Chat(
@@ -565,6 +722,7 @@ fun PreviewGroupChatLayout() {
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
@@ -582,7 +740,7 @@ fun PreviewGroupChatLayout() {
startCall = {},
acceptCall = { _ -> },
addMembers = { _ -> },
markRead = { _ -> },
markRead = { _, _ -> },
)
}
}

View File

@@ -140,7 +140,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
contentAlignment = Alignment.Center
) {
Text(
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
unreadCountStr(n),
color = MaterialTheme.colors.onPrimary,
fontSize = 11.sp,
modifier = Modifier
@@ -163,6 +163,11 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
}
}
@Composable
fun unreadCountStr(n: Int): String {
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
}
@Composable
fun ChatStatusImage(chat: Chat) {
val s = chat.serverInfo.networkStatus

View File

@@ -16,7 +16,13 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.interaction.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
@@ -31,19 +37,19 @@ import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import chat.simplex.app.TAG
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
/**
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
* */
interface PressGestureScope : Density {
interface PressGestureScope: Density {
suspend fun tryAwaitRelease(): Boolean
}
@@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture(
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
@@ -81,7 +86,6 @@ suspend fun PointerInputScope.detectGesture(
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
// If onLongPress event is needed, cancel short press event
if (onLongPress != null)
pressScope.cancel()
@@ -138,7 +142,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
) {
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
return null
@@ -148,7 +151,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
): PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
@@ -176,3 +179,49 @@ private class PressGestureScopeImpl(
return isCanceled
}
}
/**
* Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing.
* Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]).
* Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable]
* */
@Composable
fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
var topLevelInteraction: Interaction? by remember { mutableStateOf(null) }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
topLevelInteraction = interaction
}
}
LaunchedEffect(topLevelInteraction is PressInteraction.Press) {
if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect
try {
withTimeout(longPressTimeoutMillis) {
while (isActive) {
delay(10)
when (topLevelInteraction) {
is PressInteraction.Press -> {}
is PressInteraction.Release -> {
onClick(); break
}
is PressInteraction.Cancel -> break
}
}
}
} catch (_: TimeoutCancellationException) {
// Long click happened
onLongClick()
} catch (ex: CancellationException) {
// Canceled coroutine + PressInteraction.Release == short click
if (topLevelInteraction is PressInteraction.Release)
onClick()
Log.e(TAG, ex.stackTraceToString())
} catch (ex: Exception) {
// Should never be called
Log.e(TAG, ex.stackTraceToString())
}
}
return interactionSource
}