Improve image preview navigation

This commit is contained in:
sevenhill
2026-07-09 15:38:43 +03:00
parent 82f241df13
commit c5330f3086
@@ -22,6 +22,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
@@ -247,13 +248,29 @@ private fun QMaxApp(vm: QMaxViewModel) {
}
state.previewImageUrl?.let { url ->
val previewAttachment = remember(
url,
state.messages,
state.cachedImagePaths,
state.session?.serverUrl
) {
findPreviewAttachment(
url = url,
messages = state.messages,
cachedImagePaths = state.cachedImagePaths,
session = state.session
)
}
ImagePreview(
url = url,
session = state.session,
attachment = previewAttachment,
position = state.previewImageIndex,
total = state.previewImageGallery.size,
onPrevious = { vm.moveImagePreview(-1) },
onNext = { vm.moveImagePreview(1) },
onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } },
onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } },
onClose = vm::closeImage
)
}
@@ -3877,12 +3894,16 @@ private fun avatarColor(title: String): Color {
private fun ImagePreview(
url: String,
session: QMaxSession?,
attachment: AttachmentDto?,
position: Int,
total: Int,
onPrevious: () -> Unit,
onNext: () -> Unit,
onShare: (() -> Unit)?,
onOpen: (() -> Unit)?,
onClose: () -> Unit
) {
BackHandler(onBack = onClose)
val model = when {
url.isLocalImagePath() -> File(url.removePrefix("file://"))
session != null -> imageRequest(url, session.accessToken)
@@ -3890,16 +3911,43 @@ private fun ImagePreview(
}
var scale by remember(url) { mutableStateOf(1f) }
var offset by remember(url) { mutableStateOf(Offset.Zero) }
var horizontalDrag by remember(url) { mutableStateOf(0f) }
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
scale = nextScale
offset = if (nextScale == 1f) Offset.Zero else offset + panChange
}
fun resetZoom() {
scale = 1f
offset = Offset.Zero
}
fun move(delta: Int) {
resetZoom()
if (delta < 0) onPrevious() else onNext()
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
.background(Color.Black)
.pointerInput(url, total, scale) {
detectHorizontalDragGestures(
onDragStart = { horizontalDrag = 0f },
onHorizontalDrag = { _, dragAmount ->
if (total > 1 && scale <= 1.02f) {
horizontalDrag += dragAmount
}
},
onDragEnd = {
when {
horizontalDrag > 120f -> move(-1)
horizontalDrag < -120f -> move(1)
}
horizontalDrag = 0f
},
onDragCancel = { horizontalDrag = 0f }
)
},
contentAlignment = Alignment.Center
) {
if (model != null) {
@@ -3930,32 +3978,44 @@ private fun ImagePreview(
}
)
}
Surface(
color = Color(0x99000000),
shape = CircleShape,
Row(
modifier = Modifier
.align(Alignment.TopStart)
.padding(14.dp)
.align(Alignment.TopCenter)
.fillMaxWidth()
.statusBarsPadding()
.background(Color(0x99000000))
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White)
}
}
if (total > 1) {
Surface(
color = Color(0x99000000),
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 18.dp)
) {
Column(Modifier.weight(1f)) {
Text(
text = "${position + 1}/$total",
text = attachment?.displayFileName() ?: "Фото",
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = if (total > 1) "${position + 1} из $total" else "Просмотр",
color = Color(0xCCFFFFFF),
style = MaterialTheme.typography.labelMedium
)
}
onShare?.let { share ->
IconButton(onClick = share, modifier = Modifier.size(44.dp)) {
Icon(Icons.Filled.Share, contentDescription = "Поделиться", tint = Color.White)
}
}
onOpen?.let { open ->
TextButton(onClick = open) {
Text("Открыть", color = Color.White)
}
}
}
if (total > 1) {
Surface(
color = Color(0x99000000),
shape = CircleShape,
@@ -3963,7 +4023,7 @@ private fun ImagePreview(
.align(Alignment.CenterStart)
.padding(start = 10.dp)
) {
IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) {
IconButton(onClick = { move(-1) }, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous photo", tint = Color.White)
}
}
@@ -3974,11 +4034,33 @@ private fun ImagePreview(
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
) {
IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) {
IconButton(onClick = { move(1) }, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next photo", tint = Color.White)
}
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 18.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(color = Color(0x99000000), shape = RoundedCornerShape(18.dp)) {
Text(
text = "${(scale * 100).toInt()}%",
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp)
)
}
if (scale > 1.02f || offset != Offset.Zero) {
TextButton(onClick = { resetZoom() }) {
Text("Сбросить", color = Color.White)
}
}
}
}
}
@@ -4097,6 +4179,33 @@ private fun rememberAttachmentUrl(session: QMaxSession, attachment: AttachmentDt
return "${session.serverUrl.trimEnd('/')}${attachment.downloadPath}"
}
private fun findPreviewAttachment(
url: String,
messages: List<MessageDto>,
cachedImagePaths: Map<String, String>,
session: QMaxSession?
): AttachmentDto? {
val normalizedUrl = url.removePrefix("file://")
val serverUrl = session?.serverUrl?.trimEnd('/')
return messages
.flatMap { it.attachments }
.firstOrNull { attachment ->
val candidates = buildList {
add(attachment.downloadPath)
if (serverUrl != null) {
add("$serverUrl${attachment.downloadPath}")
}
cachedImagePaths[attachment.id]?.let { cached ->
add(cached)
add("file://$cached")
}
}
candidates.any { candidate ->
candidate == url || candidate.removePrefix("file://") == normalizedUrl
}
}
}
@Composable
private fun rememberAuthorizedPlayer(url: String, token: String): ExoPlayer {
val context = LocalContext.current