Merge pull request #2399 from vitorpamplona/claude/improve-zoom-animation-5csfK
Add shared element transition animation to image/video dialogs
This commit is contained in:
+160
-27
@@ -28,6 +28,9 @@ import android.os.Looper
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -57,14 +60,23 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -94,6 +106,8 @@ import kotlinx.collections.immutable.ImmutableList
|
|||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.engawapg.lib.zoomable.rememberZoomState
|
import net.engawapg.lib.zoomable.rememberZoomState
|
||||||
import net.engawapg.lib.zoomable.zoomable
|
import net.engawapg.lib.zoomable.zoomable
|
||||||
@@ -102,11 +116,62 @@ import net.engawapg.lib.zoomable.zoomable
|
|||||||
fun ZoomableImageDialog(
|
fun ZoomableImageDialog(
|
||||||
imageUrl: BaseMediaContent,
|
imageUrl: BaseMediaContent,
|
||||||
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
|
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
|
||||||
|
sourceBounds: Rect? = null,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
|
// Animation progress: 0f = at source position/size, 1f = fullscreen.
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
var isExiting by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Natural layout bounds of the currently-visible image/video inside the dialog.
|
||||||
|
// Used as the "target" of the grow animation so the image itself — not the dialog
|
||||||
|
// viewport — aligns with the tapped thumbnail at progress = 0.
|
||||||
|
var imageBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
|
// Start the enter animation as soon as valid image bounds are available. Without
|
||||||
|
// this gate, the animation can begin before onGloballyPositioned has reported real
|
||||||
|
// bounds and the graphicsLayer falls back to its alpha-only branch.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { imageBounds }
|
||||||
|
.filter { it != null && it.width > 0f && it.height > 0f }
|
||||||
|
.first()
|
||||||
|
progress.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isExiting) {
|
||||||
|
if (isExiting) {
|
||||||
|
progress.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dismissWithAnimation: () -> Unit = { if (!isExiting) isExiting = true }
|
||||||
|
val progressProvider: () -> Float = { progress.value }
|
||||||
|
|
||||||
|
// Accept the first set of valid bounds unconditionally. Subsequent updates are
|
||||||
|
// only accepted while the progress Animatable is idle, so a layout change
|
||||||
|
// mid-transition (e.g. an async image finishing loading, or a pager settling)
|
||||||
|
// can't re-target the transform and cause a hiccup.
|
||||||
|
val updateImageBounds: (Rect) -> Unit = { newBounds ->
|
||||||
|
if (newBounds.width > 0f && newBounds.height > 0f) {
|
||||||
|
val current = imageBounds
|
||||||
|
if (current == null) {
|
||||||
|
imageBounds = newBounds
|
||||||
|
} else if (!progress.isRunning && current != newBounds) {
|
||||||
|
imageBounds = newBounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = dismissWithAnimation,
|
||||||
properties =
|
properties =
|
||||||
DialogProperties(
|
DialogProperties(
|
||||||
usePlatformDefaultWidth = true,
|
usePlatformDefaultWidth = true,
|
||||||
@@ -123,11 +188,31 @@ fun ZoomableImageDialog(
|
|||||||
val attributes = WindowManager.LayoutParams()
|
val attributes = WindowManager.LayoutParams()
|
||||||
attributes.copyFrom(activityWindow.attributes)
|
attributes.copyFrom(activityWindow.attributes)
|
||||||
attributes.type = dialogWindow.attributes.type
|
attributes.type = dialogWindow.attributes.type
|
||||||
|
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
|
||||||
|
attributes.dimAmount = 0f
|
||||||
|
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
|
||||||
dialogWindow.attributes = attributes
|
dialogWindow.attributes = attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
// Background surface that fades in as the content grows to fullscreen.
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer { alpha = progressProvider() },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
DialogContent(
|
||||||
|
allImages = allImages,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
sourceBounds = sourceBounds,
|
||||||
|
imageBounds = imageBounds,
|
||||||
|
onImageBoundsChanged = updateImageBounds,
|
||||||
|
progress = progressProvider,
|
||||||
|
onDismiss = dismissWithAnimation,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +222,10 @@ fun ZoomableImageDialog(
|
|||||||
private fun DialogContent(
|
private fun DialogContent(
|
||||||
allImages: ImmutableList<BaseMediaContent>,
|
allImages: ImmutableList<BaseMediaContent>,
|
||||||
imageUrl: BaseMediaContent,
|
imageUrl: BaseMediaContent,
|
||||||
|
sourceBounds: Rect?,
|
||||||
|
imageBounds: Rect?,
|
||||||
|
onImageBoundsChanged: (Rect) -> Unit,
|
||||||
|
progress: () -> Float,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
@@ -179,31 +268,63 @@ private fun DialogContent(
|
|||||||
),
|
),
|
||||||
Alignment.TopCenter,
|
Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
if (allImages.size > 1) {
|
// Transformed image/video container. Only this layer scales & translates so the
|
||||||
SlidingCarousel(
|
// image aligns with the tapped thumbnail on enter/exit. Controls stay put.
|
||||||
pagerState = pagerState,
|
Box(
|
||||||
) { index ->
|
modifier =
|
||||||
allImages.getOrNull(index)?.let {
|
Modifier
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
.fillMaxSize()
|
||||||
RenderImageOrVideo(
|
.graphicsLayer {
|
||||||
content = it,
|
val src = sourceBounds
|
||||||
roundedCorner = false,
|
val img = imageBounds
|
||||||
isFiniteHeight = true,
|
if (src != null && img != null && img.width > 0f && img.height > 0f) {
|
||||||
controllerVisible = controllerVisible,
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
accountViewModel = accountViewModel,
|
val startScaleX = src.width / img.width
|
||||||
)
|
val startScaleY = src.height / img.height
|
||||||
|
val p = progress()
|
||||||
|
scaleX = lerp(startScaleX, 1f, p)
|
||||||
|
scaleY = lerp(startScaleY, 1f, p)
|
||||||
|
translationX = lerp(src.left - img.left * startScaleX, 0f, p)
|
||||||
|
translationY = lerp(src.top - img.top * startScaleY, 0f, p)
|
||||||
|
} else {
|
||||||
|
// No source bounds: fall back to a plain fade.
|
||||||
|
alpha = progress()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (allImages.size > 1) {
|
||||||
|
SlidingCarousel(
|
||||||
|
pagerState = pagerState,
|
||||||
|
) { index ->
|
||||||
|
allImages.getOrNull(index)?.let { pageContent ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
RenderImageOrVideo(
|
||||||
|
content = pageContent,
|
||||||
|
roundedCorner = false,
|
||||||
|
isFiniteHeight = true,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onContentBoundsChanged =
|
||||||
|
if (index == pagerState.currentPage) {
|
||||||
|
onImageBoundsChanged
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
RenderImageOrVideo(
|
||||||
RenderImageOrVideo(
|
content = imageUrl,
|
||||||
content = imageUrl,
|
roundedCorner = false,
|
||||||
roundedCorner = false,
|
isFiniteHeight = true,
|
||||||
isFiniteHeight = true,
|
controllerVisible = controllerVisible,
|
||||||
controllerVisible = controllerVisible,
|
accountViewModel = accountViewModel,
|
||||||
accountViewModel = accountViewModel,
|
onContentBoundsChanged = onImageBoundsChanged,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +332,8 @@ private fun DialogContent(
|
|||||||
visible = controllerVisible.value,
|
visible = controllerVisible.value,
|
||||||
enter = remember { fadeIn() },
|
enter = remember { fadeIn() },
|
||||||
exit = remember { fadeOut() },
|
exit = remember { fadeOut() },
|
||||||
|
// Also fade with the grow animation so controls appear/disappear alongside it.
|
||||||
|
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -373,6 +496,7 @@ private fun RenderImageOrVideo(
|
|||||||
isFiniteHeight: Boolean,
|
isFiniteHeight: Boolean,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
onContentBoundsChanged: ((Rect) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val contentScale =
|
val contentScale =
|
||||||
if (isFiniteHeight) {
|
if (isFiniteHeight) {
|
||||||
@@ -381,7 +505,16 @@ private fun RenderImageOrVideo(
|
|||||||
ContentScale.FillWidth
|
ContentScale.FillWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
val rowModifier =
|
||||||
|
if (onContentBoundsChanged != null) {
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onGloballyPositioned { onContentBoundsChanged(it.boundsInWindow()) }
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) {
|
||||||
when (content) {
|
when (content) {
|
||||||
is MediaUrlImage -> {
|
is MediaUrlImage -> {
|
||||||
val mainModifier =
|
val mainModifier =
|
||||||
|
|||||||
+22
-5
@@ -58,8 +58,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
@@ -139,6 +142,11 @@ fun ZoomableContentView(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
var dialogOpen by remember(content) { mutableStateOf(false) }
|
var dialogOpen by remember(content) { mutableStateOf(false) }
|
||||||
|
var sourceBounds by remember(content) { mutableStateOf<Rect?>(null) }
|
||||||
|
val boundsTrackingModifier =
|
||||||
|
Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
sourceBounds = coordinates.boundsInWindow()
|
||||||
|
}
|
||||||
|
|
||||||
when (content) {
|
when (content) {
|
||||||
is MediaUrlImage -> {
|
is MediaUrlImage -> {
|
||||||
@@ -147,6 +155,7 @@ fun ZoomableContentView(
|
|||||||
val mainImageModifier =
|
val mainImageModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(boundsTrackingModifier)
|
||||||
.clickable { dialogOpen = true }
|
.clickable { dialogOpen = true }
|
||||||
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
||||||
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
|
||||||
@@ -156,7 +165,10 @@ fun ZoomableContentView(
|
|||||||
|
|
||||||
is MediaUrlVideo -> {
|
is MediaUrlVideo -> {
|
||||||
SensitivityWarning(content.contentWarning, accountViewModel) {
|
SensitivityWarning(content.contentWarning, accountViewModel) {
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
VideoView(
|
VideoView(
|
||||||
videoUri = content.url,
|
videoUri = content.url,
|
||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
@@ -180,6 +192,7 @@ fun ZoomableContentView(
|
|||||||
val mainImageModifier =
|
val mainImageModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.then(boundsTrackingModifier)
|
||||||
.clickable { dialogOpen = true }
|
.clickable { dialogOpen = true }
|
||||||
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
|
||||||
|
|
||||||
@@ -189,7 +202,10 @@ fun ZoomableContentView(
|
|||||||
|
|
||||||
is MediaLocalVideo -> {
|
is MediaLocalVideo -> {
|
||||||
content.localFile?.let {
|
content.localFile?.let {
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
VideoView(
|
VideoView(
|
||||||
videoUri = it.toUri().toString(),
|
videoUri = it.toUri().toString(),
|
||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
@@ -209,12 +225,13 @@ fun ZoomableContentView(
|
|||||||
|
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
ZoomableImageDialog(
|
ZoomableImageDialog(
|
||||||
content,
|
imageUrl = content,
|
||||||
images,
|
allImages = images,
|
||||||
|
sourceBounds = sourceBounds,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
dialogOpen = false
|
dialogOpen = false
|
||||||
},
|
},
|
||||||
accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ import androidx.compose.runtime.setValue
|
|||||||
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.geometry.Rect
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -101,6 +104,7 @@ fun RenderAppDefinition(
|
|||||||
|
|
||||||
if (!theAppMetadata.banner.isNullOrBlank()) {
|
if (!theAppMetadata.banner.isNullOrBlank()) {
|
||||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||||
|
var bannerSourceBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = theAppMetadata.banner,
|
model = theAppMetadata.banner,
|
||||||
@@ -110,6 +114,7 @@ fun RenderAppDefinition(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(125.dp)
|
.height(125.dp)
|
||||||
|
.onGloballyPositioned { bannerSourceBounds = it.boundsInWindow() }
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
@@ -125,6 +130,7 @@ fun RenderAppDefinition(
|
|||||||
if (zoomImageDialogOpen) {
|
if (zoomImageDialogOpen) {
|
||||||
ZoomableImageDialog(
|
ZoomableImageDialog(
|
||||||
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
|
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
|
||||||
|
sourceBounds = bannerSourceBounds,
|
||||||
onDismiss = { zoomImageDialogOpen = false },
|
onDismiss = { zoomImageDialogOpen = false },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
@@ -153,6 +159,7 @@ fun RenderAppDefinition(
|
|||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||||
|
var pictureSourceBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
Box(Modifier.size(100.dp)) {
|
Box(Modifier.size(100.dp)) {
|
||||||
theAppMetadata.picture?.let { picture ->
|
theAppMetadata.picture?.let { picture ->
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@@ -168,6 +175,7 @@ fun RenderAppDefinition(
|
|||||||
).clip(shape = CircleShape)
|
).clip(shape = CircleShape)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.onGloballyPositioned { pictureSourceBounds = it.boundsInWindow() }
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = { zoomImageDialogOpen = true },
|
onClick = { zoomImageDialogOpen = true },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
@@ -181,6 +189,7 @@ fun RenderAppDefinition(
|
|||||||
if (zoomImageDialogOpen) {
|
if (zoomImageDialogOpen) {
|
||||||
ZoomableImageDialog(
|
ZoomableImageDialog(
|
||||||
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
|
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
|
||||||
|
sourceBounds = pictureSourceBounds,
|
||||||
onDismiss = { zoomImageDialogOpen = false },
|
onDismiss = { zoomImageDialogOpen = false },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
@@ -32,7 +32,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
@@ -68,6 +71,7 @@ fun DrawBanner(
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||||
|
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = banner,
|
model = banner,
|
||||||
@@ -79,6 +83,7 @@ fun DrawBanner(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(150.dp)
|
.height(150.dp)
|
||||||
|
.onGloballyPositioned { sourceBounds = it.boundsInWindow() }
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = { zoomImageDialogOpen = true },
|
onClick = { zoomImageDialogOpen = true },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
@@ -92,6 +97,7 @@ fun DrawBanner(
|
|||||||
if (zoomImageDialogOpen) {
|
if (zoomImageDialogOpen) {
|
||||||
ZoomableImageDialog(
|
ZoomableImageDialog(
|
||||||
imageUrl = RichTextParser.parseImageOrVideo(banner),
|
imageUrl = RichTextParser.parseImageOrVideo(banner),
|
||||||
|
sourceBounds = sourceBounds,
|
||||||
onDismiss = { zoomImageDialogOpen = false },
|
onDismiss = { zoomImageDialogOpen = false },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-2
@@ -51,6 +51,9 @@ import androidx.compose.runtime.setValue
|
|||||||
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.geometry.Rect
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -173,12 +176,15 @@ fun ZoomableUserPicture(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var zoomImageUrl by remember { mutableStateOf<String?>(null) }
|
var zoomImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
ClickableUserPicture(
|
ClickableUserPicture(
|
||||||
baseUser = baseUser,
|
baseUser = baseUser,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
size = size,
|
size = size,
|
||||||
modifier = MaterialTheme.colorScheme.userProfileBorderModifier,
|
modifier =
|
||||||
|
MaterialTheme.colorScheme.userProfileBorderModifier
|
||||||
|
.onGloballyPositioned { sourceBounds = it.boundsInWindow() },
|
||||||
onClick = {
|
onClick = {
|
||||||
val pic = baseUser.profilePicture()
|
val pic = baseUser.profilePicture()
|
||||||
if (pic != null) {
|
if (pic != null) {
|
||||||
@@ -197,7 +203,8 @@ fun ZoomableUserPicture(
|
|||||||
|
|
||||||
zoomImageUrl?.let {
|
zoomImageUrl?.let {
|
||||||
ZoomableImageDialog(
|
ZoomableImageDialog(
|
||||||
RichTextParser.parseImageOrVideo(it),
|
imageUrl = RichTextParser.parseImageOrVideo(it),
|
||||||
|
sourceBounds = sourceBounds,
|
||||||
onDismiss = { zoomImageUrl = null },
|
onDismiss = { zoomImageUrl = null },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user