Pinch, pan and rotate arrive together, and the centroid is what ties them

Multi-touch gestures produce pan, zoom and rotation simultaneously. The centroid — the point between the fingers — is what makes the transform feel anchored, and ignoring it is the most common photo-viewer bug.

6 min read
androidcomposekotlingestures

Day 74 — Pinch, pan and rotate arrive together

Day 74 of 100. Two fingers instead of one, and a piece of arithmetic that decides whether a photo viewer feels precise or slippery.

The symptom

A pinch-to-zoom image that always zooms toward the middle:

var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }

Image(
    painter = painter,
    contentDescription = null,
    modifier = Modifier
        .graphicsLayer { scaleX = scale; scaleY = scale; translationX = offset.x; translationY = offset.y }
        .pointerInput(Unit) {
            detectTransformGestures { _, pan, gestureZoom, _ ->
                scale *= gestureZoom
                offset += pan
            }
        },
)

Pinch on the top-left corner of the image and it zooms toward the centre, pulling the detail you were looking at away from your fingers. You then have to pan back to find it.

Every native photo viewer keeps the point between your fingers under your fingers. This one doesn't.

Why the obvious fix fails

The obvious fix is to translate by the pinch position:

detectTransformGestures { centroid, pan, gestureZoom, _ ->
    scale *= gestureZoom
    offset += pan + centroid          // no
}

That moves the image by the centroid's absolute position, which sends it flying off screen. The centroid isn't a translation to apply — it's the fixed point the scale should happen around, and keeping a point fixed under scaling requires compensating for how scaling moved it.

The actual mechanism

detectTransformGestures reports four values per event:

detectTransformGestures { centroid, pan, zoom, rotation -> … }
  • centroid — the average position of all pointers, in local coordinates.
  • pan — how far the centroid moved since the last event.
  • zoom — the scale factor for this event (1.0 = unchanged).
  • rotation — degrees since the last event.

All four arrive together, because a two-finger gesture produces all of them simultaneously. Trying to detect them separately is how you get a zoom that fights a pan.

The anchoring maths is short, and it's the whole post:

detectTransformGestures { centroid, pan, gestureZoom, _ ->
    val newScale = (scale * gestureZoom).coerceIn(1f, 5f)
    // Keep `centroid` fixed: compensate for how the scale change moved it
    offset = (offset + centroid / scale) - (centroid / newScale + pan / newScale)
    scale = newScale
}

The insight: scaling about the origin moves every point away from it proportionally. To keep the centroid where it is, translate by the difference between where that point was before the scale and where it would land after.

Dividing pan by the new scale converts screen-space movement into content-space movement, so panning feels one-to-one at every zoom level rather than accelerating as you zoom in.

Constrain before you commit

Two constraints that separate a usable viewer from a frustrating one.

Clamp the scale. coerceIn(1f, 5f) — below 1 the image is smaller than its frame, above 5 it's a blur. Applying the clamp before computing the offset matters, or the offset compensates for a scale the image never reached.

Clamp the pan to the content bounds:

val maxX = (size.width * (scale - 1)) / 2f
val maxY = (size.height * (scale - 1)) / 2f
offset = Offset(
    offset.x.coerceIn(-maxX, maxX),
    offset.y.coerceIn(-maxY, maxY),
)

Without this the user can drag the image entirely off screen and lose it. At scale = 1 both bounds are zero, which correctly pins the image centred.

The transformable modifier

For the common case there's a higher-level API, and Day 72's argument applies — it adds what raw pointer code doesn't:

val state = rememberTransformableState { zoomChange, panChange, rotationChange ->
    scale = (scale * zoomChange).coerceIn(1f, 5f)
    offset += panChange
    rotation += rotationChange
}

Image(
    …,
    modifier = Modifier
        .graphicsLayer { … }
        .transformable(state = state),
)

transformable handles the pointer bookkeeping and exposes state.isTransformInProgress, which is useful for suppressing other interactions mid-gesture. It doesn't do the centroid anchoring for you — that arithmetic is yours either way — but it's the right base.

Reading in the draw phase

Note the transform values are read inside graphicsLayer's lambda:

Modifier.graphicsLayer {
    scaleX = scale; scaleY = scale
    translationX = offset.x; translationY = offset.y
}

That's Day 10's deferred read. A pinch produces events at the touch sample rate — faster than the frame rate — so reading these in composition would recompose the image on every sample. In the draw phase it costs nothing above the draw itself.

For a gesture this frequent, that isn't a micro-optimisation; it's the difference between a smooth pinch and a stuttering one on a mid-range device.

Double-tap to zoom

Worth adding because it's what most users actually reach for, and it's a combinedClickable plus an animation:

val animatedScale by animateFloatAsState(scale, label = "zoom")

Modifier.combinedClickable(
    onClick = { toggleChrome() },
    onDoubleClick = { scale = if (scale > 1f) 1f else 2.5f },
    indication = null,
    interactionSource = remember { MutableInteractionSource() },
)

Day 72's caveats apply: this delays the single tap, and it's unreachable under TalkBack. Both are acceptable here because zoom is a viewing refinement rather than the primary action — and a screen-reader user has the platform's own magnification.

How to prove it

The anchoring claim has a precise manual test. Put a finger on a specific, identifiable detail — a face, a word — and pinch. That detail should stay under your fingers throughout. If it drifts toward the centre, the centroid compensation is missing or wrong.

For the pan-scaling, zoom to 4× and drag. The image should move at the same rate as your finger. If it races ahead, pan isn't being divided by the scale.

The performance check is Layout Inspector's recomposition counts during a pinch: reading in graphicsLayer should show zero, and a value read in composition will show hundreds.

What this generalizes to

The principle is a transform needs an origin, and the default origin is rarely the right one. Scale, rotation and skew all happen about a point, and the natural point for a direct-manipulation gesture is wherever the user is touching — not the centre of the element.

The same arithmetic shows up in map viewers, canvas editors, image croppers and 3D cameras, always with the same failure when it's skipped: the thing you were looking at slides away as you zoom. Once you've written the compensation once, you recognise its absence instantly in other people's apps.

Requiring two fingers

One detail that matters inside a scrolling parent. A single-finger pan on a zoomed image should scroll the page when the image is at 1×, and pan the image when it's zoomed in — otherwise the user can't scroll past a full-width photo.

Modifier.transformable(
    state = state,
    lockRotationOnZoomPan = true,
    enabled = scale > 1f,        // only claim the gesture when zoomed
)

Gating on scale > 1f is the simplest version and covers most of it: at rest the image ignores drags and the list scrolls; zoomed in, the image takes them. detectTransformGestures also has a panZoomLock parameter for the related case where a small rotation during a pinch shouldn't spin the content.

Tomorrow, Day 75: scrolling and nested scroll — the connection that lets a collapsing toolbar and a list agree about who consumed what.


Day 74 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Multi-touch.