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:
Vitor Pamplona
2026-04-15 09:38:32 -04:00
committed by GitHub
5 changed files with 206 additions and 34 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
@@ -57,14 +60,23 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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
@@ -94,6 +106,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@@ -102,11 +116,62 @@ 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) }
// 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(
onDismissRequest = onDismiss,
onDismissRequest = dismissWithAnimation,
properties =
DialogProperties(
usePlatformDefaultWidth = true,
@@ -123,11 +188,31 @@ 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)
Box(modifier = Modifier.fillMaxSize()) {
// 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(
allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent,
sourceBounds: Rect?,
imageBounds: Rect?,
onImageBoundsChanged: (Rect) -> Unit,
progress: () -> Float,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
@@ -179,31 +268,63 @@ private fun DialogContent(
),
Alignment.TopCenter,
) {
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
) { index ->
allImages.getOrNull(index)?.let {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = it,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
// Transformed image/video container. Only this layer scales & translates so the
// image aligns with the tapped thumbnail on enter/exit. Controls stay put.
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
val src = sourceBounds
val img = imageBounds
if (src != null && img != null && img.width > 0f && img.height > 0f) {
transformOrigin = TransformOrigin(0f, 0f)
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 {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = imageUrl,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo(
content = imageUrl,
roundedCorner = false,
isFiniteHeight = true,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
onContentBoundsChanged = onImageBoundsChanged,
)
}
}
}
@@ -211,6 +332,8 @@ private fun DialogContent(
visible = controllerVisible.value,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
// Also fade with the grow animation so controls appear/disappear alongside it.
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
) {
Row(
modifier =
@@ -373,6 +496,7 @@ private fun RenderImageOrVideo(
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
onContentBoundsChanged: ((Rect) -> Unit)? = null,
) {
val contentScale =
if (isFiniteHeight) {
@@ -381,7 +505,16 @@ private fun RenderImageOrVideo(
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) {
is MediaUrlImage -> {
val mainModifier =
@@ -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,
)