android: Animated switch between chat and chatList (#1175)

* android: Animated switch between chat and chatList

* Correct animation

* Testing idea

* Revert "Testing idea"

This reverts commit ecda083883.

* Experiments

* Experiments

* Experiments

* Revert "Experiments"

This reverts commit 4390de1e92.

* Revert "Experiments"

This reverts commit 0b3048aeef.

* Revert "Experiments"

This reverts commit b692803cea.

* Merge

* Gorgeous animation performance

* Undo optimization

* Formatting

* Sharing

* Box

* Continue

* Launch on Main thread only specific call to WebView

* Launch on Main thread only specific call to WebView

* Temporary made withApi() running on Main thread only

* Unneeded code

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2022-11-14 15:35:38 +03:00
committed by GitHub
parent faceeb6fce
commit 9c925ab040
7 changed files with 99 additions and 56 deletions
@@ -9,6 +9,7 @@ import android.os.SystemClock.elapsedRealtime
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
@@ -19,7 +20,9 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import chat.simplex.app.model.ChatModel
@@ -36,6 +39,9 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
class MainActivity: FragmentActivity() {
companion object {
@@ -317,14 +323,58 @@ fun MainPage(
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else {
showAdvertiseLAAlert = true
val stopped = chatModel.chatRunning.value == false
if (chatModel.chatId.value == null) {
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
BoxWithConstraints {
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
Modifier
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
) {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
}
}
launch {
snapshotFlow { chatModel.sharedContent.value }
.distinctUntilChanged()
.filter { it != null }
.collect {
chatModel.chatId.value = null
currentChatId = null
}
}
}
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
currentChatId?.let {
ChatView(it, chatModel, onComposed)
}
}
}
else ChatView(chatModel)
}
}
}
@@ -21,6 +21,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
@Stable
class ChatModel(val controller: ChatController) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val currentUser = mutableStateOf<User?>(null)
@@ -382,7 +383,7 @@ interface SomeChat {
val updatedAt: Instant
}
@Serializable
@Serializable @Stable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
@@ -1015,7 +1016,7 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
@@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.usersettings.NotificationsMode
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
@Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
@@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
withApi {
scope.launch {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
@@ -1,10 +1,8 @@
package chat.simplex.app.views.chat
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
@@ -52,8 +50,8 @@ import java.io.File
import kotlin.math.sign
@Composable
fun ChatView(chatModel: ChatModel) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) {
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
@@ -102,12 +99,11 @@ fun ChatView(chatModel: ChatModel) {
// 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
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
}
}
ChatLayout(
user,
chat,
unreadCount,
composeState,
@@ -120,7 +116,6 @@ fun ChatView(chatModel: ChatModel) {
}
},
attachmentOption,
scope,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
@@ -228,20 +223,19 @@ fun ChatView(chatModel: ChatModel) {
apiFindMessages(c.chatInfo, chatModel, value)
searchText.value = value
}
}
},
onComposed,
)
}
}
@Composable
fun ChatLayout(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>,
@@ -260,8 +254,10 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
) {
Surface(
val scope = rememberCoroutineScope()
Box(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
@@ -292,9 +288,9 @@ fun ChatLayout(
) { contentPadding ->
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
ChatItemsList(
user, chat, unreadCount, composeState, chatItems, searchValue,
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed,
)
}
}
@@ -445,7 +441,6 @@ val CIListStateSaver = run {
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
user: User,
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
@@ -461,11 +456,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
acceptCall: (Contact) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val cxt = LocalContext.current
ScrollToBottom(chat.id, listState)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
@@ -493,6 +487,16 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
LaunchedEffect(Unit) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
stopListening = true
}
}
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems) { i, cItem ->
CompositionLocalProvider(
@@ -555,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
} else {
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
} else { // direct message
@@ -570,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
}
}
@@ -717,7 +721,7 @@ fun PreloadItems(
.map {
val totalItemsNumber = it.totalItemsCount
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
totalItemsNumber
else
0
@@ -931,7 +935,6 @@ fun PreviewChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
@@ -941,7 +944,6 @@ fun PreviewChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
@@ -960,6 +962,7 @@ fun PreviewChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}
@@ -989,7 +992,6 @@ fun PreviewGroupChatLayout() {
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
val searchValue = remember { mutableStateOf("") }
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = chatItems,
@@ -999,7 +1001,6 @@ fun PreviewGroupChatLayout() {
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
scope = rememberCoroutineScope(),
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
@@ -1018,6 +1019,7 @@ fun PreviewGroupChatLayout() {
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
onComposed = {},
)
}
}
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import android.content.*
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -29,15 +28,11 @@ import kotlinx.datetime.Clock
@Composable
fun ChatItemView(
user: User,
cInfo: ChatInfo,
cItem: ChatItem,
composeState: MutableState<ComposeState>,
cxt: Context,
uriHandler: UriHandler? = null,
imageProvider: (() -> ImageGalleryProvider)? = null,
showMember: Boolean = false,
chatModelIncognito: Boolean,
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
@@ -46,6 +41,7 @@ fun ChatItemView(
scrollToItem: (Long) -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
@@ -70,7 +66,7 @@ fun ChatItemView(
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable fun ContentItem() {
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
@@ -95,13 +91,13 @@ fun ChatItemView(
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(cxt, cItem.text, filePath)
else -> shareText(cxt, cItem.content.text)
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
copyText(context, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
@@ -233,15 +229,12 @@ private fun showMsgDeliveryErrorAlert(description: String) {
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
@@ -256,13 +249,10 @@ fun PreviewChatItemView() {
fun PreviewChatItemViewDeletedContent() {
SimpleXTheme {
ChatItemView(
User.sampleData,
ChatInfo.Direct.sampleData,
ChatItem.getDeletedContentSampleData(),
useLinkPreviews = true,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
cxt = LocalContext.current,
chatModelIncognito = false,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
@@ -536,13 +536,11 @@ fun ChatListNavLinkLayout(
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Surface(modifier) {
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
@@ -2,4 +2,6 @@ package chat.simplex.app.views.helpers
import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)