How to zoom image inside ListView in flutter
Correct me if I am wrong but from the stacktrace I think your problem is that you are trying to add a child with unknown size within a parent also with unknown size and flutter fails to compute the layout. To solve this problem you need to create a widget with a fixed size (probably calculated from the initial state of its child for example, Image
in your case) like ClipRect
.
Although this solves the error; It leaves you with a glitchy behavior because in your case we are facing with a Gesture disambiguation as mentioned here, meaning that you have multiple gesture detectors trying to recognize specific gestures at the same time. To be exact, one that handles scale
which is a super set of pan
that is used for zooming and panning your image and one that handles drag
which is used for scrolling in your ListView
.To overcome this issue, I think you need to implement a widget that controls the input gestures and manually decides whether to declare victory or declare defeat in gesture arena.
I have attached a few lines of code I found here and there together in order to implement the desired behavior, you will need flutter_advanced_networkimage library for this specific example but you can replace AdvancedNetworkImage with other widgets:
ZoomableCachedNetworkImage:
class ZoomableCachedNetworkImage extends StatelessWidget { String url; ImageProvider imageProvider; ZoomableCachedNetworkImage(this.url) { imageProvider = _loadImageProvider(); } @override Widget build(BuildContext context) { return new ZoomablePhotoViewer( url: url, ); } ImageProvider _loadImageProvider() { return new AdvancedNetworkImage(this.url); }}class ZoomablePhotoViewer extends StatefulWidget { const ZoomablePhotoViewer({Key key, this.url}) : super(key: key); final String url; @override _ZoomablePhotoViewerState createState() => new _ZoomablePhotoViewerState();}class _ZoomablePhotoViewerState extends State<ZoomablePhotoViewer> with SingleTickerProviderStateMixin { AnimationController _controller; Animation<Offset> _flingAnimation; Offset _offset = Offset.zero; double _scale = 1.0; Offset _normalizedOffset; double _previousScale; HitTestBehavior behavior; @override void initState() { super.initState(); _controller = new AnimationController(vsync: this) ..addListener(_handleFlingAnimation); } @override void dispose() { _controller.dispose(); super.dispose(); } // The maximum offset value is 0,0. If the size of this renderer's box is w,h // then the minimum offset value is w - _scale * w, h - _scale * h. Offset _clampOffset(Offset offset) { final Size size = context.size; final Offset minOffset = new Offset(size.width, size.height) * (1.0 - _scale); return new Offset( offset.dx.clamp(minOffset.dx, 0.0), offset.dy.clamp(minOffset.dy, 0.0)); } void _handleFlingAnimation() { setState(() { _offset = _flingAnimation.value; }); } void _handleOnScaleStart(ScaleStartDetails details) { setState(() { _previousScale = _scale; _normalizedOffset = (details.focalPoint - _offset) / _scale; // The fling animation stops if an input gesture starts. _controller.stop(); }); } void _handleOnScaleUpdate(ScaleUpdateDetails details) { setState(() { _scale = (_previousScale * details.scale).clamp(1.0, 4.0); // Ensure that image location under the focal point stays in the same place despite scaling. _offset = _clampOffset(details.focalPoint - _normalizedOffset * _scale); }); } void _handleOnScaleEnd(ScaleEndDetails details) { const double _kMinFlingVelocity = 800.0; final double magnitude = details.velocity.pixelsPerSecond.distance; print('magnitude: ' + magnitude.toString()); if (magnitude < _kMinFlingVelocity) return; final Offset direction = details.velocity.pixelsPerSecond / magnitude; final double distance = (Offset.zero & context.size).shortestSide; _flingAnimation = new Tween<Offset>( begin: _offset, end: _clampOffset(_offset + direction * distance)) .animate(_controller); _controller ..value = 0.0 ..fling(velocity: magnitude / 1000.0); } @override Widget build(BuildContext context) { return RawGestureDetector( gestures: { AllowMultipleScaleRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleScaleRecognizer>( () => AllowMultipleScaleRecognizer(), //constructor (AllowMultipleScaleRecognizer instance) { //initializer instance.onStart = (details) => this._handleOnScaleStart(details); instance.onEnd = (details) => this._handleOnScaleEnd(details); instance.onUpdate = (details) => this._handleOnScaleUpdate(details); }, ), AllowMultipleHorizontalDragRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleHorizontalDragRecognizer>( () => AllowMultipleHorizontalDragRecognizer(), (AllowMultipleHorizontalDragRecognizer instance) { instance.onStart = (details) => this._handleHorizontalDragAcceptPolicy(instance); instance.onUpdate = (details) => this._handleHorizontalDragAcceptPolicy(instance); }, ), AllowMultipleVerticalDragRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleVerticalDragRecognizer>( () => AllowMultipleVerticalDragRecognizer(), (AllowMultipleVerticalDragRecognizer instance) { instance.onStart = (details) => this._handleVerticalDragAcceptPolicy(instance); instance.onUpdate = (details) => this._handleVerticalDragAcceptPolicy(instance); }, ), }, //Creates the nested container within the first. behavior: HitTestBehavior.opaque, child: new ClipRect( child: new Transform( transform: new Matrix4.identity() ..translate(_offset.dx, _offset.dy) ..scale(_scale), child: Image( image: new AdvancedNetworkImage(widget.url), fit: BoxFit.cover, ), ), ), ); } void _handleHorizontalDragAcceptPolicy(AllowMultipleHorizontalDragRecognizer instance) { _scale > 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false; } void _handleVerticalDragAcceptPolicy(AllowMultipleVerticalDragRecognizer instance) { _scale > 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false; }}
AllowMultipleVerticalDragRecognizer:
import 'package:flutter/gestures.dart';class AllowMultipleVerticalDragRecognizer extends VerticalDragGestureRecognizer { bool alwaysAccept; @override void rejectGesture(int pointer) { acceptGesture(pointer); } @override void resolve(GestureDisposition disposition) { if(alwaysAccept) { super.resolve(GestureDisposition.accepted); } else { super.resolve(GestureDisposition.rejected); } }}
AllowMultipleHorizontalDragRecognizer:
import 'package:flutter/gestures.dart';class AllowMultipleHorizontalDragRecognizer extends HorizontalDragGestureRecognizer { bool alwaysAccept; @override void rejectGesture(int pointer) { acceptGesture(pointer); } @override void resolve(GestureDisposition disposition) { if(alwaysAccept) { super.resolve(GestureDisposition.accepted); } else { super.resolve(GestureDisposition.rejected); } }}
AllowMultipleScaleRecognizer
import 'package:flutter/gestures.dart';class AllowMultipleScaleRecognizer extends ScaleGestureRecognizer { @override void rejectGesture(int pointer) { acceptGesture(pointer); }}
Then use it like this:
@overrideWidget build(BuildContext context) { return new MaterialApp( title: 'Zoomable Image In ListView', debugShowCheckedModeBanner: false, home: new Scaffold( body: new Column( children: <Widget>[ new Expanded( child: new ListView.builder( scrollDirection: Axis.vertical, itemBuilder: (context, index) => ZoomableCachedNetworkImage(_urlList[index]), ), ), ], ), ), );}
I hope this helps.
Update:
As requested in the comments, for supporting double-tap you should make the following changes:
AllowMultipleDoubleTapRecognizer:
import 'package:flutter/gestures.dart';class AllowMultipleDoubleTapRecognizer extends DoubleTapGestureRecognizer { @override void rejectGesture(int pointer) { acceptGesture(pointer); }}
AllowMultipleTapRecognizer
import 'package:flutter/gestures.dart';class AllowMultipleTapRecognizer extends TapGestureRecognizer { @override void rejectGesture(int pointer) { acceptGesture(pointer); }}
ZoomableCachedNetworkImage
class ZoomableCachedNetworkImage extends StatelessWidget { final String url; final bool closeOnZoomOut; final Offset focalPoint; final double initialScale; final bool animateToInitScale; ZoomableCachedNetworkImage({ this.url, this.closeOnZoomOut = false, this.focalPoint, this.initialScale, this.animateToInitScale, }); Widget loadImage() { return ZoomablePhotoViewer( url: url, closeOnZoomOut: closeOnZoomOut, focalPoint: focalPoint, initialScale: initialScale, animateToInitScale: animateToInitScale, ); }}class ZoomablePhotoViewer extends StatefulWidget { const ZoomablePhotoViewer({ Key key, this.url, this.closeOnZoomOut, this.focalPoint, this.initialScale, this.animateToInitScale, }) : super(key: key); final String url; final bool closeOnZoomOut; final Offset focalPoint; final double initialScale; final bool animateToInitScale; @override _ZoomablePhotoViewerState createState() => _ZoomablePhotoViewerState(url, closeOnZoomOut: closeOnZoomOut, focalPoint: focalPoint, animateToInitScale: animateToInitScale, initialScale: initialScale);}class _ZoomablePhotoViewerState extends State<ZoomablePhotoViewer> with TickerProviderStateMixin { static const double _minScale = 0.99; static const double _maxScale = 4.0; AnimationController _flingAnimationController; Animation<Offset> _flingAnimation; AnimationController _zoomAnimationController; Animation<double> _zoomAnimation; Offset _offset; double _scale; Offset _normalizedOffset; double _previousScale; AllowMultipleHorizontalDragRecognizer _allowMultipleHorizontalDragRecognizer; AllowMultipleVerticalDragRecognizer _allowMultipleVerticalDragRecognizer; Offset _tapDownGlobalPosition; String _url; bool _closeOnZoomOut; Offset _focalPoint; bool _animateToInitScale; double _initialScale; _ZoomablePhotoViewerState( String url, { bool closeOnZoomOut = false, Offset focalPoint = Offset.zero, double initialScale = 1.0, bool animateToInitScale = false, }) { this._url = url; this._closeOnZoomOut = closeOnZoomOut; this._offset = Offset.zero; this._scale = 1.0; this._initialScale = initialScale; this._focalPoint = focalPoint; this._animateToInitScale = animateToInitScale; } @override void initState() { super.initState(); if (_animateToInitScale) { WidgetsBinding.instance.addPostFrameCallback( (_) => _zoom(_focalPoint, _initialScale, context)); } _flingAnimationController = AnimationController(vsync: this) ..addListener(_handleFlingAnimation); _zoomAnimationController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this); } @override void dispose() { _flingAnimationController.dispose(); _zoomAnimationController.dispose(); super.dispose(); } // The maximum offset value is 0,0. If the size of this renderer's box is w,h // then the minimum offset value is w - _scale * w, h - _scale * h. Offset _clampOffset(Offset offset) { final Size size = context.size; final Offset minOffset = Offset(size.width, size.height) * (1.0 - _scale); return Offset( offset.dx.clamp(minOffset.dx, 0.0), offset.dy.clamp(minOffset.dy, 0.0)); } void _handleFlingAnimation() { setState(() { _offset = _flingAnimation.value; }); } void _handleOnScaleStart(ScaleStartDetails details) { setState(() { _previousScale = _scale; _normalizedOffset = (details.focalPoint - _offset) / _scale; // The fling animation stops if an input gesture starts. _flingAnimationController.stop(); }); } void _handleOnScaleUpdate(ScaleUpdateDetails details) { if (_scale < 1.0 && _closeOnZoomOut) { _zoom(Offset.zero, 1.0, context); Navigator.pop(context); return; } setState(() { _scale = (_previousScale * details.scale).clamp(_minScale, _maxScale); // Ensure that image location under the focal point stays in the same place despite scaling. _offset = _clampOffset(details.focalPoint - _normalizedOffset * _scale); }); } void _handleOnScaleEnd(ScaleEndDetails details) { const double _kMinFlingVelocity = 2000.0; final double magnitude = details.velocity.pixelsPerSecond.distance;// print('magnitude: ' + magnitude.toString()); if (magnitude < _kMinFlingVelocity) return; final Offset direction = details.velocity.pixelsPerSecond / magnitude; final double distance = (Offset.zero & context.size).shortestSide; _flingAnimation = Tween<Offset>( begin: _offset, end: _clampOffset(_offset + direction * distance)) .animate(_flingAnimationController); _flingAnimationController ..value = 0.0 ..fling(velocity: magnitude / 2000.0); } @override Widget build(BuildContext context) { return RawGestureDetector( gestures: { AllowMultipleScaleRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleScaleRecognizer>( () => AllowMultipleScaleRecognizer(), //constructor (AllowMultipleScaleRecognizer instance) { //initializer instance.onStart = (details) => this._handleOnScaleStart(details); instance.onEnd = (details) => this._handleOnScaleEnd(details); instance.onUpdate = (details) => this._handleOnScaleUpdate(details); }, ), AllowMultipleHorizontalDragRecognizer: GestureRecognizerFactoryWithHandlers< AllowMultipleHorizontalDragRecognizer>( () => AllowMultipleHorizontalDragRecognizer(), (AllowMultipleHorizontalDragRecognizer instance) { _allowMultipleHorizontalDragRecognizer = instance; instance.onStart = (details) => this._handleHorizontalDragAcceptPolicy(instance); instance.onUpdate = (details) => this._handleHorizontalDragAcceptPolicy(instance); }, ), AllowMultipleVerticalDragRecognizer: GestureRecognizerFactoryWithHandlers< AllowMultipleVerticalDragRecognizer>( () => AllowMultipleVerticalDragRecognizer(), (AllowMultipleVerticalDragRecognizer instance) { _allowMultipleVerticalDragRecognizer = instance; instance.onStart = (details) => this._handleVerticalDragAcceptPolicy(instance); instance.onUpdate = (details) => this._handleVerticalDragAcceptPolicy(instance); }, ), AllowMultipleDoubleTapRecognizer: GestureRecognizerFactoryWithHandlers< AllowMultipleDoubleTapRecognizer>( () => AllowMultipleDoubleTapRecognizer(), (AllowMultipleDoubleTapRecognizer instance) { instance.onDoubleTap = () => this._handleDoubleTap(); }, ), AllowMultipleTapRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleTapRecognizer>( () => AllowMultipleTapRecognizer(), (AllowMultipleTapRecognizer instance) { instance.onTapDown = (details) => this._handleTapDown(details.globalPosition); }, ), }, //Creates the nested container within the first. behavior: HitTestBehavior.opaque, child: Transform( transform: Matrix4.identity() ..translate(_offset.dx, _offset.dy) ..scale(_scale), child: _buildTransitionToImage(), ), ); } Widget _buildTransitionToImage() { return CachedNetworkImage( imageUrl: this._url, fit: BoxFit.contain, fadeOutDuration: Duration(milliseconds: 0), fadeInDuration: Duration(milliseconds: 0), ); } void _handleHorizontalDragAcceptPolicy( AllowMultipleHorizontalDragRecognizer instance) { _scale != 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false; } void _handleVerticalDragAcceptPolicy( AllowMultipleVerticalDragRecognizer instance) { _scale != 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false; } void _handleDoubleTap() { setState(() { if (_scale >= 1.0 && _scale <= 1.2) { _previousScale = _scale; _normalizedOffset = (_tapDownGlobalPosition - _offset) / _scale; _scale = 2.75; _offset = _clampOffset( context.size.center(Offset.zero) - _normalizedOffset * _scale); _allowMultipleVerticalDragRecognizer.alwaysAccept = true; _allowMultipleHorizontalDragRecognizer.alwaysAccept = true; } else { if (_closeOnZoomOut) { _zoom(Offset.zero, 1.0, context); _zoomAnimation.addListener(() { if (_zoomAnimation.isCompleted) { Navigator.pop(context); } }); return; } _scale = 1.0; _offset = _clampOffset(Offset.zero - _normalizedOffset * _scale); _allowMultipleVerticalDragRecognizer.alwaysAccept = false; _allowMultipleHorizontalDragRecognizer.alwaysAccept = false; } }); } _handleTapDown(Offset globalPosition) { final RenderBox referenceBox = context.findRenderObject(); _tapDownGlobalPosition = referenceBox.globalToLocal(globalPosition); } _zoom(Offset focalPoint, double scale, BuildContext context) { final RenderBox referenceBox = context.findRenderObject(); focalPoint = referenceBox.globalToLocal(focalPoint); _previousScale = _scale; _normalizedOffset = (focalPoint - _offset) / _scale; _allowMultipleVerticalDragRecognizer.alwaysAccept = true; _allowMultipleHorizontalDragRecognizer.alwaysAccept = true; _zoomAnimation = Tween<double>(begin: _scale, end: scale) .animate(_zoomAnimationController); _zoomAnimation.addListener(() { setState(() { _scale = _zoomAnimation.value; _offset = scale < _scale ? _clampOffset(Offset.zero - _normalizedOffset * _scale) : _clampOffset( context.size.center(Offset.zero) - _normalizedOffset * _scale); }); }); _zoomAnimationController.forward(from: 0.0); }}abstract class ScaleDownHandler { void handleScaleDown();}
In your first example you need to define the function _buildVerticalChild
as such :
Widget _buildVerticalChild(BuildContext context, int index) {
Not specifying Widget
will make the compiler think _buildVerticalChild
can return anything.
And in both situations, you need to specify an itemCount
new ListView.builder( itemCount: _urlList.length)
I had the issue, but it's getting fixed once you wrap your ZoomableWidget inside a container. So, basically the height wasn't bounded. I am new to flutter, so please check once.
children: <Widget>[ Container( height: 450.0, child: ZoomableWidget( minScale: 0.3, maxScale: 2.0, // default factor is 1.0, use 0.0 to disable boundary panLimit: 0.8, child: TransitionToImage( AdvancedNetworkImage(imageUrl, timeoutDuration: Duration(minutes: 2), useDiskCache: true), // This is the default placeholder widget at loading status, // you can write your own widget with CustomPainter. placeholder: CircularProgressIndicator(), // This is default duration duration: Duration(milliseconds: 300), height: 350.0, width: 400.0, ), ), ),// ), new Padding( padding: const EdgeInsets.all(8.0), child: new Center( child: new Text( desc, style: new TextStyle(fontSize: 16.0), textAlign: TextAlign.start, ), ), ), ],