fix: anchor zoom animation to the image instead of the dialog viewport

Previously the graphicsLayer transform wrapped the whole dialog Surface,
so at progress=0 the dialog's top edge (with the back button) aligned
with the source thumbnail's top edge and the actual image appeared
below it.

Move the transform down to a Box that wraps just the image/video
container inside DialogContent, and use the image's natural layout
bounds (captured via a new onContentBoundsChanged callback on
RenderImageOrVideo's Row) as the animation target. This makes the
image's own borders align with the tapped thumbnail's borders on
enter and exit.

The controls row (back/share/download) now lives outside the transform
with alpha tied to the progress value so it fades in alongside the
grow animation and fades out with the shrink animation.
This commit is contained in:
Claude
2026-04-15 12:42:40 +00:00
parent d3633a396c
commit 2ca1d05a71
@@ -139,6 +139,7 @@ fun ZoomableImageDialog(
} }
val dismissWithAnimation: () -> Unit = { if (!isExiting) isExiting = true } val dismissWithAnimation: () -> Unit = { if (!isExiting) isExiting = true }
val progressProvider: () -> Float = { progress.value }
Dialog( Dialog(
onDismissRequest = dismissWithAnimation, onDismissRequest = dismissWithAnimation,
@@ -164,46 +165,23 @@ fun ZoomableImageDialog(
dialogWindow.attributes = attributes dialogWindow.attributes = attributes
} }
var fullBounds by remember { mutableStateOf<Rect?>(null) } Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier
.fillMaxSize()
.onGloballyPositioned { fullBounds = it.boundsInWindow() },
) {
// Background surface that fades in as the content grows to fullscreen. // Background surface that fades in as the content grows to fullscreen.
Surface( Surface(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = progress.value }, .graphicsLayer { alpha = progressProvider() },
) {} ) {}
// Content layer that grows from the source bounds to fullscreen (and back on exit). DialogContent(
Box( allImages = allImages,
modifier = imageUrl = imageUrl,
Modifier sourceBounds = sourceBounds,
.fillMaxSize() progress = progressProvider,
.graphicsLayer { onDismiss = dismissWithAnimation,
val src = sourceBounds accountViewModel = accountViewModel,
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)
}
} }
} }
} }
@@ -213,6 +191,8 @@ fun ZoomableImageDialog(
private fun DialogContent( private fun DialogContent(
allImages: ImmutableList<BaseMediaContent>, allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent, imageUrl: BaseMediaContent,
sourceBounds: Rect?,
progress: () -> Float,
onDismiss: () -> Unit, onDismiss: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
@@ -220,6 +200,11 @@ private fun DialogContent(
val controllerVisible = remember { mutableStateOf(true) } val controllerVisible = remember { mutableStateOf(true) }
val sharePopupExpanded = remember { mutableStateOf(false) } val sharePopupExpanded = 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 source thumbnail at progress = 0.
var imageBounds by remember { mutableStateOf<Rect?>(null) }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) { LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
launch { launch {
val page = allImages.indexOf(imageUrl) val page = allImages.indexOf(imageUrl)
@@ -255,31 +240,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) {
{ imageBounds = it }
} 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 = { imageBounds = it },
) )
}
} }
} }
@@ -287,6 +304,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 =
@@ -449,6 +468,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) {
@@ -457,7 +477,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 =