Working shared element transition

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-07 13:50:50 +03:00
Unverified
parent ed92ab807d
commit ab58b5f22d
13 changed files with 388 additions and 83 deletions
+1
View File
@@ -37,6 +37,7 @@ kotlin {
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.activity.compose)
// NaCl box implementation for transport encryption (Android/JVM only)
implementation("org.purejava:tweetnacl-java:1.1.3")
}
@@ -0,0 +1,9 @@
package ru.fromchat.ui
import androidx.activity.compose.BackHandler as AndroidBackHandler
import androidx.compose.runtime.Composable
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
AndroidBackHandler(enabled = enabled, onBack = onBack)
}
@@ -28,7 +28,7 @@ import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.debug.DebugApiScreen
import ru.fromchat.ui.dm.DmScreen
import ru.fromchat.ui.dm.DmContainerScreen
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen
@@ -177,7 +177,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")?.toIntOrNull()
val userId = backStackEntry.savedStateHandle.get<String>("userId")?.toIntOrNull()
ProfileScreen(
userId = userId,
onBack = { navController.navigateUp() },
@@ -186,9 +186,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
composable("dm/{otherUserId}") { backStackEntry ->
val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0
DmScreen(
otherUserId = otherUserId
val otherUserId = backStackEntry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
DmContainerScreen(
otherUserId = otherUserId,
onBack = { navController.navigateUp() }
)
}
@@ -0,0 +1,11 @@
package ru.fromchat.ui
import androidx.compose.runtime.Composable
/**
* Handles system back when [enabled]. When enabled, [onBack] is invoked instead of default back.
* On Android: uses BackHandler (supports predictive back when app opts in).
* On iOS: no-op.
*/
@Composable
expect fun BackHandler(enabled: Boolean, onBack: () -> Unit)
@@ -24,7 +24,8 @@ data class ChatPanelState(
val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList(),
val titleAvatar: AvatarInfo? = null
val titleAvatar: AvatarInfo? = null,
val profileUserId: Int? = null
)
@Serializable
@@ -1,10 +1,17 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
@@ -44,7 +52,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -69,7 +80,6 @@ import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -77,7 +87,14 @@ fun ChatScreen(
panel: ChatPanel,
currentUserId: Int?,
modifier: Modifier = Modifier,
scrollToMessageId: Int? = null
scrollToMessageId: Int? = null,
onTitleClick: (() -> Unit)? = null,
hideTitleBarAvatar: Boolean = false,
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
onTitleAvatarChange: ((AvatarInfo?) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null
) {
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
@@ -98,10 +115,15 @@ fun ChatScreen(
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
}
LaunchedEffect(panelState.titleAvatar) {
onTitleAvatarChange?.invoke(panelState.titleAvatar)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
@@ -196,12 +218,26 @@ fun ChatScreen(
}
}
// Scroll to bottom when new messages arrive
// Scroll to bottom when new messages arrive.
// - Initial composition: jump (no animation) to avoid jank.
// - Subsequent messages: only auto-scroll if user is already near bottom.
var didInitialScroll by remember(panel) { mutableStateOf(false) }
LaunchedEffect(panelState.messages.size) {
if (panelState.messages.isNotEmpty()) {
scope.launch {
listState.animateScrollToItem(panelState.messages.size - 1)
}
if (panelState.messages.isEmpty()) return@LaunchedEffect
val lastMessageIndex = panelState.messages.size // account for top spacer item at index 0
if (!didInitialScroll) {
didInitialScroll = true
listState.scrollToItem(lastMessageIndex)
return@LaunchedEffect
}
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
val isNearBottom = lastVisibleIndex >= (totalItems - 3)
if (isNearBottom) {
listState.animateScrollToItem(lastMessageIndex)
}
}
@@ -210,16 +246,95 @@ fun ChatScreen(
topBar = {
TopAppBar(
title = {
val titleInteractionSource = remember { MutableInteractionSource() }
var titlePressed by remember { mutableStateOf(false) }
LaunchedEffect(titleInteractionSource) {
titleInteractionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> titlePressed = true
is PressInteraction.Release -> titlePressed = false
}
}
}
val titleScale by animateFloatAsState(
targetValue = if (titlePressed) 0.96f else 1f,
animationSpec = tween(durationMillis = 100),
label = "title_scale"
)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
scaleX = titleScale,
scaleY = titleScale,
transformOrigin = TransformOrigin.Center
)
.then(
if (profileUserId != null && onTitleClick != null) {
Modifier.clickable(
interactionSource = titleInteractionSource,
indication = null
) { onTitleClick() }
} else Modifier
),
verticalAlignment = Alignment.CenterVertically
) {
panelState.titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
size = 36.dp
)
Spacer(modifier = Modifier.width(8.dp))
when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
?: "?"
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
size = 36.dp,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
)
.size(36.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
!hideTitleBarAvatar -> {
panelState.titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
size = 36.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
panelState.titleAvatar?.let {
Spacer(modifier = Modifier.width(36.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
}
Column(Modifier.fillMaxWidth()) {
@@ -0,0 +1,72 @@
package ru.fromchat.ui.dm
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.profile.ProfileScreen
private const val TRANSITION_MS = 320
@Composable
fun DmContainerScreen(
otherUserId: Int,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
var showProfile by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val panel = remember(otherUserId) {
DmPanelCache.getOrCreate(otherUserId, scope)
}
BackHandler(enabled = showProfile) {
showProfile = false
}
// One stable key per DM to match the same avatar in chat + profile.
val sharedAvatarKey = remember(otherUserId) { "dm-avatar-$otherUserId" }
SharedTransitionLayout(modifier = modifier.fillMaxSize()) {
AnimatedContent(
targetState = showProfile,
transitionSpec = {
fadeIn(animationSpec = tween(TRANSITION_MS)) togetherWith
fadeOut(animationSpec = tween(TRANSITION_MS))
},
label = "dm_profile"
) { targetState ->
if (!targetState) {
DmScreen(
panel = panel,
modifier = Modifier.fillMaxSize(),
onTitleClick = { showProfile = true },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
sharedAvatarKey = sharedAvatarKey
)
} else {
ProfileScreen(
userId = otherUserId,
onBack = { showProfile = false },
onChat = { _ -> showProfile = false },
modifier = Modifier.fillMaxSize(),
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
sharedAvatarKey = sharedAvatarKey
)
}
}
}
}
@@ -38,8 +38,8 @@ class DmPanel(
private var otherProfilePicture: String? = null
init {
updateState { it.copy(title = "Direct message") }
coroutineScope.launch(Dispatchers.IO) {
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
coroutineScope.launch(Dispatchers.Default) {
runCatching {
ApiClient.getProfileById(otherUserId)
}.onSuccess { profile ->
@@ -52,7 +52,8 @@ class DmPanel(
titleAvatar = AvatarInfo(
displayName = displayName,
profilePictureUrl = otherProfilePicture
)
),
profileUserId = otherUserId
)
}
}
@@ -105,7 +106,7 @@ class DmPanel(
json.decodeFromJsonElement(DmEnvelope.serializer(), element)
}.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) {
addMessage(createMessage(envelope, plaintext))
@@ -0,0 +1,29 @@
package ru.fromchat.ui.dm
import kotlinx.coroutines.CoroutineScope
import ru.fromchat.api.ApiClient
/**
* Cache of DM panels by otherUserId so that when navigating back from profile
* we reuse the same panel and don't reload messages.
*/
object DmPanelCache {
private val panels = mutableMapOf<Int, DmPanel>()
fun getOrCreate(
otherUserId: Int,
scope: CoroutineScope
): DmPanel {
return panels.getOrPut(otherUserId) {
DmPanel(
otherUserId = otherUserId,
coroutineScope = scope,
currentUserId = ApiClient.user?.id
)
}
}
fun remove(otherUserId: Int) {
panels.remove(otherUserId)?.destroy()
}
}
@@ -1,32 +1,31 @@
package ru.fromchat.ui.dm
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
import androidx.compose.ui.geometry.Rect
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.chat.ChatScreen
@Composable
fun DmScreen(
otherUserId: Int,
modifier: Modifier = Modifier
panel: DmPanel,
modifier: Modifier = Modifier,
onTitleClick: (() -> Unit)? = null,
hideTitleBarAvatar: Boolean = false,
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
onTitleAvatarChange: ((ru.fromchat.ui.chat.AvatarInfo?) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null
) {
val coroutineScope = rememberCoroutineScope()
val currentUserId = ApiClient.user?.id
val panel = remember(otherUserId) {
DmPanel(
otherUserId = otherUserId,
coroutineScope = coroutineScope,
currentUserId = currentUserId
)
}
LaunchedEffect(otherUserId) {
coroutineScope.launch {
LaunchedEffect(panel) {
if (panel.getState().messages.isEmpty()) {
panel.loadMessages()
}
}
@@ -34,6 +33,13 @@ fun DmScreen(
ChatScreen(
panel = panel,
currentUserId = currentUserId,
modifier = modifier.fillMaxSize()
modifier = modifier.fillMaxSize(),
onTitleClick = onTitleClick,
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
onTitleAvatarChange = onTitleAvatarChange,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey
)
}
@@ -1,5 +1,7 @@
package ru.fromchat.ui.profile
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
@@ -22,7 +23,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -33,7 +33,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
@@ -41,6 +44,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UserProfile
import ru.fromchat.ui.chat.Avatar
private data class ProfileUiState(
val profile: UserProfile? = null,
@@ -54,7 +58,13 @@ private data class ProfileUiState(
fun ProfileScreen(
userId: Int?,
onBack: () -> Unit,
onChat: (Int) -> Unit
onChat: (Int) -> Unit,
hideAvatar: Boolean = false,
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
var state by remember { mutableStateOf(ProfileUiState()) }
@@ -78,6 +88,7 @@ fun ProfileScreen(
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = "Profile") },
@@ -96,49 +107,88 @@ fun ProfileScreen(
.padding(16.dp)
) {
val errorMessage = state.error
when {
state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.Center)
)
}
state.profile != null -> {
val profile = state.profile!!
val profileLink = profile.username.takeIf { it.isNotBlank() }
?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}"
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
val initials = profile.displayName?.firstOrNull()
?: profile.username.firstOrNull()
?: '?'
val profile = state.profile
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.username?.takeIf { it.isNotBlank() }
?: "?"
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Surface(
modifier = Modifier
.size(128.dp)
.padding(top = 16.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = initials.toString(),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
val useSharedAvatar = sharedTransitionScope != null &&
animatedVisibilityScope != null &&
sharedAvatarKey != null
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
when {
useSharedAvatar -> {
with(sharedTransitionScope!!) {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
size = 128.dp,
modifier = Modifier
.padding(top = 16.dp)
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey!!),
animatedVisibilityScope = animatedVisibilityScope!!
)
.size(128.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
!hideAvatar -> {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
size = 128.dp,
modifier = Modifier.padding(top = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.padding(top = 16.dp)
.size(128.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
Spacer(modifier = Modifier.height(12.dp))
}
else -> {
Spacer(modifier = Modifier.height(16.dp + 128.dp + 12.dp))
}
}
when {
state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
}
errorMessage != null -> {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 24.dp)
)
}
profile != null -> {
val profileLink = profile.username.takeIf { it.isNotBlank() }
?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}"
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge,
@@ -0,0 +1,8 @@
package ru.fromchat.ui
import androidx.compose.runtime.Composable
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
// iOS has no system back button; back is handled by navigation.
}