Fix flickering

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-19 22:06:39 +03:00
Unverified
parent 323ec99c12
commit 900fc3429d
4 changed files with 500 additions and 375 deletions
@@ -1,8 +1,6 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -49,6 +47,8 @@ import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.withSaveLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@@ -79,9 +79,8 @@ fun AttachmentPreview(
fileIndex: Int? = null,
onFileClick: (() -> Unit)? = null,
onImageClick: (() -> Unit)? = null,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onImageBounds: ((Rect) -> Unit)? = null,
isExpanded: Boolean = false,
isAuthor: Boolean = false,
modifier: Modifier = Modifier
) {
@@ -123,18 +122,10 @@ fun AttachmentPreview(
}
isImageWithThumb -> {
var isFullyLoaded by remember { mutableStateOf(false) }
val sharedModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else Modifier
Box(
modifier = modifier
.then(
if (onImageClick != null && isFullyLoaded) Modifier.clickable(onClick = onImageClick)
if (onImageClick != null && isFullyLoaded && !isExpanded) Modifier.clickable(onClick = onImageClick)
else Modifier
)
.conditional(
@@ -148,10 +139,28 @@ fun AttachmentPreview(
Modifier.size(IMAGE_SIZE)
}
)
.then(sharedModifier)
.clip(RoundedCornerShape(IMAGE_RADIUS)),
.clip(RoundedCornerShape(IMAGE_RADIUS))
.then(
if (onImageBounds != null) {
Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val size = coords.size
onImageBounds(
Rect(
pos.x,
pos.y,
pos.x + size.width.toFloat(),
pos.y + size.height.toFloat()
)
)
}
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (!isExpanded) {
DecryptedImageContent(
messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
@@ -160,11 +169,11 @@ fun AttachmentPreview(
currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio,
sharedImageKey = sharedImageKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onFullyLoaded = { isFullyLoaded = it }
)
} else {
Box(modifier = Modifier.fillMaxSize())
}
}
}
isPendingImage -> {
@@ -264,9 +273,6 @@ private fun DecryptedImageContent(
currentUserId: Int?,
thumbnailBase64: String,
aspectRatio: Float?,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onFullyLoaded: (Boolean) -> Unit = {}
) {
var cachedPath by remember(messageId, fileIndex, file.path) {
@@ -3,7 +3,6 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -43,6 +42,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -184,10 +184,9 @@ fun ChatScreen(
)
}
var expandedImage by remember { mutableStateOf<Pair<Message, Int>?>(null) }
BackHandler(enabled = expandedImage != null) {
expandedImage = null
}
var isImageClosing by remember { mutableStateOf(false) }
val imageThumbBounds = remember { mutableStateMapOf<String, Rect>() }
val expandedImageKey = expandedImage?.let { (msg, idx) -> "img_${msg.id}_$idx" }
// Collect WebSocket messages
LaunchedEffect(Unit) {
@@ -301,15 +300,8 @@ fun ChatScreen(
}
}
AnimatedContent(
targetState = expandedImage,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith ExitTransition.None
},
label = "image_fullscreen"
) { expanded ->
if (expanded == null) {
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -449,7 +441,7 @@ fun ChatScreen(
)
},
bottomBar = {
Column( // New Column to hold ChatInput below the LazyColumn
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
@@ -539,7 +531,6 @@ fun ChatScreen(
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
@@ -581,7 +572,6 @@ fun ChatScreen(
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
currentUserId = currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
@@ -596,9 +586,13 @@ fun ChatScreen(
tapOffset = offset
},
onImageClick = { msg, idx -> expandedImage = msg to idx },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
showUsername = panel.showUsernamesInMessages
onImageBounds = { key, rect ->
imageThumbBounds[key] = rect
},
expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId
)
}
}
@@ -632,19 +626,25 @@ fun ChatScreen(
)
}
}
} else {
expanded.let { (msg, idx) ->
expandedImage?.let { (msg, idx) ->
val key = "img_${msg.id}_$idx"
ImageFullscreenPreview(
message = msg,
fileIndex = idx,
currentUserId = currentUserId,
onDismiss = { expandedImage = null },
onDismiss = {
isImageClosing = false
expandedImage = null
},
onClosingChange = { isImageClosing = it },
onReply = { m ->
replyTo = m
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
isImageClosing = false
expandedImage = null
},
onDelete = { m ->
@@ -653,12 +653,12 @@ fun ChatScreen(
}
},
onSave = { _, _ -> /* TODO: platform-specific save to gallery */ },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
sharedImageKey = "img_${msg.id}_$idx",
modifier = Modifier.fillMaxSize()
sharedTransitionScope = null,
animatedVisibilityScope = null,
sharedImageKey = null,
modifier = Modifier.fillMaxSize(),
thumbnailBounds = imageThumbBounds[key]
)
}
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -56,10 +57,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import ru.fromchat.ui.BackHandler
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
@@ -67,6 +70,14 @@ import kotlin.time.Instant
private val MENU_BG_ALPHA = 0.5f
private data class InitialTransform(
val scale: Float,
val offsetX: Float,
val offsetY: Float,
val cornerRadius: Float,
val bgAlpha: Float
)
@OptIn(ExperimentalTime::class)
private fun formatDateTime(timestamp: String): String {
return try {
@@ -92,10 +103,12 @@ fun ImageFullscreenPreview(
onReply: (Message) -> Unit,
onDelete: (Message) -> Unit,
onSave: (Message, Int) -> Unit,
onClosingChange: (Boolean) -> Unit = {},
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedImageKey: Any? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
thumbnailBounds: Rect? = null
) {
val file = message.files?.getOrNull(fileIndex) ?: return
val envelope = message.dmEnvelope
@@ -116,11 +129,20 @@ fun ImageFullscreenPreview(
var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) }
var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) }
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation
val effectiveBackgroundAlpha = if (isInitialOpenState) 0f else backgroundAlpha.value
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible
BackHandler(enabled = true) {
dismissRequested = true
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.background(Color.Black.copy(alpha = effectiveBackgroundAlpha))
) {
BoxWithConstraints(
modifier = Modifier
@@ -145,16 +167,71 @@ fun ImageFullscreenPreview(
)
}
else -> {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val scaleAnim = remember { Animatable(1f) }
val offsetXAnim = remember { Animatable(0f) }
val offsetYAnim = remember { Animatable(0f) }
val initial = remember(
thumbnailBounds, containerWidth, containerHeight, contentHeightAtScale1
) {
if (thumbnailBounds != null && fileAspectRatio != null) {
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val s = thumbWidth / containerWidth
val o = thumbCenter - fullCenter
InitialTransform(s, o.x, o.y, 8f, 0f)
} else {
InitialTransform(1f, 0f, 0f, 0f, 1f)
}
}
var scale by remember { mutableStateOf(initial.scale) }
var offset by remember { mutableStateOf(Offset(initial.offsetX, initial.offsetY)) }
val scaleAnim = remember { Animatable(initial.scale) }
val offsetXAnim = remember { Animatable(initial.offsetX) }
val offsetYAnim = remember { Animatable(initial.offsetY) }
val cornerRadiusAnim = remember { Animatable(initial.cornerRadius) }
val state = rememberTransformableState { zoomChange, offsetChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 12f)
offset += offsetChange
}
LaunchedEffect(thumbnailBounds) {
if (thumbnailBounds != null && !hasPlayedOpenAnimation) {
hasPlayedOpenAnimation = true
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val startScale = thumbWidth / containerWidth
val startOffset = thumbCenter - fullCenter
scale = startScale
offset = startOffset
scaleAnim.snapTo(startScale)
offsetXAnim.snapTo(startOffset.x)
offsetYAnim.snapTo(startOffset.y)
cornerRadiusAnim.snapTo(8f)
backgroundAlpha.snapTo(0f)
menusVisible = false
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(250)) }
launch { offsetXAnim.animateTo(0f, tween(250)) }
launch { offsetYAnim.animateTo(0f, tween(250)) }
launch { cornerRadiusAnim.animateTo(0f, tween(250)) }
launch { backgroundAlpha.animateTo(1f, tween(250)) }
}
menusVisible = true
}
}
LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect
@@ -162,14 +239,50 @@ fun ImageFullscreenPreview(
offsetXAnim.value != 0f ||
offsetYAnim.value != 0f
if (thumbnailBounds != null) {
menusVisible = false
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val targetScale = thumbWidth / containerWidth
val targetOffset = thumbCenter - fullCenter
scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y)
cornerRadiusAnim.snapTo(0f)
coroutineScope {
launch { scaleAnim.animateTo(targetScale, tween(250)) }
launch { offsetXAnim.animateTo(targetOffset.x, tween(250)) }
launch { offsetYAnim.animateTo(targetOffset.y, tween(250)) }
launch { cornerRadiusAnim.animateTo(8f, tween(250)) }
launch { backgroundAlpha.animateTo(0f, tween(250)) }
}
} else {
if (hasTransform) {
menusVisible = false
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(220)) }
launch { offsetXAnim.animateTo(0f, tween(220)) }
launch { offsetYAnim.animateTo(0f, tween(220)) }
launch { cornerRadiusAnim.animateTo(0f, tween(220)) }
launch { backgroundAlpha.animateTo(0f, tween(220)) }
}
} else {
backgroundAlpha.animateTo(0f, tween(220))
}
}
onClosingChange(true)
delay(50)
onClosingChange(false)
onDismiss()
}
LaunchedEffect(scale, offset, state.isTransformInProgress) {
@@ -238,6 +351,8 @@ fun ImageFullscreenPreview(
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
shape = androidx.compose.foundation.shape.RoundedCornerShape(cornerRadiusAnim.value.dp)
clip = cornerRadiusAnim.value > 0f
},
contentScale = ContentScale.FillWidth
)
@@ -249,7 +364,7 @@ fun ImageFullscreenPreview(
// Top bar: back, display name + date/time, 3-dot menu
AnimatedVisibility(
visible = menusVisible,
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
@@ -349,7 +464,7 @@ fun ImageFullscreenPreview(
// Bottom: message text
AnimatedVisibility(
visible = menusVisible && message.content.isNotBlank(),
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
@@ -2,7 +2,6 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -34,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
@@ -64,11 +64,12 @@ fun MessageItem(
onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {},
onImageClick: ((Message, Int) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onImageBounds: ((String, Rect) -> Unit)? = null,
modifier: Modifier = Modifier,
showUsername: Boolean = true,
currentUserId: Int? = null
currentUserId: Int? = null,
expandedImageKey: String? = null,
isImageClosing: Boolean = false
) {
AnimatedVisibility(
visible = true,
@@ -241,6 +242,7 @@ fun MessageItem(
}
message.files?.forEachIndexed { index, file ->
val isImage = isImageFilename(file.name)
val imageKey = if (isImage) "img_${message.id}_$index" else null
AttachmentPreview(
file = file,
dmEnvelope = message.dmEnvelope,
@@ -252,11 +254,13 @@ fun MessageItem(
fileSizeBytes = message.fileSizes?.getOrNull(index),
messageId = if (isImage) message.id else null,
fileIndex = if (isImage) index else null,
isAuthor = isAuthor,
onFileClick = null,
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
sharedImageKey = if (isImage && sharedTransitionScope != null && animatedVisibilityScope != null) "img_${message.id}_$index" else null,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
{ rect -> onImageBounds.invoke(imageKey, rect) }
} else null,
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
isAuthor = isAuthor,
modifier = Modifier.padding(
horizontal = if (isImage) 2.dp else 12.dp,
vertical = if (isImage) 2.dp else 4.dp