add fullscreen image viewing when tapping image

This commit is contained in:
Adam Brown 2022-10-30 18:23:05 +00:00
parent 3df8c0bdab
commit 2ad4ca1c61
6 changed files with 146 additions and 16 deletions

View File

@ -47,24 +47,29 @@ sealed interface BubbleModel {
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String) 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 private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
@Composable @Composable
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) { fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
val itemisedLongClick = { onLongClick.invoke(model) } val itemisedLongClick = { actions.onLongClick.invoke(model) }
when (model) { when (model) {
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick) is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Encrypted -> EncryptedBubble(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) is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
} }
} }
@Composable @Composable
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick = {}, onLongClick) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(model.event, bubble) AuthorName(model.event, bubble)
} }
@ -79,8 +84,8 @@ private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, st
} }
@Composable @Composable
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick, onLongClick) {
if (bubble.isNotSelf()) { if (bubble.isNotSelf()) {
AuthorName(model.event, bubble) AuthorName(model.event, bubble)
} }
@ -97,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
@Composable @Composable
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) { private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) { Bubble(bubble, onItemClick = {}, onLongClick) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -204,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @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 = Modifier.padding(start = 6.dp)) {
Box( Box(
Modifier Modifier
@ -212,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
.clip(bubble.shape) .clip(bubble.shape)
.background(bubble.background) .background(bubble.background)
.height(IntrinsicSize.Max) .height(IntrinsicSize.Max)
.combinedClickable(onLongClick = onLongClick, onClick = {}), .combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
) { ) {
Column( Column(
Modifier Modifier

View File

@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@ -16,13 +17,14 @@ fun Toolbar(
onNavigate: (() -> Unit)? = null, onNavigate: (() -> Unit)? = null,
title: String? = null, title: String? = null,
offset: (Density.() -> IntOffset)? = null, offset: (Density.() -> IntOffset)? = null,
color: Color = MaterialTheme.colorScheme.background,
actions: @Composable RowScope.() -> Unit = {} actions: @Composable RowScope.() -> Unit = {}
) { ) {
val navigationIcon = foo(onNavigate) val navigationIcon = foo(onNavigate)
TopAppBar( TopAppBar(
modifier = offset?.let { Modifier.offset(it) } ?: Modifier, modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
colors = TopAppBarDefaults.smallTopAppBarColors( colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.background containerColor = color,
), ),
navigationIcon = navigationIcon, navigationIcon = navigationIcon,
title = title?.let { { Text(it, maxLines = 2) } } ?: {}, title = title?.let { { Text(it, maxLines = 2) } } ?: {},

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.* import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -51,6 +57,8 @@ import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt
@Composable @Composable
internal fun MessengerScreen( internal fun MessengerScreen(
@ -76,7 +84,8 @@ internal fun MessengerScreen(
val messageActions = MessageActions( val messageActions = MessageActions(
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) } onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
onImageClick = { viewModel.selectImage(it) }
) )
Column { Column {
@ -85,6 +94,7 @@ internal fun MessengerScreen(
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) // DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
// } // }
}) })
when (state.composerState) { when (state.composerState) {
is ComposerState.Text -> { is ComposerState.Text -> {
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) 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 @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( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -201,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
onReply = { messageActions.onReply(item) }, onReply = { messageActions.onReply(item) },
) { ) {
val status = @Composable { SendStatus(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) @OptIn(ExperimentalAnimationApi::class)
@Composable @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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -447,4 +548,5 @@ class MessageActions(
val onReply: (RoomEvent) -> Unit, val onReply: (RoomEvent) -> Unit,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
val onLongClick: (BubbleModel) -> Unit, val onLongClick: (BubbleModel) -> Unit,
val onImageClick: (BubbleModel.Image) -> Unit,
) )

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger package app.dapk.st.messenger
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.engine.MessengerState import app.dapk.st.engine.MessengerState
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
@ -10,6 +11,11 @@ data class MessengerScreenState(
val roomId: RoomId?, val roomId: RoomId?,
val roomState: Lce<MessengerState>, val roomState: Lce<MessengerState>,
val composerState: ComposerState, val composerState: ComposerState,
val viewerState: ViewerState?
)
data class ViewerState(
val event: BubbleModel.Image,
) )
sealed interface MessengerEvent { sealed interface MessengerEvent {

View File

@ -32,7 +32,8 @@ internal class MessengerViewModel(
initialState = MessengerScreenState( initialState = MessengerScreenState(
roomId = null, roomId = null,
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null) composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
), ),
factory = factory, 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) { private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {

View File

@ -48,7 +48,8 @@ class MessengerViewModelTest {
MessengerScreenState( MessengerScreenState(
roomId = null, roomId = null,
roomState = Lce.Loading(), 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( fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
roomId = roomId, roomId = roomId,
roomState = Lce.Content(roomState), roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null) composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
viewerState = null,
) )
class FakeCopyToClipboard { class FakeCopyToClipboard {