How to zoom image inside ListView in flutter
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
)
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:
@override
Widget 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();
}