I'm writing a Flutter app and I'd like to know how to use/implement a zoomable image inside a ListView. I have used below plugins in my app.
Neither of them worked on my project and threw different exceptions. example code to reproduce the error:
flutter_advanced_networkimage:
import 'package:flutter/material.dart';
import 'package:flutter_advanced_networkimage/flutter_advanced_networkimage.dart';
import 'package:flutter_advanced_networkimage/transition_to_image.dart';
import 'package:flutter_advanced_networkimage/zoomable_widget.dart';
void main() {
runApp(new ZoomableImageInListView());
}
class ZoomableImageInListView extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return new _ZoomableImageInListViewState();
}
}
final List<String> _urlList = [
'https://www.w3schools.com/htmL/pic_trulli.jpg',
'https://www.w3schools.com/htmL/img_girl.jpg',
'https://www.w3schools.com/htmL/img_chania.jpg',
];
class _ZoomableImageInListViewState extends State<ZoomableImageInListView> {
@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: _buildVerticalChild,
),
),
],
),
),
);
}
_buildVerticalChild(BuildContext context, int index) {
index++;
if (index > _urlList.length) return null;
TransitionToImage imageWidget = TransitionToImage(
AdvancedNetworkImage(
_urlList[index],
useDiskCache: true,
),
useReload: true,
reloadWidget: Icon(Icons.replay),
);
return new ZoomableWidget(
minScale: 1.0,
maxScale: 5.0,
child: imageWidget,
tapCallback: imageWidget.reloadImage,
);
}
}
Threw this exception:
I/flutter (13594): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter (13594): The following assertion was thrown building ZoomableImageInListView(dirty, state:
I/flutter (13594): _ZoomableImageInListViewState#39144):
I/flutter (13594): type '(BuildContext, int) => dynamic' is not a subtype of type '(BuildContext, int) => Widget'
I/flutter (13594):
I/flutter (13594): Either the assertion indicates an error in the framework itself, or we should provide substantially
I/flutter (13594): more information in this error message to help you determine and fix the underlying cause.
I/flutter (13594): In either case, please report this assertion by filing a bug on GitHub:
I/flutter (13594): https://github.com/flutter/flutter/issues/new
.
.
.
I/flutter (13594): ════════════════════════════════════════════════════════════════════════════════════════════════════
zoomable_image:
import 'package:flutter/material.dart';
import 'package:zoomable_image/zoomable_image.dart';
void main() {
runApp(new ZoomableImageInListView());
}
class ZoomableImageInListView extends StatefulWidget {
@override
_ZoomableImageInListViewState createState() =>
new _ZoomableImageInListViewState();
}
final List<String> _urlList = [
'https://www.w3schools.com/htmL/pic_trulli.jpg',
'https://www.w3schools.com/htmL/img_girl.jpg',
'https://www.w3schools.com/htmL/img_chania.jpg',
];
class _ZoomableImageInListViewState extends State<ZoomableImageInListView> {
@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) => new ZoomableImage(
new NetworkImage(_urlList[index], scale: 1.0)),
),
),
],
),
),
);
}
}
Threw this exception:
I/flutter (13594): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter (13594): The following assertion was thrown building ZoomableImage(dirty, state: _ZoomableImageState#d60f4):
I/flutter (13594): A build function returned null.
I/flutter (13594): The offending widget is: ZoomableImage
I/flutter (13594): Build functions must never return null. To return an empty space that causes the building widget to
I/flutter (13594): fill available room, return "new Container()". To return an empty space that takes as little room as
I/flutter (13594): possible, return "new Container(width: 0.0, height: 0.0)".
.
.
.
I/flutter (13594): ════════════════════════════════════════════════════════════════════════════════════════════════════
I checked both of the plugins outside of ListView and they worked great. Is there any problem with my implementations? Do these plugins support ListView? If the answer is yes, please let me know how?
A widget based on Flutter's new Interactive Viewer that makes picture pinch zoom, and return to its initial size and position when released. This package offers Instagram based pinch zooming that feels more responsive than other similar packages.
Expandable ListView is a type of list view that is used to show multiple types of data based on category and subcategory. Expandable list view is used to expand or collapse the view in list items In a flutter app. We can easily make our own Expandable ListView using the ExpansionTile widget.
You can wrap your ListView widget inside the Expanded widget and this will allow the ListView to take all the available as long as the Column allows.
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.
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,
),
),
),
],
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With