From 76c4771b582d560ecba837c087400a9a689f9cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 19:42:07 +0000 Subject: [PATCH] fix(ui): start zoomable dialog close animation from zoomed bounds The close animation lerped from the image's unzoomed layout bounds to the source thumbnail rect, so dismissing a zoomed-in image jumped: the image was visible at the inner zoomable's transformed bounds but the exit animation rewound from the layout bounds instead. Hoist the per-page ZoomState up to the dialog and feed its live scale + offset into the outer graphicsLayer, so the grow/shrink animation matches whatever the user is currently seeing on screen. At identity zoom (entry case) behavior is unchanged. --- .../ui/components/ZoomableContentDialog.kt | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index de503573f7..e53321661e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -109,6 +109,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.ZoomState import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -129,6 +130,11 @@ fun ZoomableImageDialog( // viewport — aligns with the tapped thumbnail at progress = 0. var imageBounds by remember { mutableStateOf(null) } + // ZoomState of the currently-visible image, hoisted from RenderImageOrVideo so the + // grow/shrink animation can read live scale/offset and start the exit from the + // image's actual on-screen bounds when the user has zoomed in. + var currentZoomState by remember { mutableStateOf(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. @@ -209,6 +215,8 @@ fun ZoomableImageDialog( sourceBounds = sourceBounds, imageBounds = imageBounds, onImageBoundsChanged = updateImageBounds, + currentZoomState = currentZoomState, + onZoomStateChanged = { currentZoomState = it }, progress = progressProvider, onDismiss = dismissWithAnimation, accountViewModel = accountViewModel, @@ -225,6 +233,8 @@ private fun DialogContent( sourceBounds: Rect?, imageBounds: Rect?, onImageBoundsChanged: (Rect) -> Unit, + currentZoomState: ZoomState?, + onZoomStateChanged: (ZoomState) -> Unit, progress: () -> Float, onDismiss: () -> Unit, accountViewModel: AccountViewModel, @@ -281,18 +291,32 @@ private fun DialogContent( src.width > 0f && src.height > 0f && img.width > 0f && img.height > 0f ) { + // Account for user-applied zoom: the image is rendered at + // imageBounds transformed by the inner zoomable (uniform scale + // around the layout center, then offset). The exit animation + // must start from those visible bounds, not the unzoomed layout + // bounds — otherwise dismissing a zoomed-in image jumps. + val zoom = currentZoomState + val zScale = zoom?.scale ?: 1f + val zOffX = zoom?.offsetX ?: 0f + val zOffY = zoom?.offsetY ?: 0f + val imgCenter = img.center + val zoomedWidth = img.width * zScale + val zoomedHeight = img.height * zScale + val zoomedCenterX = imgCenter.x + zOffX + val zoomedCenterY = imgCenter.y + zOffY + transformOrigin = TransformOrigin(0f, 0f) // Uniform scale so non-square images keep their aspect ratio during // the grow animation. max() so the image covers the source rect in // at least one dimension; the other overflows centered on the tap. - val startScale = maxOf(src.width / img.width, src.height / img.height) + val startScale = maxOf(src.width / zoomedWidth, src.height / zoomedHeight) val srcCenter = src.center - val imgCenter = img.center val p = progress() scaleX = lerp(startScale, 1f, p) scaleY = lerp(startScale, 1f, p) - translationX = lerp(srcCenter.x - startScale * imgCenter.x, 0f, p) - translationY = lerp(srcCenter.y - startScale * imgCenter.y, 0f, p) + translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p) + translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p) } else { // No source bounds: fall back to a plain fade. alpha = progress() @@ -305,6 +329,7 @@ private fun DialogContent( ) { index -> allImages.getOrNull(index)?.let { pageContent -> Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val isCurrent = index == pagerState.currentPage RenderImageOrVideo( content = pageContent, roundedCorner = false, @@ -312,11 +337,9 @@ private fun DialogContent( controllerVisible = controllerVisible, accountViewModel = accountViewModel, onContentBoundsChanged = - if (index == pagerState.currentPage) { - onImageBoundsChanged - } else { - null - }, + if (isCurrent) onImageBoundsChanged else null, + onZoomStateChanged = + if (isCurrent) onZoomStateChanged else null, ) } } @@ -330,6 +353,7 @@ private fun DialogContent( controllerVisible = controllerVisible, accountViewModel = accountViewModel, onContentBoundsChanged = onImageBoundsChanged, + onZoomStateChanged = onZoomStateChanged, ) } } @@ -517,6 +541,7 @@ private fun RenderImageOrVideo( controllerVisible: MutableState, accountViewModel: AccountViewModel, onContentBoundsChanged: ((Rect) -> Unit)? = null, + onZoomStateChanged: ((ZoomState) -> Unit)? = null, ) { val contentScale = if (isFiniteHeight) { @@ -534,6 +559,13 @@ private fun RenderImageOrVideo( Modifier.fillMaxWidth() } + // Hoist ZoomState so the parent dialog can read live scale/offset and start the + // exit animation from the image's actual on-screen bounds when zoomed in. + val zoomState = rememberZoomState() + LaunchedEffect(zoomState, onZoomStateChanged) { + onZoomStateChanged?.invoke(zoomState) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) { when (content) { is MediaUrlImage -> { @@ -541,7 +573,7 @@ private fun RenderImageOrVideo( Modifier .fillMaxWidth() .zoomable( - rememberZoomState(), + zoomState, onTap = { controllerVisible.value = !controllerVisible.value }, @@ -606,7 +638,7 @@ private fun RenderImageOrVideo( Modifier .fillMaxWidth() .zoomable( - rememberZoomState(), + zoomState, onTap = { controllerVisible.value = !controllerVisible.value },