Bounding a pinch-zoom pan on the viewport lets the photo leave the screen — bound it on the fitted image

A full-screen photo viewer with pinch zoom clamped its pan offset so « the image never leaves the screen ». The clamp used the viewport size. For a photo whose aspect ratio differs from the screen, that formula lets the image be pushed entirely out of view well before the maximum zoom.

TL;DR — With ContentScale.Fit, compute the fitted image size first and clamp per axis: the offset is 0 while scale × fitted ≤ viewport, otherwise ±(scale × fitted − viewport) / 2. The viewport-only formula ±(scale − 1) × viewport / 2 is wrong as soon as the image does not fill the viewport.

The setup

An Android app shows card photos in a Dialog that fills the screen. The photo is drawn with Image(..., contentScale = ContentScale.Fit) inside a Box that also carries Modifier.transformable(state) and a graphicsLayer { scaleX = scale; scaleY = scale; translationX = offset.x; translationY = offset.y }. Zoom is 1× to 5×. A small pure object, ZoomMath, clamps the scale and the offset on every gesture so the viewer never shows an empty black screen with the photo lost somewhere outside. The viewport is the size reported by onSizeChanged on the Box.

What I expected

The first version — which I had written into the implementation plan myself — bounded the pan symmetrically from the viewport:

val maxX = (scale - 1f) * viewport.width / 2f
val maxY = (scale - 1f) * viewport.height / 2f

That is exact when the image covers the whole viewport at 1×: at 2× the image is twice the viewport, so it can slide by half a viewport each way and an edge still touches the border. It felt like the general case.

What actually happens

It is only the case for an image with the same aspect ratio as the viewport. With Fit, a landscape photo on a portrait phone occupies a strip: on a 1000×2000 viewport a 1600×900 photo is drawn 1000×562.5. At 3× its height is 1687, still smaller than the viewport, yet the viewport formula allows a vertical pan of ±(3−1)×2000/2 = ±2000 px. Dragging by 2000 px moves a 1687-px-tall image completely out of a 2000-px window. In general the image can leave the screen once scale > 2 × viewport / (viewport − fitted) on the letterboxed axis — 2.8× for this photo, inside the allowed 1×–5×.

I did not notice it by hand-testing with the app’s own photos, which happen to be portrait. A reviewer worked the arithmetic; the unit tests then confirmed it.

Three panels: the fitted 16:9 photo in a portrait viewport at 1×; at 3× the viewport formula allows a pan that pushes the image off-screen; at 3× the fitted formula keeps it centred vertically and bounded horizontally

The fix

Compute the fitted size once, then clamp each axis on it:

object ZoomMath {
    fun fittedSize(viewport: Size, image: Size): Size {
        if (viewport.width <= 0f || viewport.height <= 0f || image.width <= 0f || image.height <= 0f) return viewport
        val k = minOf(viewport.width / image.width, viewport.height / image.height)
        return Size(image.width * k, image.height * k)
    }

    fun clampOffset(offset: Offset, scale: Float, viewport: Size, fitted: Size = viewport): Offset =
        Offset(clampAxis(offset.x, scale, fitted.width, viewport.width),
               clampAxis(offset.y, scale, fitted.height, viewport.height))

    private fun clampAxis(value: Float, scale: Float, fittedDim: Float, viewportDim: Float): Float {
        val max = (scale * fittedDim - viewportDim) / 2f
        return if (max <= 0f) 0f else value.coerceIn(-max, max)
    }
}

The viewer needs the bitmap size to call fittedSize, so it loads the bitmap itself (produceState keyed on the photo id) instead of delegating to a composable that hides it, falls back to fitted = viewport while decoding, and re-clamps in a LaunchedEffect(viewport, fitted) so a rotation cannot leave a stale offset:

val fitted = remember(viewport, bitmap) {
    bitmap?.let { ZoomMath.fittedSize(viewport, Size(it.width.toFloat(), it.height.toFloat())) } ?: viewport
}
LaunchedEffect(viewport, fitted) { offset = ZoomMath.clampOffset(offset, scale, viewport, fitted) }
val transform = rememberTransformableState { _, zoom, pan, _ ->
    scale = ZoomMath.clampScale(scale * zoom)
    offset = ZoomMath.clampOffset(offset + pan, scale, viewport, fitted)
}

Worked example, checked by unit tests: viewport 1000×2000, photo 1600×900 → fitted 1000×562.5; at 2× the bounds are ±500 horizontally and 0 vertically; at 5× they are ±2000 and ±406.25. With the default fitted = viewport the function reduces to the old formula, so the original tests kept passing.

Why it works

graphicsLayer scales around the node’s centre, so at scale s an image of fitted size f spans s·f, centred. Its edge reaches the viewport border when |offset| = (s·f − v) / 2; beyond that a border shows through. When s·f ≤ v the image is smaller than the window on that axis and the only position that keeps it fully visible and centred is 0. The viewport formula is the special case f = v.

The explicit if (max <= 0f) 0f matters for a second reason. The earlier code computed coerceIn(-maxX, maxX) with maxX = 0f at 1×, which returns -0f for a negative input. Offset is a value class over a packed Long, and its equals compares bits: Offset(0f, -0f) == Offset.Zero is false even though 0f == -0f. A test asserting Offset.Zero at scale 1 failed on exactly that, with bitsY=-2147483648 in the failure message. Returning a literal 0f sidesteps it.

What I did not test

Rotation gestures (ignored), zooming around the pinch centroid (the viewer zooms around the centre), and images larger than the viewport at 1× — Fit never produces those. The maths assumes graphicsLayer‘s default transform origin, the centre of the node.

Facts

context: Jetpack Compose full-screen photo viewer with transformable() + graphicsLayer, ContentScale.Fit, zoom 1×–5×
problem: clamping the pan with ±(scale−1)·viewport/2 lets an image whose aspect ratio differs from the viewport be panned fully off-screen above scale 2·v/(v−fitted)
solution: compute the Fit size (fittedSize) and clamp per axis: 0 while scale·fitted ≤ viewport, else ±(scale·fitted − viewport)/2; return a literal 0f to avoid -0f breaking Offset equality
verified_on: 2026-08-30
applies_to: [Jetpack Compose, any centre-origin scale + translate zoom viewer]
does_not_apply_to: [viewers that zoom around the gesture centroid or crop the image at 1×]

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut