Merge pull request #4280 from vitorpamplona/ccr-2f271106-tg06kt

Fix image zoom transition clipping during open/close animation
This commit is contained in:
Vitor Pamplona
2026-09-29 18:03:18 -04:00
committed by GitHub
@@ -54,12 +54,17 @@ 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.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
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.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -267,36 +272,47 @@ private fun DialogContent(
.graphicsLayer {
val src = sourceBounds
val img = imageBounds
if (src != null && img != null &&
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
if (src != null && img != null && src.hasArea() && img.hasArea()) {
// Account for user-applied zoom: the exit animation must start from
// the visible bounds, not the unzoomed layout bounds — otherwise
// dismissing a zoomed-in image jumps.
val zoomed = img.zoomedBy(currentZoomState)
// Uniform scale so non-square images keep their aspect ratio during
// the grow animation. The image covers the source rect; the overflow
// is clipped below.
val startScale = coverScale(src, zoomed)
val p = progress()
val scale = lerp(startScale, 1f, p)
val tx = lerp(src.center.x - startScale * zoomed.center.x, 0f, p)
val ty = lerp(src.center.y - startScale * zoomed.center.y, 0f, p)
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 / zoomedWidth, src.height / zoomedHeight)
val srcCenter = src.center
val p = progress()
scaleX = lerp(startScale, 1f, p)
scaleY = lerp(startScale, 1f, p)
translationX = lerp(srcCenter.x - startScale * zoomedCenterX, 0f, p)
translationY = lerp(srcCenter.y - startScale * zoomedCenterY, 0f, p)
scaleX = scale
scaleY = scale
translationX = tx
translationY = ty
if (p < 1f) {
// The thumbnail may be a crop of the image (e.g. a square gallery
// cell showing a 4:3 photo). Clip to a window that morphs from the
// thumbnail's rect into the whole viewport, so the transition opens
// from and closes into exactly what was on screen. The viewport (not
// the measured image) is the end state so there is nothing to snap
// when the clip turns off at p = 1, where pager neighbours must show.
// Window coordinates, mapped back into this layer's pre-transform
// space. A layer outline, not a draw-phase clip, so animating it
// doesn't re-record the pager's display list every frame.
shape =
RectClipShape(
Rect(
left = (lerp(src.left, 0f, p) - tx) / scale,
top = (lerp(src.top, 0f, p) - ty) / scale,
right = (lerp(src.right, size.width, p) - tx) / scale,
bottom = (lerp(src.bottom, size.height, p) - ty) / scale,
),
)
clip = true
}
} else {
// No source bounds: fall back to a plain fade.
alpha = progress()
@@ -441,6 +457,38 @@ internal suspend fun saveMediaToGallery(
}
}
private fun Rect.hasArea() = width > 0f && height > 0f
/**
* The image's on-screen bounds after the user's pinch zoom: the zoomable scales
* uniformly around the layout center, then offsets.
*/
private fun Rect.zoomedBy(zoom: ZoomState?): Rect {
val zScale = zoom?.scale ?: 1f
val halfWidth = width * zScale / 2f
val halfHeight = height * zScale / 2f
val centerX = center.x + (zoom?.offsetX ?: 0f)
val centerY = center.y + (zoom?.offsetY ?: 0f)
return Rect(centerX - halfWidth, centerY - halfHeight, centerX + halfWidth, centerY + halfHeight)
}
/** Uniform scale at which [image] covers [source] in both dimensions. */
private fun coverScale(
source: Rect,
image: Rect,
): Float = maxOf(source.width / image.width, source.height / image.height)
/** Clips a layer to a fixed [rect] in its own coordinates, regardless of the layer's size. */
private class RectClipShape(
private val rect: Rect,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline = Outline.Rectangle(rect)
}
@Composable
private fun RenderImageOrVideo(
content: BaseMediaContent,