From 2ad4ca1c61265de126b8aff0a4cdc9b2c84f43c0 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 30 Oct 2022 18:23:05 +0000 Subject: [PATCH] add fullscreen image viewing when tapping image --- .../app/dapk/st/design/components/Bubble.kt | 23 ++-- .../app/dapk/st/design/components/Toolbar.kt | 4 +- .../app/dapk/st/messenger/MessengerScreen.kt | 108 +++++++++++++++++- .../app/dapk/st/messenger/MessengerState.kt | 6 + .../dapk/st/messenger/MessengerViewModel.kt | 15 ++- .../st/messenger/MessengerViewModelTest.kt | 6 +- 6 files changed, 146 insertions(+), 16 deletions(-) diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index b17e24f..f03c16d 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -47,24 +47,29 @@ sealed interface BubbleModel { data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String) + + data class Action( + val onLongClick: (BubbleModel) -> Unit, + val onImageClick: (Image) -> Unit, + ) } private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId @Composable -fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) { - val itemisedLongClick = { onLongClick.invoke(model) } +fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) { + val itemisedLongClick = { actions.onLongClick.invoke(model) } when (model) { is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick) - is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick) + is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick) is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick) } } @Composable private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { + Bubble(bubble, onItemClick = {}, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -79,8 +84,8 @@ private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, st } @Composable -private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { +private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) { + Bubble(bubble, onItemClick, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -97,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C @Composable private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) { - Bubble(bubble, onLongClick) { + Bubble(bubble, onItemClick = {}, onLongClick) { Column( Modifier .fillMaxWidth() @@ -204,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float { @OptIn(ExperimentalFoundationApi::class) @Composable -private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) { +private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) { Box(modifier = Modifier.padding(start = 6.dp)) { Box( Modifier @@ -212,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos .clip(bubble.shape) .background(bubble.background) .height(IntrinsicSize.Max) - .combinedClickable(onLongClick = onLongClick, onClick = {}), + .combinedClickable(onLongClick = onLongClick, onClick = onItemClick), ) { Column( Modifier diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt index ec0010e..efcb2c1 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Toolbar.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -16,13 +17,14 @@ fun Toolbar( onNavigate: (() -> Unit)? = null, title: String? = null, offset: (Density.() -> IntOffset)? = null, + color: Color = MaterialTheme.colorScheme.background, actions: @Composable RowScope.() -> Unit = {} ) { val navigationIcon = foo(onNavigate) TopAppBar( modifier = offset?.let { Modifier.offset(it) } ?: Modifier, colors = TopAppBarDefaults.smallTopAppBarColors( - containerColor = MaterialTheme.colorScheme.background + containerColor = color, ), navigationIcon = navigationIcon, title = title?.let { { Text(it, maxLines = 2) } } ?: {}, diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 2b2c186..d8e0486 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.* import androidx.compose.animation.core.tween @@ -8,6 +9,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape @@ -24,7 +26,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -51,6 +57,8 @@ import app.dapk.st.navigator.Navigator import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import kotlinx.coroutines.launch +import kotlin.math.min +import kotlin.math.roundToInt @Composable internal fun MessengerScreen( @@ -76,7 +84,8 @@ internal fun MessengerScreen( val messageActions = MessageActions( onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, - onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) } + onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }, + onImageClick = { viewModel.selectImage(it) } ) Column { @@ -85,6 +94,7 @@ internal fun MessengerScreen( // DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) // } }) + when (state.composerState) { is ComposerState.Text -> { Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) @@ -106,6 +116,86 @@ internal fun MessengerScreen( } } } + + when (state.viewerState) { + null -> { + // do nothing + } + + else -> { + Box(Modifier.fillMaxSize().background(Color.Black)) { + BackHandler(onBack = { viewModel.unselectImage() }) + ZoomableImage(state.viewerState) + Toolbar( + onNavigate = { viewModel.unselectImage() }, + title = state.viewerState.event.event.authorName, + color = Color.Black.copy(alpha = 0.4f), + ) + } + } + } +} + +@Composable +private fun ZoomableImage(viewerState: ViewerState) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val angle by remember { mutableStateOf(0f) } + var zoom by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + + val screenWidth = constraints.maxWidth + val screenHeight = constraints.maxHeight + + val renderedSize = remember { + val imageContent = viewerState.event.imageContent + val imageHeight = imageContent.height ?: 120 + val heightScaleFactor = screenHeight.toFloat() / imageHeight.toFloat() + val imageWidth = imageContent.width ?: 120 + val widthScaleFactor = screenWidth.toFloat() / imageWidth.toFloat() + val scaler = min(heightScaleFactor, widthScaleFactor) + IntSize((imageWidth * scaler).roundToInt(), (imageHeight * scaler).roundToInt()) + } + + Image( + painter = rememberAsyncImagePainter(model = viewerState.event.imageRequest), + contentDescription = "", + contentScale = ContentScale.Fit, + modifier = Modifier + .graphicsLayer { + scaleX = zoom + scaleY = zoom + rotationZ = angle + translationX = offsetX + translationY = offsetY + } + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { _, pan, gestureZoom, _ -> + zoom = (zoom * gestureZoom).coerceIn(1F..4F) + if (zoom > 1) { + val x = (pan.x * zoom) + val y = (pan.y * zoom) + + if (renderedSize.width * zoom > screenWidth) { + val maxZoomedWidthOffset = ((renderedSize.width * zoom) - screenWidth) / 2 + offsetX = (offsetX + x).coerceIn(-maxZoomedWidthOffset..maxZoomedWidthOffset) + } + + if (renderedSize.height * zoom > screenHeight) { + val maxZoomedHeightOffset = ((renderedSize.height * zoom) - screenHeight) / 2 + offsetY = (offsetY + y).coerceIn(-maxZoomedHeightOffset..maxZoomedHeightOffset) + } + } else { + offsetX = 0F + offsetY = 0F + } + } + ) + } + .fillMaxSize() + ) + } } @Composable @@ -180,6 +270,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio } } + val bubbleActions = BubbleModel.Action( + onLongClick = { messageActions.onLongClick(it) }, + onImageClick = { messageActions.onImageClick(it) } + ) + LazyColumn( modifier = Modifier .fillMaxWidth() @@ -201,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio onReply = { messageActions.onReply(item) }, ) { val status = @Composable { SendStatus(item) } - MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick) + MessageBubble(this, item.toModel(), status, bubbleActions) } } } @@ -283,7 +378,13 @@ private fun SendStatus(message: RoomEvent) { @OptIn(ExperimentalAnimationApi::class) @Composable -private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) { +private fun TextComposer( + state: ComposerState.Text, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + onAttach: () -> Unit, + messageActions: MessageActions +) { Row( Modifier .fillMaxWidth() @@ -447,4 +548,5 @@ class MessageActions( val onReply: (RoomEvent) -> Unit, val onDismiss: () -> Unit, val onLongClick: (BubbleModel) -> Unit, + val onImageClick: (BubbleModel.Image) -> Unit, ) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index fb50fc9..60657a9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import app.dapk.st.core.Lce +import app.dapk.st.design.components.BubbleModel import app.dapk.st.engine.MessengerState import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomId @@ -10,6 +11,11 @@ data class MessengerScreenState( val roomId: RoomId?, val roomState: Lce, val composerState: ComposerState, + val viewerState: ViewerState? +) + +data class ViewerState( + val event: BubbleModel.Image, ) sealed interface MessengerEvent { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 257599b..460257d 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -32,7 +32,8 @@ internal class MessengerViewModel( initialState = MessengerScreenState( roomId = null, roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "", reply = null) + composerState = ComposerState.Text(value = "", reply = null), + viewerState = null, ), factory = factory, ) { @@ -157,6 +158,18 @@ internal class MessengerViewModel( } } + fun selectImage(image: BubbleModel.Image) { + updateState { + copy(viewerState = ViewerState(image)) + } + } + + fun unselectImage() { + updateState { + copy(viewerState = null) + } + } + } private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt index ee605bf..c152194 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -48,7 +48,8 @@ class MessengerViewModelTest { MessengerScreenState( roomId = null, roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "", reply = null) + composerState = ComposerState.Text(value = "", reply = null), + viewerState = null, ) ) } @@ -114,7 +115,8 @@ class MessengerViewModelTest { fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState( roomId = roomId, roomState = Lce.Content(roomState), - composerState = ComposerState.Text(value = messageContent ?: "", reply = null) + composerState = ComposerState.Text(value = messageContent ?: "", reply = null), + viewerState = null, ) class FakeCopyToClipboard {