Improve image preview navigation
This commit is contained in:
@@ -22,6 +22,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.rememberTransformableState
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
import androidx.compose.foundation.gestures.transformable
|
import androidx.compose.foundation.gestures.transformable
|
||||||
@@ -247,13 +248,29 @@ private fun QMaxApp(vm: QMaxViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.previewImageUrl?.let { url ->
|
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(
|
ImagePreview(
|
||||||
url = url,
|
url = url,
|
||||||
session = state.session,
|
session = state.session,
|
||||||
|
attachment = previewAttachment,
|
||||||
position = state.previewImageIndex,
|
position = state.previewImageIndex,
|
||||||
total = state.previewImageGallery.size,
|
total = state.previewImageGallery.size,
|
||||||
onPrevious = { vm.moveImagePreview(-1) },
|
onPrevious = { vm.moveImagePreview(-1) },
|
||||||
onNext = { vm.moveImagePreview(1) },
|
onNext = { vm.moveImagePreview(1) },
|
||||||
|
onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } },
|
||||||
|
onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } },
|
||||||
onClose = vm::closeImage
|
onClose = vm::closeImage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3877,12 +3894,16 @@ private fun avatarColor(title: String): Color {
|
|||||||
private fun ImagePreview(
|
private fun ImagePreview(
|
||||||
url: String,
|
url: String,
|
||||||
session: QMaxSession?,
|
session: QMaxSession?,
|
||||||
|
attachment: AttachmentDto?,
|
||||||
position: Int,
|
position: Int,
|
||||||
total: Int,
|
total: Int,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
onShare: (() -> Unit)?,
|
||||||
|
onOpen: (() -> Unit)?,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
val model = when {
|
val model = when {
|
||||||
url.isLocalImagePath() -> File(url.removePrefix("file://"))
|
url.isLocalImagePath() -> File(url.removePrefix("file://"))
|
||||||
session != null -> imageRequest(url, session.accessToken)
|
session != null -> imageRequest(url, session.accessToken)
|
||||||
@@ -3890,16 +3911,43 @@ private fun ImagePreview(
|
|||||||
}
|
}
|
||||||
var scale by remember(url) { mutableStateOf(1f) }
|
var scale by remember(url) { mutableStateOf(1f) }
|
||||||
var offset by remember(url) { mutableStateOf(Offset.Zero) }
|
var offset by remember(url) { mutableStateOf(Offset.Zero) }
|
||||||
|
var horizontalDrag by remember(url) { mutableStateOf(0f) }
|
||||||
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
|
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
|
||||||
val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
|
val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
|
||||||
scale = nextScale
|
scale = nextScale
|
||||||
offset = if (nextScale == 1f) Offset.Zero else offset + panChange
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (model != null) {
|
if (model != null) {
|
||||||
@@ -3930,32 +3978,44 @@ private fun ImagePreview(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Surface(
|
Row(
|
||||||
color = Color(0x99000000),
|
|
||||||
shape = CircleShape,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopCenter)
|
||||||
.padding(14.dp)
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.background(Color(0x99000000))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) {
|
IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) {
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White)
|
Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White)
|
||||||
}
|
}
|
||||||
}
|
Column(Modifier.weight(1f)) {
|
||||||
if (total > 1) {
|
|
||||||
Surface(
|
|
||||||
color = Color(0x99000000),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.padding(top = 18.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "${position + 1}/$total",
|
text = attachment?.displayFileName() ?: "Фото",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
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(
|
Surface(
|
||||||
color = Color(0x99000000),
|
color = Color(0x99000000),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
@@ -3963,7 +4023,7 @@ private fun ImagePreview(
|
|||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.padding(start = 10.dp)
|
.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)
|
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous photo", tint = Color.White)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3974,11 +4034,33 @@ private fun ImagePreview(
|
|||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.padding(end = 10.dp)
|
.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)
|
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}"
|
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
|
@Composable
|
||||||
private fun rememberAuthorizedPlayer(url: String, token: String): ExoPlayer {
|
private fun rememberAuthorizedPlayer(url: String, token: String): ExoPlayer {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
Reference in New Issue
Block a user