mirror of
https://github.com/google-ai-edge/gallery.git
synced 2025-07-15 02:36:43 -04:00
Better image viewer
This commit is contained in:
parent
0b67ccce1a
commit
eff2c62276
3 changed files with 153 additions and 115 deletions
|
@ -16,15 +16,10 @@
|
|||
|
||||
package com.google.ai.edge.gallery.ui.common.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material.icons.Icons
|
||||
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.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
|
@ -62,7 +53,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
@ -73,16 +63,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
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.LocalContext
|
||||
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.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.ai.edge.gallery.R
|
||||
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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatPanel(
|
||||
modelManagerViewModel: ModelManagerViewModel,
|
||||
|
@ -130,6 +115,7 @@ fun ChatPanel(
|
|||
onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> },
|
||||
onStreamEnd: (Int) -> Unit = {},
|
||||
onStopButtonClicked: () -> Unit = {},
|
||||
onImageSelected: (Bitmap) -> Unit = {},
|
||||
chatInputType: ChatInputType = ChatInputType.TEXT,
|
||||
showStopButtonInInputWhenInProgress: Boolean = false,
|
||||
) {
|
||||
|
@ -140,7 +126,6 @@ fun ChatPanel(
|
|||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var selectedImageMessage by remember { mutableStateOf<ChatMessageImage?>(null) }
|
||||
val hasImageMessageToLastConfigChange = remember(messages) {
|
||||
var foundImageMessage = false
|
||||
for (message in messages.reversed()) {
|
||||
|
@ -278,7 +263,7 @@ fun ChatPanel(
|
|||
bottom = 6.dp,
|
||||
),
|
||||
horizontalAlignment = hAlign,
|
||||
) {
|
||||
) messageColumn@{
|
||||
// Sender row.
|
||||
MessageSender(
|
||||
message = message,
|
||||
|
@ -316,7 +301,8 @@ fun ChatPanel(
|
|||
var messageBubbleModifier = Modifier
|
||||
.clip(
|
||||
MessageBubbleShape(
|
||||
radius = bubbleBorderRadius, hardCornerAtLeftOrRight = hardCornerAtLeftOrRight
|
||||
radius = bubbleBorderRadius,
|
||||
hardCornerAtLeftOrRight = hardCornerAtLeftOrRight
|
||||
)
|
||||
)
|
||||
.background(backgroundColor)
|
||||
|
@ -340,9 +326,12 @@ fun ChatPanel(
|
|||
|
||||
// Image
|
||||
is ChatMessageImage -> {
|
||||
MessageBodyImage(message = message, modifier = Modifier.clickable {
|
||||
selectedImageMessage = message
|
||||
})
|
||||
MessageBodyImage(
|
||||
message = message, modifier = Modifier
|
||||
.clickable {
|
||||
onImageSelected(message.bitmap)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (showErrorDialog) {
|
||||
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)
|
||||
@Composable
|
||||
fun ChatPanelPreview() {
|
||||
|
|
|
@ -16,15 +16,28 @@
|
|||
|
||||
package com.google.ai.edge.gallery.ui.common.chat
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
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.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
|
@ -37,9 +50,13 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.ModelDownloadStatusType
|
||||
import com.google.ai.edge.gallery.data.Task
|
||||
|
@ -84,9 +101,10 @@ fun ChatView(
|
|||
val uiState by viewModel.uiState.collectAsState()
|
||||
val modelManagerUiState by modelManagerViewModel.uiState.collectAsState()
|
||||
val selectedModel = modelManagerUiState.selectedModel
|
||||
var selectedImage by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = task.models.indexOf(selectedModel),
|
||||
val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel),
|
||||
pageCount = { task.models.size })
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
@ -139,8 +157,7 @@ fun ChatView(
|
|||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow {
|
||||
PagerScrollState(
|
||||
page = pagerState.currentPage,
|
||||
offset = pagerState.currentPageOffsetFraction
|
||||
page = pagerState.currentPage, offset = pagerState.currentPageOffsetFraction
|
||||
)
|
||||
}.collect { scrollState ->
|
||||
modelManagerViewModel.pagerScrollState.value = scrollState
|
||||
|
@ -164,9 +181,7 @@ fun ChatView(
|
|||
onResetSessionClicked = onResetSessionClicked,
|
||||
onConfigChanged = { old, new ->
|
||||
viewModel.addConfigChangedMessage(
|
||||
oldConfigValues = old,
|
||||
newConfigValues = new,
|
||||
model = selectedModel
|
||||
oldConfigValues = old, newConfigValues = new, model = selectedModel
|
||||
)
|
||||
},
|
||||
onBackClicked = {
|
||||
|
@ -197,9 +212,7 @@ fun ChatView(
|
|||
.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
ModelDownloadStatusInfoPanel(
|
||||
model = curSelectedModel,
|
||||
task = task,
|
||||
modelManagerViewModel = modelManagerViewModel
|
||||
model = curSelectedModel, task = task, modelManagerViewModel = modelManagerViewModel
|
||||
)
|
||||
|
||||
// The main messages panel.
|
||||
|
@ -223,6 +236,10 @@ fun ChatView(
|
|||
onStopButtonClicked = {
|
||||
onStopButtonClicked(curSelectedModel)
|
||||
},
|
||||
onImageSelected = { bitmap ->
|
||||
selectedImage = bitmap
|
||||
showImageViewer = true
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue