DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Pinning the point you pinch: the one line behind anchored zoom

Open a photo on your phone and pinch to zoom. The spot between your fingers stays between your fingers while everything around it grows. Trackpad pinch, scroll-wheel zoom on a map, double-tap to zoom into a diagram: same feel. Whatever point you aim at holds still while the view scales around it.

That behavior is anchored zoom, and it comes down to one line of arithmetic. This post is about that line. The example package is photo_zoom, which I maintain, but the math is the same whether you are building an image viewer, a map, a canvas, or a diagram editor.

GIF: a marked focal point staying fixed on screen while the scene scales around it, then pans

That is a conceptual illustration of the behavior. It is not a screen recording of the Flutter widget. The marked point holds its spot on screen while the grid scales, then the whole view pans.

A view is a scale and a translation

A zoomable view maps content coordinates (where a pixel sits inside the image or the map) to screen coordinates (where it lands on the display). In one dimension the entire mapping is one equation:

screen = scale * content + translation

scale is how far you are zoomed in. translation is how far the content is shifted. Two numbers per axis, and that is the whole transform.

Now zoom in, meaning raise scale, and leave translation alone. Every content value gets multiplied by a bigger number, so every point moves, including the one you were looking at. The image balloons toward a corner and the thing you cared about slides away.

Pin one point and solve for the rest

Pick the point you want to hold still. Say it sits at content coordinate F, and you want it to stay at screen coordinate S. Put those into the mapping:

S = scale * F + translation

You know S, F, and the new scale. The only unknown is translation, so solve for it:

translation = S - scale * F

That is the line. Recompute it every time scale changes, and the point at F lands on S on every frame. The translation is forced here: once the focal point and the scale are fixed, there is exactly one translation that keeps the point pinned. You never choose it directly.

Where the focal point comes from

A pinch hands you two things: a focal point, which is the midpoint between your two fingers, and a change in scale. Feed that focal point into translation = S - scale * F and the image grows out from between your fingers instead of drifting toward a corner.

The same formula covers the other zoom gestures you already use:

  • Double-tap to zoom: the focal point is where you tapped.
  • Scroll-wheel or trackpad zoom on desktop: the focal point is the cursor.

Different input, same math. Whatever point you name as the focal point is the point that stays put.

Two axes, no new ideas

Screens are 2D, and the rule runs on each axis on its own. Same formula, twice:

translationX = focalScreenX - scale * focalContentX

translationY = focalScreenY - scale * focalContentY

There is no cross-talk between X and Y. The point you pinch is pinned horizontally and vertically by the same subtraction.

The line, as code

Here it is as a pure-Dart function. It takes the focal point in both coordinate spaces plus the scale, and returns the translation that pins it, as a record with x and y:

// Given where the focal point is on screen and in the content, and the
// scale, return the translation that keeps the focal point pinned.
({double x, double y}) anchoredTranslation({
  required double focalScreenX,
  required double focalScreenY,
  required double focalContentX,
  required double focalContentY,
  required double scale,
}) => (
      x: focalScreenX - focalContentX * scale,
      y: focalScreenY - focalContentY * scale,
    );
Enter fullscreen mode Exit fullscreen mode

Read the body and it is the 2D formula from the last section. focalScreenX - focalContentX * scale is S - scale * F on the X axis, and the same on Y.

Checking it holds

Here is the check I ran. Drop this into the same file as the function above and run it. It fixes a focal point, zooms from scale 1.0 to 3.0, and pushes the pinned point back through screen = scale * content + translation to confirm it lands in the same place:

void main() {
  const focalScreenX = 200.0;
  const focalScreenY = 150.0;
  const focalContentX = 300.0;
  const focalContentY = 400.0;

  for (final scale in [1.0, 3.0]) {
    final t = anchoredTranslation(
      focalScreenX: focalScreenX,
      focalScreenY: focalScreenY,
      focalContentX: focalContentX,
      focalContentY: focalContentY,
      scale: scale,
    );
    final backX = scale * focalContentX + t.x;
    final backY = scale * focalContentY + t.y;
    print('scale $scale -> translation (${t.x}, ${t.y}); '
        'focal maps back to ($backX, $backY)');
  }
}
Enter fullscreen mode Exit fullscreen mode

Run it and you get two lines. At scale 1.0 the translation is (-100.0, -250.0). At scale 3.0 it is (-700.0, -1050.0). The translation moves a long way between the two, because at 3x the content has to shift much further to hold that one point in place. But the pinned point maps back to (200.0, 150.0) at both scales. That equality, the same screen position at every scale, is the property the formula guarantees.

Where this lives in Flutter

In Flutter the transform is a Matrix4. A Transform widget applies one, and InteractiveViewer maintains one for you as you pan and zoom. The scale and translation from above are entries in that matrix. When you pinch inside an InteractiveViewer, it is running this same focal-point arithmetic to pick the matrix for the next frame.

photo_zoom, the package I maintain, is a drop-in replacement for photo_view that wires this up for image viewing. It reads the pinch, double-tap, and drag gestures, builds the anchored transform internally, and scales the image around the gesture's focal point. A caller uses PhotoView with an imageProvider and, if they want, minScale, maxScale, and initialScale (typed as PhotoViewScale). The anchored-zoom math stays inside; the caller gets zoom that holds where they pinched.

To be plain about what that buys you: anchored zoom is standard behavior. photo_view does it, InteractiveViewer does it, your phone's photo app does it. None of it is special to photo_zoom. The part worth carrying to other projects is the one line, translation = S - scale * F. Once you have seen it you can add anchored zoom to a custom canvas, a diagram editor, or a map yourself.

A wrinkle: when a drag should pan and when it should dismiss

Once you allow zooming, a drag has two possible jobs. It can pan the zoomed image, or it can dismiss the viewer and close the full-screen view. You have to decide which one a given drag means.

The rule that feels right keys off whether there is anything to pan into. While the image is larger than the viewport, there is off-screen content to pan into, so a drag pans. When the image is at or below the fit scale, there is nothing to pan, so a drag can dismiss.

photo_zoom 0.2.1 fixed a case here: dismiss had gone dead once the image was zoomed out below its initial scale. At that point there is nothing left to pan, so a drag should have been free to dismiss, and it was not. This is the kind of edge that shows up only once anchored zoom already works and you start spending time in the zoomed-out state.

Keep this one line

If you keep one thing from this, keep translation = S - scale * F. Recompute it whenever the scale changes, using the focal point the gesture hands you, and the point under your finger stays under your finger. The matrix, the widget, and the package are all wrappers around that subtraction.

Top comments (0)