photo_zoom 0.2.0
photo_zoom: ^0.2.0 copied to clipboard
Pan, zoom, rotate and swipe a photo gallery. Double tap and pinch zoom where you touch; mouse wheel on desktop. A type-safe, maintained drop-in for photo_view.
photo_zoom #

Pan, zoom and rotate images, and swipe through a gallery of them.
import 'package:photo_zoom/photo_zoom.dart';
PhotoView(imageProvider: const AssetImage('assets/photo.jpg'))
Drag to pan, pinch to zoom, double tap to cycle through fit, fill and actual size. On desktop and web the mouse wheel zooms at the pointer and a two-finger trackpad scroll pans.
The API follows the photo_view package by Renan C. Araújo, which is MIT licensed. Class names and most parameters are the same, so moving across is mostly a change of import; the differences are listed below.
Zoom lands where you put it #
Zooming, by any means, keeps the point you started from under your finger or pointer:
PhotoView(
imageProvider: const NetworkImage('https://example.com/map.png'),
minScale: PhotoViewComputedScale.contained * 0.8,
maxScale: PhotoViewComputedScale.covered * 3,
)
Double tap a corner and that corner zooms, rather than the middle of the image sliding into view. The same anchoring applies to a pinch and to a wheel scroll.
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, so 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, so it 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, so 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.
