photo_zoom

A pan and zoom image viewer, and a gallery of them, that zooms where you touched rather than towards the middle.

A numbered grid in a viewer. A marker sits on tile 6. A double tap magnifies
it and tile 6 stays under the marker; a finger drags the image around; two
fingers pinch out from the same point and tile 6 is still there, sharper. Then
it returns to a contained
fit.

import 'package:photo_zoom/photo_zoom.dart';

PhotoView(imageProvider: const AssetImage('assets/photo.jpg'))

Why this instead of what you already have

Instead of InteractiveViewer. Flutter's own viewer pans and pinches, and for a picture you only ever look at, it is enough. It has no double tap zoom at all (onDoubleTap does not appear in its API), no fit / fill / actual-size cycle, no swipe-between-photos gallery, and it clamps against the widget it wraps rather than the image inside it, so an image letterboxed in its box can still be dragged into the empty margin. Adding those back is the work this package already did.

Instead of photo_view. The API is the same one, so the move is mostly a change of import, and the differences are listed below. The one in the recording is the reason to bother: on a double tap photo_view runs animatePosition(controller.position, Offset.zero) (photo_view_core.dart:282), which returns the image to basePosition and zooms towards the centre whatever you tapped. Its issues #82, #394 and #538 ask for the tapped point to stay put. Here it does, and the same anchoring applies to a pinch and to a mouse wheel.

Every gesture in that recording goes through the same code your users' fingers will: a double tap, a one-finger drag, a two-finger pinch. They are synthesised rather than performed, so each one lands on the same pixel every run, and the grid is numbered so that "the tile under the marker did not change" is something you can check rather than take on trust. cd example && flutter create . && flutter run --dart-define=start=compare shows the same screen, and the buttons hand the viewer back to you.

It gets sharper as you go in, not softer

Magnifying and shrinking want different sampling. FilterQuality.medium reads a mipmap, which is what a shrunken image needs and what leaves a magnified one soft; FilterQuality.high is bicubic, which holds an edge when the image is drawn larger than its own pixels. Which one applies is not a property of the image. It changes as the reader zooms.

So it is decided per frame, from the scale actually on screen, in device pixels rather than logical ones. Cubic is the expensive filter, so it is only asked for once the transform has come to rest: during a pinch or a fling the frame budget matters more, and the difference is not visible on a moving image. Passing filterQuality yourself turns all of that off and uses what you passed.

Reach for it when

  • A photo, a map, a scan or a chart has detail worth magnifying, and the reader wants to zoom into a particular part of it.
  • A gallery needs each photo to keep its own zoom while the pages swipe.
  • The same screen ships to phone and desktop, and the wheel and trackpad should behave like they do everywhere else.

Skip it when the image is decorative, or when a fixed Image with BoxFit already answers the question. This is a viewer, not a canvas: no drawing, no annotation layers, no video.

Scale limits

Limits are written against two computed scales rather than raw numbers, because the number that fits a photo is not the number that fits a map.

Three viewports side by side holding the same wide image. At contained it
fits with bars above and below; at covered it fills the frame and the left and
right edges fall outside; at 1.0 it sits small in the
middle.

contained is the largest scale that still shows the whole image, covered the smallest that leaves no gaps, and 1.0 is one source pixel per logical pixel. Multiply either to get a limit that follows the image instead of guessing at it.

PhotoView(
  imageProvider: const NetworkImage('https://example.com/map.png'),
  minScale: PhotoViewComputedScale.contained * 0.8,
  maxScale: PhotoViewComputedScale.covered * 3,
)

The figure's numbers are the test fixture's: a 200×100 image in a 400×400 viewport really is 2.0, 4.0 and 1.0. Redraw it with dart run tool/scale_states_figure.dart.

PhotoViewGallery.builder(
  itemCount: photos.length,
  onPageChanged: (index) => setState(() => _current = index),
  builder: (context, index) => PhotoViewGalleryPageOptions(
    imageProvider: NetworkImage(photos[index].url),
    heroAttributes: PhotoViewHeroAttributes(tag: photos[index].id),
  ),
)

Each page keeps its own zoom. A drag pans the photo while it has room to move, and turns the page once the photo is against its edge, so panning a zoomed photo does not flip the page out from under it.

Swipe to dismiss

Pass onDismiss and a vertical drag on the unzoomed image slides it and fades the background; let go past dismissThreshold and it fires, usually to pop the route the photo is on.

PhotoView(
  imageProvider: NetworkImage(url),
  onDismiss: () => Navigator.of(context).pop(),
  // How far to drag to dismiss, as a fraction of viewport height. Default 0.2.
  dismissThreshold: 0.2,
)

A shorter drag springs back to rest, and a drag while zoomed still pans. Without onDismiss the gesture is off. PhotoViewGallery takes the same two, applied to every page; a PhotoViewGalleryPageOptions can override them per page.

Parts

Class Role
PhotoView One zoomable image, or any widget via PhotoView.customChild
PhotoViewGallery A PageView of them, from a list or built on demand
PhotoViewController Reads and drives the transform; a ValueNotifier
PhotoViewScaleStateController Reads and drives the double tap cycle
PhotoViewScale PhotoViewScale.value(2), or a PhotoViewComputedScale
PhotoViewHeroAttributes The Hero configuration for a view
PhotoViewGestureDetectorScope Shares drags with a gesture-sensitive parent

Driving it from code

PhotoViewController is a ValueNotifier. Read it with a ValueListenableBuilder and write to it directly:

final controller = PhotoViewController();

PhotoView(imageProvider: provider, controller: controller);

controller.scale = 2;   // clamped into minScale..maxScale
controller.reset();     // back to the start

ValueListenableBuilder(
  valueListenable: controller,
  builder: (context, value, _) => Text('${value.scale}'),
);

Whoever creates a controller disposes it. A controller you do not pass is created and disposed by the view itself.

Desktop and web

enableScrollZoom (on by default) wires up the mouse wheel and trackpad. Events the view cannot act on are left alone rather than swallowed: a scroll-to-zoom-in while already at maxScale, or a trackpad pan with nowhere left to pan, falls through to an ancestor scrollable, so a photo in a scrolling page does not trap the wheel.

Two feeds side by side with a photo in each. On the left the photo is
outlined and the caption reads "the photo zooms"; on the right the feed is
outlined and it reads "the feed scrolls". The only difference stated above them
is whether the photo has any zoom
left.

The two failure modes this sits between are both silent. A viewer that always claims the wheel traps a reader inside the post; one that never claims it cannot zoom on a desktop at all — photo_view 0.15.0 has no PointerSignal handling anywhere in its lib/. Eleven tests in test/pointer_signal_test.dart pin this, the hand-off included. Redraw the figure with dart run tool/wheel_handoff_figure.dart.

Accessibility

The current zoom is exposed to screen readers as a percentage of initialScale, alongside semanticLabel, with increase and decrease actions that zoom in steps. When the platform asks for reduced motion, zoom changes jump to their target instead of animating.

Limits

  • The view fills the box it is given and needs a bounded one. In an unbounded parent, pass customSize.
  • PhotoView.customChild transforms a widget; it does not arbitrate with gestures inside that widget. A child with its own pan or tap handlers will fight the view. Use disableGestures: true and drive the controller yourself.
  • Rotation (enableRotation) turns the child about basePosition, not about the centre of the pinch. The double tap cycle unwinds it back to zero.
  • Pan bounds are worked out from the child's unrotated width and height, so with enableRotation on and the child at an angle, the edges it stops at are the ones it would have had upright. This matches photo_view.
  • No video, and no widget-per-frame content. imageProvider resolves once to learn the image's size; an animated GIF plays, but its first frame sets the size.
  • filterQuality applies to PhotoView.new only. PhotoView.customChild draws whatever the child draws.
  • The gallery does not loop; page 0 is the first page.
  • tightMode from photo_view is not carried over. A SizedBox around the view is the closest replacement, but it is not the same thing: tightMode shrank the background to childSize * scale and kept following it as the zoom changed, where a SizedBox is whatever size you give it and stays there.

Migrating from photo_view

Most code moves across with the import alone. What differs:

photo_view photo_zoom Why
photo_view.dart + photo_view_gallery.dart one photo_zoom.dart One entry point
minScale: 0.5 minScale: PhotoViewScale.value(0.5) dynamic became a type: a bad value is a compile error, not a runtime assert
minScale: PhotoViewComputedScale.contained * 0.8 unchanged
controller.outputStateStream.listen(fn) controller.addListener(fn), or a ValueListenableBuilder The controller is a ValueNotifier; no stream, and updates land on the same frame
PhotoViewControllerBase, addIgnorableListener, setScaleInvisibly, setInvisibly removed Internals that leaked into the public API
PhotoViewControllerValue.rotationFocusPoint removed It was stored and streamed but never reached the transform
PhotoViewScaleState.isScaleStateZooming .isZooming
tightMode: true removed Wrap in a SizedBox
PhotoViewGestureDetectorScope(axis: null) axis is required A scope without an axis did nothing
PhotoViewGallery(..., scaleStateChangedCallback:) unchanged
double tap zooms towards basePosition it zooms at the tap #82, #394, #538
mouse wheel ignored wheel zooms, trackpad pans #481
strictScale freezes the whole gesture past a limit the scale clamps, the pan keeps working
n/a enableScrollZoom New

Controllers behave the same in one respect worth repeating: whoever creates one disposes it.

Example

example/ is a gallery: a grid of thumbnails that fly into a full screen PhotoViewGallery with a live zoom readout.

cd example && flutter run

License

MIT. The API and the scale and pan behaviour are derived from photo_view, also MIT, by Renan C. Araújo.

Libraries

photo_zoom
A pannable, zoomable image viewer and gallery.