Better image viewer

This commit is contained in:
Jing Jin 2025-05-19 22:59:10 -07:00
parent 0b67ccce1a
commit eff2c62276
3 changed files with 153 additions and 115 deletions

View file

@ -16,15 +16,10 @@
package com.google.ai.edge.gallery.ui.common.chat package com.google.ai.edge.gallery.ui.common.chat
import androidx.compose.animation.AnimatedVisibility import android.graphics.Bitmap
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -34,7 +29,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -45,13 +39,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@ -62,7 +53,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -73,16 +63,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -92,7 +78,6 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.R
import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Model
@ -115,7 +100,7 @@ enum class ChatInputType {
/** /**
* Composable function for the main chat panel, displaying messages and handling user input. * Composable function for the main chat panel, displaying messages and handling user input.
*/ */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatPanel( fun ChatPanel(
modelManagerViewModel: ModelManagerViewModel, modelManagerViewModel: ModelManagerViewModel,
@ -130,6 +115,7 @@ fun ChatPanel(
onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },
onStreamEnd: (Int) -> Unit = {}, onStreamEnd: (Int) -> Unit = {},
onStopButtonClicked: () -> Unit = {}, onStopButtonClicked: () -> Unit = {},
onImageSelected: (Bitmap) -> Unit = {},
chatInputType: ChatInputType = ChatInputType.TEXT, chatInputType: ChatInputType = ChatInputType.TEXT,
showStopButtonInInputWhenInProgress: Boolean = false, showStopButtonInInputWhenInProgress: Boolean = false,
) { ) {
@ -140,7 +126,6 @@ fun ChatPanel(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var selectedImageMessage by remember { mutableStateOf<ChatMessageImage?>(null) }
val hasImageMessageToLastConfigChange = remember(messages) { val hasImageMessageToLastConfigChange = remember(messages) {
var foundImageMessage = false var foundImageMessage = false
for (message in messages.reversed()) { for (message in messages.reversed()) {
@ -278,7 +263,7 @@ fun ChatPanel(
bottom = 6.dp, bottom = 6.dp,
), ),
horizontalAlignment = hAlign, horizontalAlignment = hAlign,
) { ) messageColumn@{
// Sender row. // Sender row.
MessageSender( MessageSender(
message = message, message = message,
@ -316,7 +301,8 @@ fun ChatPanel(
var messageBubbleModifier = Modifier var messageBubbleModifier = Modifier
.clip( .clip(
MessageBubbleShape( MessageBubbleShape(
radius = bubbleBorderRadius, hardCornerAtLeftOrRight = hardCornerAtLeftOrRight radius = bubbleBorderRadius,
hardCornerAtLeftOrRight = hardCornerAtLeftOrRight
) )
) )
.background(backgroundColor) .background(backgroundColor)
@ -340,9 +326,12 @@ fun ChatPanel(
// Image // Image
is ChatMessageImage -> { is ChatMessageImage -> {
MessageBodyImage(message = message, modifier = Modifier.clickable { MessageBodyImage(
selectedImageMessage = message message = message, modifier = Modifier
}) .clickable {
onImageSelected(message.bitmap)
}
)
} }
// Image with history (for image gen) // Image with history (for image gen)
@ -534,47 +523,6 @@ fun ChatPanel(
} }
} }
// A full-screen image viewer.
val curSelectedImageMessage = selectedImageMessage
AnimatedVisibility(
visible = curSelectedImageMessage != null,
enter = fadeIn(),
exit = fadeOut(),
) {
if (curSelectedImageMessage == null) return@AnimatedVisibility
ZoomableBox(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
) {
// Image.
Image(
bitmap = curSelectedImageMessage.imageBitMap,
contentDescription = "",
modifier = modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale, scaleY = scale, translationX = offsetX, translationY = offsetY
),
contentScale = ContentScale.Fit,
)
// Close button.
IconButton(
onClick = {
selectedImageMessage = null
}, colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
), modifier = Modifier.offset(x = (-8).dp, y = 8.dp)
) {
Icon(
Icons.Rounded.Close, contentDescription = "", tint = MaterialTheme.colorScheme.primary
)
}
}
}
// Error dialog. // Error dialog.
if (showErrorDialog) { if (showErrorDialog) {
ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = { ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = {
@ -636,47 +584,6 @@ fun ChatPanel(
} }
} }
@Composable
fun ZoomableBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 5f,
content: @Composable ZoomableBoxScope.() -> Unit
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var size by remember { mutableStateOf(IntSize.Zero) }
Box(modifier = modifier
.clip(RectangleShape)
.onSizeChanged { size = it }
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = maxOf(minScale, minOf(scale * zoom, maxScale))
val maxX = (size.width * (scale - 1)) / 2
val minX = -maxX
offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x))
val maxY = (size.height * (scale - 1)) / 2
val minY = -maxY
offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y))
}
}, contentAlignment = Alignment.TopEnd
) {
val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY)
scope.content()
}
}
interface ZoomableBoxScope {
val scale: Float
val offsetX: Float
val offsetY: Float
}
private data class ZoomableBoxScopeImpl(
override val scale: Float, override val offsetX: Float, override val offsetY: Float
) : ZoomableBoxScope
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun ChatPanelPreview() { fun ChatPanelPreview() {

View file

@ -16,15 +16,28 @@
package com.google.ai.edge.gallery.ui.common.chat package com.google.ai.edge.gallery.ui.common.chat
import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -37,9 +50,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.Model
import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.ModelDownloadStatusType
import com.google.ai.edge.gallery.data.Task import com.google.ai.edge.gallery.data.Task
@ -84,9 +101,10 @@ fun ChatView(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
val selectedModel = modelManagerUiState.selectedModel val selectedModel = modelManagerUiState.selectedModel
var selectedImage by remember { mutableStateOf<Bitmap?>(null) }
var showImageViewer by remember { mutableStateOf(false) }
val pagerState = rememberPagerState( val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel),
initialPage = task.models.indexOf(selectedModel),
pageCount = { task.models.size }) pageCount = { task.models.size })
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -139,8 +157,7 @@ fun ChatView(
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { snapshotFlow {
PagerScrollState( PagerScrollState(
page = pagerState.currentPage, page = pagerState.currentPage, offset = pagerState.currentPageOffsetFraction
offset = pagerState.currentPageOffsetFraction
) )
}.collect { scrollState -> }.collect { scrollState ->
modelManagerViewModel.pagerScrollState.value = scrollState modelManagerViewModel.pagerScrollState.value = scrollState
@ -164,9 +181,7 @@ fun ChatView(
onResetSessionClicked = onResetSessionClicked, onResetSessionClicked = onResetSessionClicked,
onConfigChanged = { old, new -> onConfigChanged = { old, new ->
viewModel.addConfigChangedMessage( viewModel.addConfigChangedMessage(
oldConfigValues = old, oldConfigValues = old, newConfigValues = new, model = selectedModel
newConfigValues = new,
model = selectedModel
) )
}, },
onBackClicked = { onBackClicked = {
@ -197,9 +212,7 @@ fun ChatView(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
) { ) {
ModelDownloadStatusInfoPanel( ModelDownloadStatusInfoPanel(
model = curSelectedModel, model = curSelectedModel, task = task, modelManagerViewModel = modelManagerViewModel
task = task,
modelManagerViewModel = modelManagerViewModel
) )
// The main messages panel. // The main messages panel.
@ -223,6 +236,10 @@ fun ChatView(
onStopButtonClicked = { onStopButtonClicked = {
onStopButtonClicked(curSelectedModel) onStopButtonClicked(curSelectedModel)
}, },
onImageSelected = { bitmap ->
selectedImage = bitmap
showImageViewer = true
},
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.graphicsLayer { alpha = curAlpha }, .graphicsLayer { alpha = curAlpha },
@ -232,6 +249,49 @@ fun ChatView(
} }
} }
} }
// Image viewer.
AnimatedVisibility(
visible = showImageViewer,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
exit = slideOutVertically(
targetOffsetY = { fullHeight -> fullHeight },
) + fadeOut()
) {
selectedImage?.let { image ->
ZoomableBox(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.background(Color.Black.copy(alpha = 0.95f)),
) {
Image(
bitmap = image.asImageBitmap(), contentDescription = "",
modifier = modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale, scaleY = scale, translationX = offsetX, translationY = offsetY
),
contentScale = ContentScale.Fit,
)
// Close button.
IconButton(
onClick = {
showImageViewer = false
}, colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
), modifier = Modifier.offset(x = (-8).dp, y = 8.dp)
) {
Icon(
Icons.Rounded.Close,
contentDescription = "",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
} }
} }
} }

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.ai.edge.gallery.ui.common.chat
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
@Composable
fun ZoomableBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 5f,
content: @Composable ZoomableBoxScope.() -> Unit
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var size by remember { mutableStateOf(IntSize.Zero) }
Box(modifier = modifier
.onSizeChanged { size = it }
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = maxOf(minScale, minOf(scale * zoom, maxScale))
val maxX = (size.width * (scale - 1)) / 2
val minX = -maxX
offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x))
val maxY = (size.height * (scale - 1)) / 2
val minY = -maxY
offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y))
}
}, contentAlignment = Alignment.TopEnd
) {
val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY)
scope.content()
}
}
interface ZoomableBoxScope {
val scale: Float
val offsetX: Float
val offsetY: Float
}
private data class ZoomableBoxScopeImpl(
override val scale: Float, override val offsetX: Float, override val offsetY: Float
) : ZoomableBoxScope