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

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.

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.
Gallery
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.
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.customChildtransforms a widget; it does not arbitrate with gestures inside that widget. A child with its own pan or tap handlers will fight the view. UsedisableGestures: trueand drive the controller yourself.- Rotation (
enableRotation) turns the child aboutbasePosition, 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
enableRotationon 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.
imageProviderresolves once to learn the image's size; an animated GIF plays, but its first frame sets the size. filterQualityapplies toPhotoView.newonly.PhotoView.customChilddraws whatever the child draws.- The gallery does not loop; page 0 is the first page.
tightModefrom photo_view is not carried over. ASizedBoxaround the view is the closest replacement, but it is not the same thing:tightModeshrank the background tochildSize * scaleand kept following it as the zoom changed, where aSizedBoxis 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.