feat: grow zoomable media dialog from source bounds

The fullscreen image/video dialog previously popped in without any
transition. Capture the tapped thumbnail's window bounds via
onGloballyPositioned and pass them to ZoomableImageDialog, which now
animates a graphicsLayer scale/translate from the source rect to
fullscreen on enter and back to the source rect on dismiss. The system
window dim is disabled and a Surface background fades in alongside the
growing content so the thumbnail remains visible during the animation.

Applies to images and videos in feeds (via ZoomableContentView) as well
as profile/banner/app-definition callers.
This commit is contained in:
Claude
2026-04-15 02:52:21 +00:00
parent de94b28fc2
commit d3633a396c
5 changed files with 125 additions and 10 deletions
@@ -28,6 +28,9 @@ import android.os.Looper
import android.view.WindowManager
import android.widget.Toast
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.fadeOut
import androidx.compose.foundation.clickable
@@ -59,12 +62,20 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
@@ -102,11 +113,35 @@ import net.engawapg.lib.zoomable.zoomable
fun ZoomableImageDialog(
imageUrl: BaseMediaContent,
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
sourceBounds: Rect? = null,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
// Animation progress: 0f = at source position/size, 1f = fullscreen.
val progress = remember { Animatable(0f) }
var isExiting by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
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 }
Dialog(
onDismissRequest = onDismiss,
onDismissRequest = dismissWithAnimation,
properties =
DialogProperties(
usePlatformDefaultWidth = true,
@@ -123,11 +158,52 @@ fun ZoomableImageDialog(
val attributes = WindowManager.LayoutParams()
attributes.copyFrom(activityWindow.attributes)
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
}
Surface(Modifier.fillMaxSize()) {
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
var fullBounds by remember { mutableStateOf<Rect?>(null) }
Box(
modifier =
Modifier
.fillMaxSize()
.onGloballyPositioned { fullBounds = it.boundsInWindow() },
) {
// Background surface that fades in as the content grows to fullscreen.
Surface(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer { alpha = progress.value },
) {}
// Content layer that grows from the source bounds to fullscreen (and back on exit).
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
val src = sourceBounds
val full = fullBounds
if (src != null && full != null && full.width > 0f && full.height > 0f) {
transformOrigin = TransformOrigin(0f, 0f)
val startScaleX = src.width / full.width
val startScaleY = src.height / full.height
scaleX = lerp(startScaleX, 1f, progress.value)
scaleY = lerp(startScaleY, 1f, progress.value)
translationX = lerp(src.left - full.left, 0f, progress.value)
translationY = lerp(src.top - full.top, 0f, progress.value)
} else {
// No source bounds provided: fall back to a simple fade.
alpha = progress.value
}
},
) {
DialogContent(allImages, imageUrl, dismissWithAnimation, accountViewModel)
}
}
}
}
@@ -58,8 +58,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
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.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@@ -139,6 +142,11 @@ fun ZoomableContentView(
accountViewModel: AccountViewModel,
) {
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) {
is MediaUrlImage -> {
@@ -147,6 +155,7 @@ fun ZoomableContentView(
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
@@ -156,7 +165,10 @@ fun ZoomableContentView(
is MediaUrlVideo -> {
SensitivityWarning(content.contentWarning, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = content.url,
mimeType = content.mimeType,
@@ -180,6 +192,7 @@ fun ZoomableContentView(
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
@@ -189,7 +202,10 @@ fun ZoomableContentView(
is MediaLocalVideo -> {
content.localFile?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = it.toUri().toString(),
mimeType = content.mimeType,
@@ -209,12 +225,13 @@ fun ZoomableContentView(
if (dialogOpen) {
ZoomableImageDialog(
content,
images,
imageUrl = content,
allImages = images,
sourceBounds = sourceBounds,
onDismiss = {
dialogOpen = false
},
accountViewModel,
accountViewModel = accountViewModel,
)
}
}
@@ -47,7 +47,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
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.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -101,6 +104,7 @@ fun RenderAppDefinition(
if (!theAppMetadata.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
var bannerSourceBounds by remember { mutableStateOf<Rect?>(null) }
AsyncImage(
model = theAppMetadata.banner,
@@ -110,6 +114,7 @@ fun RenderAppDefinition(
Modifier
.fillMaxWidth()
.height(125.dp)
.onGloballyPositioned { bannerSourceBounds = it.boundsInWindow() }
.combinedClickable(
onClick = {},
onLongClick = {
@@ -125,6 +130,7 @@ fun RenderAppDefinition(
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
sourceBounds = bannerSourceBounds,
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
@@ -153,6 +159,7 @@ fun RenderAppDefinition(
verticalAlignment = Alignment.Bottom,
) {
var zoomImageDialogOpen by remember { mutableStateOf(false) }
var pictureSourceBounds by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.size(100.dp)) {
theAppMetadata.picture?.let { picture ->
AsyncImage(
@@ -168,6 +175,7 @@ fun RenderAppDefinition(
).clip(shape = CircleShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.onGloballyPositioned { pictureSourceBounds = it.boundsInWindow() }
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
onLongClick = {
@@ -181,6 +189,7 @@ fun RenderAppDefinition(
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
sourceBounds = pictureSourceBounds,
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
@@ -32,7 +32,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
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.unit.dp
import coil3.compose.AsyncImage
@@ -68,6 +71,7 @@ fun DrawBanner(
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var zoomImageDialogOpen by remember { mutableStateOf(false) }
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
AsyncImage(
model = banner,
@@ -79,6 +83,7 @@ fun DrawBanner(
Modifier
.fillMaxWidth()
.height(150.dp)
.onGloballyPositioned { sourceBounds = it.boundsInWindow() }
.combinedClickable(
onClick = { zoomImageDialogOpen = true },
onLongClick = {
@@ -92,6 +97,7 @@ fun DrawBanner(
if (zoomImageDialogOpen) {
ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(banner),
sourceBounds = sourceBounds,
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel,
)
@@ -51,6 +51,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.LocalClipboard
import androidx.compose.ui.platform.LocalContext
@@ -173,12 +176,15 @@ fun ZoomableUserPicture(
val scope = rememberCoroutineScope()
var zoomImageUrl by remember { mutableStateOf<String?>(null) }
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = size,
modifier = MaterialTheme.colorScheme.userProfileBorderModifier,
modifier =
MaterialTheme.colorScheme.userProfileBorderModifier
.onGloballyPositioned { sourceBounds = it.boundsInWindow() },
onClick = {
val pic = baseUser.profilePicture()
if (pic != null) {
@@ -197,7 +203,8 @@ fun ZoomableUserPicture(
zoomImageUrl?.let {
ZoomableImageDialog(
RichTextParser.parseImageOrVideo(it),
imageUrl = RichTextParser.parseImageOrVideo(it),
sourceBounds = sourceBounds,
onDismiss = { zoomImageUrl = null },
accountViewModel = accountViewModel,
)