Pan and Zoom in Flutter: Building Pinch-to-Zoom with InteractiveViewer
Maps, canvases, image galleries, and whiteboards all need the same gesture pair. InteractiveViewer gives you pan and zoom for free — but boundaries, constraints, and programmatic control are where it gets interesting.
Published on • October 14, 2026
AI Assistant

Any Flutter app that shows a large canvas — a floor plan, a mind map, a photo, a diagram — eventually needs the same two gestures: drag to pan, pinch to zoom, scroll wheel to scale. InteractiveViewer implements both, including momentum, boundaries, and a controller you can drive from code.
The default one-liner works immediately:
InteractiveViewer(
child: MyHugeCanvas(),
)
Everything below is what happens when the default is not what you want.
How the transformation works
InteractiveViewer maintains a Matrix4 transformation on its child. Gestures update it: dragging translates, pinching scales around the focal point. The widget then applies the matrix and clips to its own bounds with Clip.hardEdge by default.
Three properties decide how much freedom the user has:
minScale/maxScale— defaults0.8and2.5. Note thatminScalebelow1.0lets the child shrink smaller than its box; setminScale: 1.0if you never want the content to shrink.boundaryMargin— a margin around the visible area that the content may be dragged into.EdgeInsets.zero(the default) means the content cannot be dragged out of view at all: pan past the edge and it stops.boundaryMargin: EdgeInsets.all(double.infinity)removes the limit entirely, which is what a map-style UI wants.constrained— whentrue(default), normal layout constraints are applied to the child, so the child sizes itself normally and the view transforms. Setconstrained: falseand the child is laid out at its natural (possibly huge) size inside the viewer.
panEnabled and scaleEnabled let you disable one gesture — a read-only zoomable image viewer sets panEnabled: false if you want pinch-only behavior, or scaleEnabled: false for a pure pan surface.
InteractiveViewer(
minScale: 1.0,
maxScale: 4.0,
boundaryMargin: EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: const DiagramCanvas(),
)
Programmatic control: TransformationController
For “zoom to fit,” “reset,” “center on this node,” or a button that zooms in by a fixed factor, you need the matrix itself. Pass a TransformationController and you can read and write the transform directly:
class CanvasController extends StatefulWidget { /* ... */ }
class _CanvasControllerState extends State<CanvasController> {
final TransformationController controller = TransformationController();
final Matrix4 identity = Matrix4.identity();
void resetView() => controller.value = Matrix4.identity();
void zoomIn() {
final m = Matrix4.identity()..scale(1.25);
controller.value = m * controller.value;
}
void zoomTo(Rect target, double zoom) {
// Matrix that maps `target` onto the visible viewport.
controller.value = Matrix4.identity()
..translate(-target.left * zoom, -target.top * zoom)
..scale(zoom);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
// ...
InteractiveViewer(
transformationController: controller,
child: const DiagramCanvas(),
);
}
For a smooth transition, animate the matrix rather than snapping it. A small helper that interpolates between two Matrix4s with a CurvedAnimation gives you “fly to node” behavior:
void animateTo(Matrix4 target) {
final animation = Matrix4Tween(begin: controller.value, end: target)
.animate(CurvedAnimation(parent: this.curve, curve: Curves.easeOutCubic));
animation.addListener(() => controller.value = animation.value);
}
Dispose the controller, and remember that the caller owns any controller it passes in.
Gesture callbacks and the widget builder
onInteractionStart, onInteractionUpdate, and onInteractionEnd fire with the focal point and scale — the place to show a zoom percentage badge or suspend expensive painting while the user is actively gesturing. onInteractionEnd gives you the velocity of release, useful for snapping to a grid after a flick.
Two knobs affect how gestures feel:
scaleFactor— how much scale a single pointer-scroll unit produces. Higher is more sensitive for mouse wheels; the.builderconstructor uses a different default (200.0) tuned for on-demand building.trackpadScrollCausesScale— on desktop, whether a two-finger trackpad scroll scales or pans. Set it based on your app: maps usually pan, image viewers usually scale.interactionEndFrictionCoefficient— how quickly momentum decelerates after release.
panAxis restricts panning to PanAxis.aligned (horizontal or vertical only, no diagonals) when you want a filmstrip-like feel instead of free 2D movement.
Large content: the builder constructor
A canvas with thousands of widgets is expensive even when it is not visible. InteractiveViewer.builder builds the child on demand for the current viewport, which is how you show a very large surface without paying for off-screen children:
InteractiveViewer.builder(
builder: (context, viewport) {
final visibleNodes = graph.nodesIn(viewport);
return CustomPaint(
painter: NodePainter(nodes: visibleNodes),
size: Size.infinite,
);
},
)
The builder receives the visible rectangle in the child’s coordinate space. The Lazy Flutter Performance session linked from the API docs demonstrates building subsets of a large widget set this way — for anything beyond a screen or two of content, prefer it over putting the whole tree inside child.
Clip, gestures, and the dead-area trap
The clipBehavior parameter has a non-obvious interaction:
- With
Clip.none, the child may draw outside the viewer’s bounds — a zoomed image bleeds over neighboring widgets. - But the viewer does not receive gestures outside its own bounds. Set
Clip.noneon a viewer that is smaller than the area it draws into and you create dead zones: pixels that look interactive but ignore touches.
The docs’ advice: don’t set Clip.none, or make sure the InteractiveViewer itself is sized to the area that should be interactive. When you do want overflow visually, give the viewer a larger layout slot (via Padding or a SizedBox larger than the content region) rather than turning clipping off.
Nesting inside scrollables
A pinch inside a ListView or a page inside a PageView competes with the parent’s gesture recognizer. Options, in order of preference:
- Put the viewer in a fixed-size box and let it own its gesture entirely — the common case for a full-screen canvas.
- Restrict the parent’s physics where the viewer sits, so the parent only responds to drags that start outside it.
- Disable one axis with
panAxisorpanEnabled/scaleEnabledto reduce ambiguity.
Avoid placing a zoomable viewer directly inside a vertical scroller and expecting both to work on the same gesture — Flutter’s arena will pick one, and it will not always be the one you meant.
Accessibility and input parity
Zoom is an accessibility feature as much as a gesture. Provide explicit zoom controls (buttons or a slider writing to the TransformationController) so users who cannot pinch — one-handed use, switch access, desktop users — can still scale content. Combine with Semantics labels on the canvas, and respect text scaling when the child contains text: a fixed-maxScale viewer that cannot zoom out far enough to read is worse than no viewer at all.
Keyboard support comes free on desktop for scroll-wheel scaling; trackpadScrollCausesScale decides trackpad behavior. For arrow-key panning, drive the controller from a Focus node.
Checklist
- Set
minScale/maxScaledeliberately; the defaults (0.8–2.5) rarely fit a map. - Use
boundaryMarginto control how far content can be dragged;EdgeInsets.zerolocks it to the edges. - Own a
TransformationControllerfor zoom-to-fit, reset, and animated flights — and dispose it. - Reach for
InteractiveViewer.builderwhen the content is larger than the screen. - Don’t use
Clip.noneunless the viewer’s bounds already match the interactive area. - Offer button/slider zoom alongside pinch gestures.
Sources: InteractiveViewer API, Flutter documentation.