13

I want to enable zoom in and out on double tap of the image, together with scaling in/out on pinch. I saw some tutorials on YouTube where they implemented this feature using GestureDetector like this one but for some reason, it didn't work out for me. In order to implement scaling in/out on pinch, I relied on this answer, and it really works well, but I also want to enable zoom in/out on double tapping the image. Looking up a way to do so on the internet, unfortunately, yielded nothing.

Is there any way to enable zoom in/out with both pinch and double tap using InteractiveViewer?

here is my code:

@override
Widget build(BuildContext context) {
  return Center(
    child: InteractiveViewer(
      boundaryMargin: EdgeInsets.all(80),
      panEnabled: false,
      scaleEnabled: true,
      minScale: 1.0,
      maxScale: 2.2,
      child: Image.network("https://pngimg.com/uploads/muffin/muffin_PNG123.png",
        fit: BoxFit.fitWidth,
      )
    ),
  );
}
AK-23
  • 336
  • 1
  • 2
  • 14
  • yes, you need to use `TransformationController` – pskink Dec 22 '20 at 12:30
  • @pskink If I use the `TransformationController`, how can I know that the user double-tapped the image? Wrapping the image or the `InteractiveViewer` with `GestureDetector` doesn't work. There are no examples of this on the internet. Could you provide an example please? – AK-23 Dec 22 '20 at 13:51
  • 2
    it works just fine: `child: GestureDetector( onDoubleTapDown: (d) => print('onDoubleTapDown ${d.localPosition}'), onDoubleTap: () => print('onDoubleTap'), child: InteractiveViewer( ... ` – pskink Dec 22 '20 at 14:06

3 Answers3

33

You can use a GestureDetector, that gives you the position of the click and with that you can zoom with the TransformationController at the click position:

final _transformationController = TransformationController();
TapDownDetails _doubleTapDetails;

@override
Widget build(BuildContext context) {
  return GestureDetector(
    onDoubleTapDown: (d) => _doubleTapDetails = d,
    onDoubleTap: _handleDoubleTap,
    child: Center(
      child: InteractiveViewer(
        transformationController: _transformationController,
        /* ... */
      ),
    ),
  );
}

void _handleDoubleTap() {
  if (_transformationController.value != Matrix4.identity()) {
    _transformationController.value = Matrix4.identity();
  } else {
    final position = _doubleTapDetails.localPosition;
    // For a 3x zoom
    _transformationController.value = Matrix4.identity()
      ..translate(-position.dx * 2, -position.dy * 2)
      ..scale(3.0);
    // Fox a 2x zoom
    // ..translate(-position.dx, -position.dy)
    // ..scale(2.0);
  }
}
Till Friebe
  • 1,280
  • 15
  • 27
  • 2
    Hi! Firstly, thank you for your answer. Secondly, notice that `transformationController` isn't defined `GestureDetector` so this doesn't work. – AK-23 Jan 11 '21 at 22:56
  • 1
    Thanks you are right, the `transformationController` is the argument of the `InteractiveViewer`. – Till Friebe Jan 12 '21 at 01:16
  • 1
    Thanks a lot!! This works excellently! I hope you don't mind if I ask - is there a way I can control the animation of it? for example, I want to set a specific duration and curve for the zoom in/out. – AK-23 Jan 12 '21 at 11:23
13

To animate the transition on double tap, you have to create an explicit animation on top of Till's code.

class _WidgetState extends State<Widget> with SingleTickerProviderStateMixin {
  .
  .
  .  
  AnimationController _animationController;
  Animation<Matrix4> _animation;
  
  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 400),
    )..addListener(() {
        _transformationController.value = _animation.value;
      });
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
  .
  .
  .
  void _handleDoubleTap() {
    Matrix4 _endMatrix;
    Offset _position = _doubleTapDetails.localPosition;

    if (_transformationController.value != Matrix4.identity()) {
      _endMatrix = Matrix4.identity();
    } else {
      _endMatrix = Matrix4.identity()
        ..translate(-_position.dx * 2, -_position.dy * 2)
        ..scale(3.0);
    }

    _animation = Matrix4Tween(
      begin: _transformationController.value,
      end: _endMatrix,
    ).animate(
      CurveTween(curve: Curves.easeOut).animate(_animationController),
    );
    _animationController.forward(from: 0);
  }
  .
  .
  .
}
Alexander Sidikov Pfeif
  • 2,418
  • 1
  • 20
  • 35
ltk
  • 994
  • 7
  • 9
  • Hi, thank you for your answer! Unfortunately, when I double tap on the image it does not respond to the gestures and does not zoom in or out. – AK-23 Jan 19 '21 at 00:11
  • 1
    Just a reminder, the above code is the pieces you have to add or change to @Till's code. It won't work on its own. Highlighting the changes, 1) include the mixin in the class 2) add _animationController and _animation 3) override the initState and dispose function, include the _transformationController in these function as well 4) replace the _handleDoubleTap function. The _handleDoubleTapDown should remain in the class. Make sure you include the correct _transformationController in your build function, if you simply missed the underscore. – ltk Jan 19 '21 at 11:34
  • 1
    Most likely it is your _transformationController screwing things up. – ltk Jan 19 '21 at 11:47
  • 2
    In flutter-2.0, to fix the UnimplementedError of scale function, you need to fix scale(3) to scale(3.0). – rollrat May 17 '21 at 02:56
4

Here's a full, portable solution with included customizable animation:

class DoubleTappableInteractiveViewer extends StatefulWidget {
  final double scale;
  final Duration scaleDuration;
  final Curve curve;
  final Widget child;
  
  const DoubleTappableInteractiveViewer({
    super.key,
    this.scale = 2,
    this.curve = Curves.fastLinearToSlowEaseIn,
    required this.scaleDuration,
    required this.child,
  });
  
  @override
  State<DoubleTappableInteractiveViewer> createState() => _DoubleTappableInteractiveViewerState();
}

class _DoubleTappableInteractiveViewerState extends State<DoubleTappableInteractiveViewer>
  with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  Animation<Matrix4>? _zoomAnimation;
  late TransformationController _transformationController;
  TapDownDetails? _doubleTapDetails;
  
  @override
  void initState() {
    super.initState();
    _transformationController = TransformationController();
    _animationController = AnimationController(
      vsync: this,
      duration: widget.scaleDuration,
    )..addListener(() {
      _transformationController.value = _zoomAnimation!.value;
    });
  }
  
  @override
  void dispose() {
    _transformationController.dispose();
    _animationController.dispose();
    super.dispose();
  }
  
  void _handleDoubleTapDown(TapDownDetails details) {
  _doubleTapDetails = details;
}

  void _handleDoubleTap() {
    final newValue = 
      _transformationController.value.isIdentity() ?
        _applyZoom() : _revertZoom();
      
    _zoomAnimation = Matrix4Tween(
      begin: _transformationController.value,
      end: newValue,
    ).animate(
      CurveTween(curve: widget.curve)
        .animate(_animationController)
    );
    _animationController.forward(from: 0);
  }
  
  Matrix4 _applyZoom() {
    final tapPosition = _doubleTapDetails!.localPosition;
    final translationCorrection = widget.scale - 1;
    final zoomed = Matrix4.identity()
      ..translate(
        -tapPosition.dx * translationCorrection,
        -tapPosition.dy * translationCorrection,
      )
      ..scale(widget.scale);
    return zoomed;
  }
  
  Matrix4 _revertZoom() => Matrix4.identity();

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onDoubleTapDown: _handleDoubleTapDown,
      onDoubleTap: _handleDoubleTap,
      child: InteractiveViewer(
        transformationController: _transformationController,
        child: widget.child,
      ),
    );
  }
}

Example usage:

DoubleTappableInteractiveViewer(
  scaleDuration: const Duration(milliseconds: 600),
  child: Image.network(imageUrl),
),

Play around with it on dartpad.

Marcin Wróblewski
  • 811
  • 1
  • 10
  • 25