mirror of
https://github.com/google-ai-edge/gallery.git
synced 2025-07-15 18:56:42 -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
|
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() {
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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