Skip to content

add minTaps argument to BaseTapAndDragGestureRecognizer and TapAndPanGestureRecognizer #164922

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

yakagami
Copy link
Contributor

@yakagami yakagami commented Mar 10, 2025

This adds a minTaps parameter to BaseTapAndDragGestureRecognizer and TapAndPanGestureRecognizer, allowing ScaleGestureRecognizer to resolve on single taps and TapAndPanGestureRecognizer to only win when there is a double tap first by setting minTaps to 2. See the related issue "Add double tap and pan/double tap and drag gesture (#164889)"

Closes #164889

Note: we should probably check that minTaps <= maxConsecutiveTap. Not sure where to check for that. I guess we would add setters for maxConsecutiveTap and minTaps and check there?

Before
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

/// Flutter code sample for [TapAndPanGestureRecognizer].

void main() {
  runApp(const TapAndDragToZoomApp());
}

class TapAndDragToZoomApp extends StatelessWidget {
  const TapAndDragToZoomApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: Center(child: TapAndDragToZoomWidget(child: MyBoxWidget()))),
    );
  }
}

class MyBoxWidget extends StatelessWidget {
  const MyBoxWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(color: Colors.blueAccent, height: 100.0, width: 100.0);
  }
}

// This widget will scale its child up when it detects a drag up, after a
// double tap/click. It will scale the widget down when it detects a drag down,
// after a double tap. Dragging down and then up after a double tap/click will
// zoom the child in/out. The scale of the child will be reset when the drag ends.
class TapAndDragToZoomWidget extends StatefulWidget {
  const TapAndDragToZoomWidget({super.key, required this.child});

  final Widget child;

  @override
  State<TapAndDragToZoomWidget> createState() => _TapAndDragToZoomWidgetState();
}

class _TapAndDragToZoomWidgetState extends State<TapAndDragToZoomWidget> {
  final double scaleMultiplier = -0.0001;
  double _currentScale = 1.0;
  Offset? _previousDragPosition;

  static double _keepScaleWithinBounds(double scale) {
    const double minScale = 0.1;
    const double maxScale = 30;
    if (scale <= 0) {
      return minScale;
    }
    if (scale >= 30) {
      return maxScale;
    }
    return scale;
  }

  void _zoomLogic(Offset currentDragPosition) {
    final double dx = (_previousDragPosition!.dx - currentDragPosition.dx).abs();
    final double dy = (_previousDragPosition!.dy - currentDragPosition.dy).abs();

    if (dx > dy) {
      // Ignore horizontal drags.
      _previousDragPosition = currentDragPosition;
      return;
    }

    if (currentDragPosition.dy < _previousDragPosition!.dy) {
      // Zoom out on drag up.
      setState(() {
        _currentScale += currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    } else {
      // Zoom in on drag down.
      setState(() {
        _currentScale -= currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    }
    _previousDragPosition = currentDragPosition;
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: <Type, GestureRecognizerFactory>{
        TapAndPanGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
              () => TapAndPanGestureRecognizer( ),
              (TapAndPanGestureRecognizer instance) {
                instance
                  ..onTapDown = (TapDragDownDetails details) {
                    _previousDragPosition = details.globalPosition;
                  }
                  ..onDragStart = (TapDragStartDetails details) {
                      print("Drag Start");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragUpdate = (TapDragUpdateDetails details) {
                      print("Drag Update");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragEnd = (TapDragEndDetails details) {      
                    print("Drag End");              
                    setState(() {
                      _currentScale = 1.0;
                    });
                    _previousDragPosition = null;
                  };
              },
            ),
            //ScaleGestureRecognizer never wins the arena
            ScaleGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
              () => ScaleGestureRecognizer(),
              (ScaleGestureRecognizer instance) {
                instance
                  ..onStart = (ScaleStartDetails details) {
                    print('Scale Start');
                  }
                  ..onUpdate = (ScaleUpdateDetails details) {
                    print('Scale Update');
                  }
                  ..onEnd = (ScaleEndDetails details) {
                    print('Scale End');
                  };
              },
            ),
      },
      child: Transform.scale(scale: _currentScale, child: widget.child),
    );
  }
}
After
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

/// Flutter code sample for [TapAndPanGestureRecognizer].

void main() {
  runApp(const TapAndDragToZoomApp());
}

class TapAndDragToZoomApp extends StatelessWidget {
  const TapAndDragToZoomApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: Center(child: TapAndDragToZoomWidget(child: MyBoxWidget()))),
    );
  }
}

class MyBoxWidget extends StatelessWidget {
  const MyBoxWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(color: Colors.blueAccent, height: 100.0, width: 100.0);
  }
}

// This widget will scale its child up when it detects a drag up, after a
// double tap/click. It will scale the widget down when it detects a drag down,
// after a double tap. Dragging down and then up after a double tap/click will
// zoom the child in/out. The scale of the child will be reset when the drag ends.
class TapAndDragToZoomWidget extends StatefulWidget {
  const TapAndDragToZoomWidget({super.key, required this.child});

  final Widget child;

  @override
  State<TapAndDragToZoomWidget> createState() => _TapAndDragToZoomWidgetState();
}

class _TapAndDragToZoomWidgetState extends State<TapAndDragToZoomWidget> {
  final double scaleMultiplier = -0.0001;
  double _currentScale = 1.0;
  Offset? _previousDragPosition;

  static double _keepScaleWithinBounds(double scale) {
    const double minScale = 0.1;
    const double maxScale = 30;
    if (scale <= 0) {
      return minScale;
    }
    if (scale >= 30) {
      return maxScale;
    }
    return scale;
  }

  void _zoomLogic(Offset currentDragPosition) {
    final double dx = (_previousDragPosition!.dx - currentDragPosition.dx).abs();
    final double dy = (_previousDragPosition!.dy - currentDragPosition.dy).abs();

    if (dx > dy) {
      // Ignore horizontal drags.
      _previousDragPosition = currentDragPosition;
      return;
    }

    if (currentDragPosition.dy < _previousDragPosition!.dy) {
      // Zoom out on drag up.
      setState(() {
        _currentScale += currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    } else {
      // Zoom in on drag down.
      setState(() {
        _currentScale -= currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    }
    _previousDragPosition = currentDragPosition;
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: <Type, GestureRecognizerFactory>{
        TapAndPanGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
              () => TapAndPanGestureRecognizer(
                minTaps: 2,    //<----- new argument
              ),
              (TapAndPanGestureRecognizer instance) {
                instance
                  ..onTapDown = (TapDragDownDetails details) {
                    _previousDragPosition = details.globalPosition;
                  }
                  ..onDragStart = (TapDragStartDetails details) {
                      print("Drag Start");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragUpdate = (TapDragUpdateDetails details) {
                      print("Drag Update");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragEnd = (TapDragEndDetails details) {      
                    print("Drag End");              
                    setState(() {
                      _currentScale = 1.0;
                    });
                    _previousDragPosition = null;
                  };
              },
            ),
            //wins the arena if there is only one tap followed by a pan
            ScaleGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
              () => ScaleGestureRecognizer(),
              (ScaleGestureRecognizer instance) {
                instance
                  ..onStart = (ScaleStartDetails details) {
                    print('Scale Start');
                  }
                  ..onUpdate = (ScaleUpdateDetails details) {
                    print('Scale Update');
                  }
                  ..onEnd = (ScaleEndDetails details) {
                    print('Scale End');
                  };
              },
            ),
      },
      child: Transform.scale(scale: _currentScale, child: widget.child),
    );
  }
}

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: gestures flutter/packages/flutter/gestures repository. labels Mar 10, 2025
@yakagami
Copy link
Contributor Author

yakagami commented Mar 11, 2025

After some testing, I see that you can't combine TapAndPanGestureRecognizer with DoubleTapGestureRecognizer. In fact, nothing is printed from either TapAndPanGestureRecognizer or DoubleTapGestureRecognizer. (With and without this PR.)

Full example
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

/// Flutter code sample for [TapAndPanGestureRecognizer].

void main() {
  runApp(const TapAndDragToZoomApp());
}

class TapAndDragToZoomApp extends StatelessWidget {
  const TapAndDragToZoomApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: Center(child: TapAndDragToZoomWidget(child: MyBoxWidget()))),
    );
  }
}

class MyBoxWidget extends StatelessWidget {
  const MyBoxWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(color: Colors.blueAccent, height: 100.0, width: 100.0);
  }
}

// This widget will scale its child up when it detects a drag up, after a
// double tap/click. It will scale the widget down when it detects a drag down,
// after a double tap. Dragging down and then up after a double tap/click will
// zoom the child in/out. The scale of the child will be reset when the drag ends.
class TapAndDragToZoomWidget extends StatefulWidget {
  const TapAndDragToZoomWidget({super.key, required this.child});

  final Widget child;

  @override
  State<TapAndDragToZoomWidget> createState() => _TapAndDragToZoomWidgetState();
}

class _TapAndDragToZoomWidgetState extends State<TapAndDragToZoomWidget> {
  final double scaleMultiplier = -0.0001;
  double _currentScale = 1.0;
  Offset? _previousDragPosition;

  static double _keepScaleWithinBounds(double scale) {
    const double minScale = 0.1;
    const double maxScale = 30;
    if (scale <= 0) {
      return minScale;
    }
    if (scale >= 30) {
      return maxScale;
    }
    return scale;
  }

  void _zoomLogic(Offset currentDragPosition) {
    final double dx = (_previousDragPosition!.dx - currentDragPosition.dx).abs();
    final double dy = (_previousDragPosition!.dy - currentDragPosition.dy).abs();

    if (dx > dy) {
      // Ignore horizontal drags.
      _previousDragPosition = currentDragPosition;
      return;
    }

    if (currentDragPosition.dy < _previousDragPosition!.dy) {
      // Zoom out on drag up.
      setState(() {
        _currentScale += currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    } else {
      // Zoom in on drag down.
      setState(() {
        _currentScale -= currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    }
    _previousDragPosition = currentDragPosition;
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: <Type, GestureRecognizerFactory>{
        TapAndPanGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
              () => TapAndPanGestureRecognizer(
                //minTaps: 2,
              ),
              (TapAndPanGestureRecognizer instance) {
                instance
                  ..onTapDown = (TapDragDownDetails details) {
                    _previousDragPosition = details.globalPosition;
                  }
                  ..onTapUp = (TapDragUpDetails details) {
                    //print("Tap Up");
                  }
                  ..onDragStart = (TapDragStartDetails details) {
                      print("Drag Start");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragUpdate = (TapDragUpdateDetails details) {
                      print("Drag Update");
                      _zoomLogic(details.globalPosition);
                  }
                  ..onDragEnd = (TapDragEndDetails details) {      
                    print("Drag End");              
                    setState(() {
                      _currentScale = 1.0;
                    });
                    _previousDragPosition = null;
                  };
              },
            ),
            DoubleTapGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
              () => DoubleTapGestureRecognizer(),
              (DoubleTapGestureRecognizer instance) {
                instance
                  ..onDoubleTap = () {
                    print('Double Tap');
                  };
              },
            ),
            
      },
      child: Transform.scale(scale: _currentScale, child: widget.child),
    );
  }
}

I wonder if it would make sense to add a clean way to recognize double or n taps within TapAndPanGestureRecognizer. But I also realize it could be a bad idea to add too much functionality to one gesture. You can almost get this already with TapAndPanGestureRecognizer but consider this case:

User taps three times then drags:

You could interpret this in many ways:

  • Perform double tap immediately and then do nothing, allowing a potential drag gesture to take over
  • Perform a double tap immediately and then on the third tap+drag, perform the pan/scale action
  • Do not perform the double tap, only scale after third tap + drag

Google earth does the first and Google photos does the second. With the current implementation, there is no way to let the third tap fall through back to the gesture arena as the beginning of a new gesture (such as a drag).

Besides this, the current method of detecting a double tap with TapAndPanGestureRecognizer is somewhat cumbersome as you have to do this:

Click
int _tapUpCount = 0;
..onTapUp = (TapDragUpDetails details) {
  _tapUpCount++;
  if(_tapUpCount == 2){
    performDoubleTap();
  }
}
..onDragStart = (TapDragStartDetails details) {
   //don't scale if a double tap occurred.
    if(_tapUpCount <2){
      _zoomLogic(details.globalPosition);
    }
}
..onDragUpdate = (TapDragUpdateDetails details) {
    //don't scale if a double tap occurred.
    if(_tapUpCount <2){
      _zoomLogic(details.globalPosition);
    }
}
..onDragEnd = (TapDragEndDetails details) {      
   //don't scale if a double tap occurred.
    if(_tapUpCount <2){
     //...
    }
};

It would be nice if there was a way to say "After n taps up, complete the gesture and fire the n-tapsUp callback, something like:

(TapAndPanGestureRecognizer instance) {
    instance
     ..nTaps = 2
     ..onNTaps(){
          performDoubleTap();
     }

After onNTaps is called, the gesture would end immediately (or could be set to do so via an onNTapsBehavior enum.

All of this doesn't need to be part of the PR of course, I only mention this because in light of these issues, maybe my own solution is not good either and should be part of some entirely new gesture recognizer specifically meant for double tap, double-tap zoom and scale all in one. To be clean I do think the current PR is useful and would allow for replicating the Google photos behavior and even the Google earth behavior if you are able to perform a drag from the TapDragUpdate, eg. via a ScrollPosition. I also don't think the Google Earth behavior is even good, although I'm not sure what the correct behavior should be.

@Renzo-Olivares Renzo-Olivares self-requested a review March 11, 2025 19:45
@yakagami
Copy link
Contributor Author

yakagami commented Apr 21, 2025

@Renzo-Olivares have you had a chance to look at the PR? I'd like to know if you think this is the right approach.

@justinmc justinmc added a: text input Entering text in a text field or keyboard related problems fyi-text-input For the attention of Text Input team labels Apr 22, 2025
@Renzo-Olivares
Copy link
Contributor

Hi @yakagami, thank you for your patience will take a look this week.

@Renzo-Olivares
Copy link
Contributor

Renzo-Olivares commented Apr 28, 2025

Hi @yakagami, thank you for your patience. I'm trying to understand the use-cases that this PR is trying to accomplish.

Perform a double tap immediately and then on the third tap+drag, perform the pan/scale action

I think I was able to accomplish this case with the code below.

Code Sample
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const GooglePhotosTapAndScaleDemo(),
    );
  }
}

class GooglePhotosTapAndScaleDemo extends StatefulWidget {
  const GooglePhotosTapAndScaleDemo({super.key});

  @override
  State<GooglePhotosTapAndScaleDemo> createState() =>
      _GooglePhotosTapAndScaleDemoState();
}

class _GooglePhotosTapAndScaleDemoState
    extends State<GooglePhotosTapAndScaleDemo> {
  final Map<Type, GestureRecognizerFactory<GestureRecognizer>>
  _gestureRecognizers = <Type, GestureRecognizerFactory<GestureRecognizer>>{};

  final double scaleMultiplier = -0.0001;
  double _currentScale = 1.0;
  Offset? _previousDragPosition;

  static double _keepScaleWithinBounds(double scale) {
    const double minScale = 1.0;
    const double maxScale = 30;
    if (scale <= 0) {
      return minScale;
    }
    if (scale >= 30) {
      return maxScale;
    }
    return scale;
  }

  void _zoomLogic(Offset currentDragPosition) {
    final double dx = (_previousDragPosition!.dx - currentDragPosition.dx)
        .abs();
    final double dy = (_previousDragPosition!.dy - currentDragPosition.dy)
        .abs();

    if (dx > dy) {
      // Ignore horizontal drags.
      _previousDragPosition = currentDragPosition;
      return;
    }

    if (currentDragPosition.dy < _previousDragPosition!.dy) {
      // Zoom out on drag up.
      setState(() {
        _currentScale += currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    } else {
      // Zoom in on drag down.
      setState(() {
        _currentScale -= currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    }
    _previousDragPosition = currentDragPosition;
  }

  void _initGestures() {
    _gestureRecognizers[TapAndPanGestureRecognizer] =
        GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
          () => TapAndPanGestureRecognizer(debugOwner: this),
          (TapAndPanGestureRecognizer instance) {
            instance
              ..dragStartBehavior = DragStartBehavior.down
              ..maxConsecutiveTap = 3
              ..onTapDown = (TapDragDownDetails details) {
                _previousDragPosition = details.globalPosition;
              }
              ..onTapUp = (TapDragUpDetails details) {
                if (details.consecutiveTapCount == 2) {
                  setState(() {
                    if (_currentScale == 1.0) {
                      _currentScale = 2.0;
                    } else {
                      _currentScale = 1.0;
                    }
                  });
                }
              }
              ..onDragUpdate = (TapDragUpdateDetails details) {
                if (details.consecutiveTapCount == 3) {
                  _zoomLogic(details.globalPosition);
                }
              };
          },
        );
  }

  @override
  void initState() {
    super.initState();
    _initGestures();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Scale Gesture Example')),
      body: Center(
        child: RawGestureDetector(
          gestures: _gestureRecognizers,
          child: Transform.scale(
            scale: _currentScale,
            child: Container(
              width: 150,
              height: 150,
              decoration: BoxDecoration(
                color: Colors.lightBlueAccent,
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: Colors.grey.withValues(
                      alpha: (255.0 * 0.5).roundToDouble(),
                    ),
                    spreadRadius: 2,
                    blurRadius: 5,
                    offset: const Offset(0, 3),
                  ),
                ],
              ),
              child: const Center(child: FlutterLogo(size: 80)),
            ),
          ),
        ),
      ),
    );
  }
}

In google photos on mobile I notice a few behaviors:

  1. A double tap zooms in/zooms out the picture, depending on the current state of the zoom, if the image is zoomed in we zoom out, if it is zoomed out then it zooms in.
  2. A triple tap + drag, zooms in the picture on double tap, and on the third tap + drag it begins to scale the image up and down based on the vertical axis.
  3. A 2 pointer scale at anytime scales the image.

The code below accomplishes that for the most part.

Code Sample
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const GooglePhotosTapAndScaleDemo(),
    );
  }
}

class GooglePhotosTapAndScaleDemo extends StatefulWidget {
  const GooglePhotosTapAndScaleDemo({super.key});

  @override
  State<GooglePhotosTapAndScaleDemo> createState() =>
      _GooglePhotosTapAndScaleDemoState();
}

class _GooglePhotosTapAndScaleDemoState
    extends State<GooglePhotosTapAndScaleDemo> {
  final Map<Type, GestureRecognizerFactory<GestureRecognizer>>
  _gestureRecognizers = <Type, GestureRecognizerFactory<GestureRecognizer>>{};

  final double scaleMultiplier = -0.0001;
  double _currentScale = 1.0;
  Offset? _previousDragPosition;

  static double _keepScaleWithinBounds(double scale) {
    const double minScale = 1.0;
    const double maxScale = 30;
    if (scale <= 0) {
      return minScale;
    }
    if (scale >= 30) {
      return maxScale;
    }
    return scale;
  }

  void _zoomLogic(Offset currentDragPosition) {
    final double dx = (_previousDragPosition!.dx - currentDragPosition.dx)
        .abs();
    final double dy = (_previousDragPosition!.dy - currentDragPosition.dy)
        .abs();

    if (dx > dy) {
      // Ignore horizontal drags.
      _previousDragPosition = currentDragPosition;
      return;
    }

    if (currentDragPosition.dy < _previousDragPosition!.dy) {
      // Zoom out on drag up.
      setState(() {
        _currentScale += currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    } else {
      // Zoom in on drag down.
      setState(() {
        _currentScale -= currentDragPosition.dy * scaleMultiplier;
        _currentScale = _keepScaleWithinBounds(_currentScale);
      });
    }
    _previousDragPosition = currentDragPosition;
  }

  void _initGestures() {
    _gestureRecognizers[TapAndPanGestureRecognizer] =
        GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
          () => TapAndPanGestureRecognizer(debugOwner: this),
          (TapAndPanGestureRecognizer instance) {
            instance
              ..dragStartBehavior = DragStartBehavior.down
              ..maxConsecutiveTap = 3
              ..onTapDown = (TapDragDownDetails details) {
                _previousDragPosition = details.globalPosition;
              }
              ..onTapUp = (TapDragUpDetails details) {
                if (details.consecutiveTapCount == 2) {
                  setState(() {
                    if (_currentScale == 1.0) {
                      _currentScale = 2.0;
                    } else {
                      _currentScale = 1.0;
                    }
                  });
                }
              }
              ..onDragUpdate = (TapDragUpdateDetails details) {
                if (details.consecutiveTapCount == 3) {
                  _zoomLogic(details.globalPosition);
                }
              };
          },
        );
    _gestureRecognizers[ScaleGestureRecognizer] =
        GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
          () => ScaleGestureRecognizer(debugOwner: this),
          (ScaleGestureRecognizer instance) {
            instance
              .onUpdate = (ScaleUpdateDetails details) {
                setState(() {
                  _currentScale = details.scale;
                });
              };
          },
        );
  }

  @override
  void initState() {
    super.initState();
    _initGestures();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Scale Gesture Example')),
      body: Center(
        child: RawGestureDetector(
          gestures: _gestureRecognizers,
          child: Transform.scale(
            scale: _currentScale,
            child: Container(
              width: 150,
              height: 150,
              decoration: BoxDecoration(
                color: Colors.lightBlueAccent,
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: Colors.grey.withValues(
                      alpha: (255.0 * 0.5).roundToDouble(),
                    ),
                    spreadRadius: 2,
                    blurRadius: 5,
                    offset: const Offset(0, 3),
                  ),
                ],
              ),
              child: const Center(child: FlutterLogo(size: 80)),
            ),
          ),
        ),
      ),
    );
  }
}

Besides this, the current method of detecting a double tap with TapAndPanGestureRecognizer is somewhat cumbersome as you have to do this

Regarding detecting a double tap, can we just use details.consecutiveTapCount instead of tracking it ourselves?

After some testing, I see that you can't combine TapAndPanGestureRecognizer with DoubleTapGestureRecognizer. In fact, nothing is printed from either TapAndPanGestureRecognizer or DoubleTapGestureRecognizer. (With and without this PR.)

I tested your double tap + tap and pan example on the master branch and Double Tap is printed for me. The reason that using the DoubleTapGestureRecognizer with a TapAndPanGestureRecognizer results in double tap being printed is that TapAndPanGestureRecognizer does not eagerly accept the gesture on normal taps, only on drags.

@yakagami
Copy link
Contributor Author

yakagami commented Apr 28, 2025

@Renzo-Olivares Thanks for taking a look and for those comments.

I'm trying to understand the use-cases that this PR is trying to accomplish.

The use case, if I remember correctly, was that ScaleGestureRecognizer would never win the arena because TapAndPanGestureRecognizer would always win, which is what I wrote in the a comment in the "before" example:

ScaleGestureRecognizer never wins the arena

However after testing this again the scale does usually win. I'm not sure if something changed since then. But there is still one case where the scale should win for my use case but it does not:

If you place one pointer down and do not drag or lift the pointer for > 3 seconds, then begin to drag after 3 seconds, the TapAndPanGestureRecognizer will win. I am using a ScaleGestureRecognizer to handle both zooming and panning so it would be better if I didn't have to handle this case within the TapAndPanGestureRecognizer, although I suppose I could theoretically.

Here's a video showing this, specifically the last two gestures in the video. Those last two gestures should be panning the list.

untitled.mp4

full example code:

code
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';

void main() {
  runApp(const MaterialApp(
    home: ZoomViewExample(),
  ));
}

class ZoomViewExample extends StatefulWidget {
  const ZoomViewExample({super.key});

  @override
  State<ZoomViewExample> createState() => _ZoomViewExampleState();
}

class _ZoomViewExampleState extends State<ZoomViewExample> with TickerProviderStateMixin {
  ScrollController controller = ScrollController();
  ZoomViewGestureHandler handler = ZoomViewGestureHandler(zoomLevels: [2, 1]);

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Expanded(
            child: ZoomView(
              controller: controller,
              minScale: 0.5,
              maxScale: double.infinity,
              onDoubleTapDown: (ZoomViewDetails zoomViewDetails) {
                return handler.onDoubleTap(zoomViewDetails);
              },
              child: ListView.builder(
                  controller: controller,
                  itemCount: 10000,
                  physics: BouncingScrollPhysics(),
                  itemBuilder: (context, index) {
                    return Center(
                        child: Text("Text $index"),
                    );
                  }),
            ),
          ),
        ],
      ),
    );
  }
}


///Allows a ListView or other Scrollables that implement ScrollPosition and
///jumpTo(offset) in their controller to be zoomed and scrolled.
class ZoomView extends StatefulWidget {
  const ZoomView({
    super.key,
    required this.child,
    required this.controller,
    this.maxScale = 4.0,
    this.minScale = 1.0,
    this.onDoubleTapDown,
    this.scrollAxis = Axis.vertical,
  });

  ///Callback invoked after a double tap down.
  ///This is set by the user but will generally be [ZoomViewGestureHandler.onDoubleTap] or null.
  final void Function(ZoomViewDetails details)? onDoubleTapDown;
  final Widget child;
  final ScrollController controller;

  ///scrollAxis must be set to Axis.horizontal if the Scrollable is horizontal
  final Axis scrollAxis;

  ///The maximum scale that the ZoomView can be zoomed to. Set to double.infinity to allow infinite zoom in
  final double maxScale;

  ///The minimum scale that the ZoomView can be zoomed to. Set to 0 to allow infinite zoom out
  final double minScale;

  @override
  State<ZoomView> createState() => _ZoomViewState();
}

class _ZoomViewState extends State<ZoomView> with TickerProviderStateMixin {
  @override
  void initState() {
    if (widget.scrollAxis == Axis.vertical) {
      _verticalController = widget.controller;
      _horizontalController = ScrollController();
    } else {
      _verticalController = ScrollController();
      _horizontalController = widget.controller;
    }
    _verticalTouchHandler = _TouchHandler(controller: _verticalController);
    _horizontalTouchHandler = _TouchHandler(controller: _horizontalController);

    _animationController = AnimationController.unbounded(vsync: this)
      ..addListener(() {
        _updateScale(_animationController.value);
      });
             WidgetsBinding.instance.addPostFrameCallback((_) {
      _verticalAnimationController =
          AnimationController.unbounded(vsync: _verticalController.position.context.vsync)
            ..addListener(() {
              _verticalController.jumpTo(_verticalAnimationController.value);
            });

      _horizontalAnimationController =
          AnimationController.unbounded(vsync: _horizontalController.position.context.vsync)
            ..addListener(() {
              _horizontalController.jumpTo(_horizontalAnimationController.value);
            });
    });



    _maxScale = 1 / widget.maxScale;
    _minScale = 1 / widget.minScale;

    super.initState();
  }

  ///The current scale of the ZoomView
  double _scale = 1;

  ///The scale of the ZoomView before the last scale update event
  double _lastScale = 1;

  ///Used for trackpad pointerEvents to determine if the user is panning or scaling
  late TrackPadState _trackPadState;

  ///Total distance the trackpad has moved vertically since the last scale start event
  Size _globalTrackpadDistance = Size.zero;

  ///Used to by double tap to animate to a new scale
  late final AnimationController _animationController;

  ///Used by double tap to animate the vertical scroll position
  late final AnimationController _verticalAnimationController;

  ///Used by double tap to animate the horizontal scroll position
  late final AnimationController _horizontalAnimationController;

  late final ScrollController _verticalController;
  late final ScrollController _horizontalController;

  late final _TouchHandler _verticalTouchHandler;
  late final _TouchHandler _horizontalTouchHandler;

  late final double _maxScale;
  late final double _minScale;

  Offset? _previousDragPosition;

  final VelocityTracker _tracker = VelocityTracker.withKind(
    PointerDeviceKind.touch,
  );

  late TapDownDetails _tapDownDetails;

  ///The focal point of pointers at the start of a scale event
  late Offset _localFocalPoint;

  void _updateScale(double scale) {
    setState(() {
      _scale = scale;
      _lastScale = scale;
    });
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        double height = constraints.maxHeight;
        double width = constraints.maxWidth;
        //The listener is only needed for trackpad events
        return Listener(
          behavior: HitTestBehavior.translucent,
          onPointerDown: (PointerDownEvent event) {
            _trackPadState = event.kind == PointerDeviceKind.trackpad
                ? TrackPadState.waiting
                : TrackPadState.none;
          },
          onPointerPanZoomStart: (PointerPanZoomStartEvent event) {
            _trackPadState = event.kind == PointerDeviceKind.trackpad
                ? TrackPadState.waiting
                : TrackPadState.none;
          },
          child: RawGestureDetector(
            behavior: HitTestBehavior.translucent,
            gestures: <Type, GestureRecognizerFactory>{
              ScaleGestureRecognizer:
                GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
                  () => ScaleGestureRecognizer(),
                  (ScaleGestureRecognizer instance) {
                    instance
                      .. onStart = (ScaleStartDetails details) {
                        if (details.pointerCount == 1) {
                          DragStartDetails dragDetails = DragStartDetails(
                            globalPosition: details.focalPoint,
                            kind: PointerDeviceKind.touch,
                          );
                          _verticalTouchHandler.handleDragStart(dragDetails);
                          _horizontalTouchHandler.handleDragStart(dragDetails);
                        } else {
                          _localFocalPoint = details.localFocalPoint;
                        }
                      }
                      .. onUpdate = (ScaleUpdateDetails details) {
                        //If the trackpad has not moved enough to determine the
                        //gesture type, then wait for it to move more
                        if (_trackPadState == TrackPadState.waiting) {
                          if (details.scale != 1.0) {
                            _trackPadState = TrackPadState.scale;
                          } else {
                            _globalTrackpadDistance += details.focalPointDelta * _scale;
                            if (_globalTrackpadDistance.longestSide > kPrecisePointerPanSlop) {
                              _trackPadState = TrackPadState.pan;
                              DragStartDetails dragDetails = DragStartDetails(
                                globalPosition: details.focalPoint,
                                kind: PointerDeviceKind.touch,
                              );
                              _verticalTouchHandler.handleDragStart(dragDetails);
                              _horizontalTouchHandler.handleDragStart(dragDetails);
                            }
                          }
                        } else if (details.pointerCount > 1 && _trackPadState == TrackPadState.none ||
                            _trackPadState == TrackPadState.scale) {
                          final newScale = _clampDouble(_lastScale / details.scale, _maxScale, _minScale);
                          final verticalOffset =
                              _verticalController.position.pixels + (_scale - newScale) * _localFocalPoint.dy;
                          final horizontalOffset = _horizontalController.position.pixels +
                              (_scale - newScale) * _localFocalPoint.dx;
                          //This is the main logic to actually perform the scaling
                          setState(() {
                            _scale = newScale;
                          });
                          _verticalController.jumpTo(verticalOffset);
                          _horizontalController.jumpTo(horizontalOffset);
                        } else {
                          final correctedDelta = details.focalPointDelta * _scale;
                          final correctedOffset = details.focalPoint * _scale;
                          final time = details.sourceTimeStamp!;
                          _tracker.addPosition(time, correctedOffset);
                          final DragUpdateDetails verticalDetails = DragUpdateDetails(
                            globalPosition: correctedOffset,
                            sourceTimeStamp: time,
                            primaryDelta: correctedDelta.dy,
                            delta: Offset(0.0, correctedDelta.dy),
                          );
                          final DragUpdateDetails horizontalDetails = DragUpdateDetails(
                            globalPosition: correctedOffset,
                            sourceTimeStamp: time,
                            primaryDelta: correctedDelta.dx,
                            delta: Offset(correctedDelta.dx, 0.0),
                          );
                          _verticalTouchHandler.handleDragUpdate(verticalDetails);
                          _horizontalTouchHandler.handleDragUpdate(horizontalDetails);
                        }
                      }
                      .. onEnd = (ScaleEndDetails details) {
                        _trackPadState = TrackPadState.none;
                        _globalTrackpadDistance = Size.zero;
                        _lastScale = _scale;
                        Offset velocity = _tracker.getVelocity().pixelsPerSecond;
                        DragEndDetails endDetails = DragEndDetails(
                          velocity: Velocity(pixelsPerSecond: Offset(0.0, velocity.dy)),
                          primaryVelocity: velocity.dy,
                        );
                        DragEndDetails hEndDetails = DragEndDetails(
                          velocity: Velocity(pixelsPerSecond: Offset(velocity.dx, 0.0)),
                          primaryVelocity: velocity.dx,
                        );
                        _verticalTouchHandler.handleDragEnd(endDetails);
                        _horizontalTouchHandler.handleDragEnd(hEndDetails);
                        };
                      
                    }),
            TapAndPanGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
              () => TapAndPanGestureRecognizer( ),
              (TapAndPanGestureRecognizer instance) {
                instance
                  ..onTapDown = (TapDragDownDetails details) {
                    print("onTapDown ${details.consecutiveTapCount}");
                  }
                  ..onTapUp = widget.onDoubleTapDown == null
                  ? null
                  :(TapDragUpDetails details){
                      if(details.consecutiveTapCount > 1){
                        final tapDownDetails = TapDownDetails(
                        globalPosition: details.globalPosition,
                        localPosition: details.localPosition,
                        kind: details.kind,
                      );
                      ZoomViewDetails zoomViewDetails = ZoomViewDetails(
                        height: height,
                        width: width,
                        scale: _scale,
                        updateScale: _updateScale,
                        tapDownDetails: tapDownDetails,
                        verticalController: _verticalController,
                        horizontalController: _horizontalController,
                        animationController: _animationController,
                        verticalAnimationController: _verticalAnimationController,
                        horizontalAnimationController: _horizontalAnimationController,
                      );
                      setState(() {
                        widget.onDoubleTapDown!(zoomViewDetails);
                      });
                    }
                  }
                  ..onDragStart = (TapDragStartDetails details) {
                      _previousDragPosition ??= details.localPosition;
                  }
                  ..onDragUpdate = (TapDragUpdateDetails details) {
                      if(details.consecutiveTapCount > 1){
                        final currentDragPosition = details.localPosition;
                        final double dx = (_previousDragPosition!.dx - currentDragPosition.dx);
                        double dy = (_previousDragPosition!.dy - currentDragPosition.dy);

                        if (dx.abs() > dy.abs()) {
                          // Ignore horizontal drags.
                          return;
                        }
                        const double scaleMultiplier = 200;
                        final newScale =  _clampDouble(_lastScale + _lastScale + (dy/scaleMultiplier), _maxScale, _minScale);
                          final verticalOffset =
                              _verticalController.position.pixels + (_scale - newScale) * details.localPosition.dy;
                          final horizontalOffset = _horizontalController.position.pixels +
                              (_scale - newScale) * details.localPosition.dx;
                          //This is the main logic to actually perform the scaling
                          setState(() {
                            _scale = newScale;
                          });
                          _verticalController.jumpTo(verticalOffset);
                          _horizontalController.jumpTo(horizontalOffset);
                      }
                  }
                  ..onDragEnd = (TapDragEndDetails details) {    
                    _previousDragPosition = null;  
                    print("Drag End");              
                  };
              },
            ),
         
            },
            child: Column(
              children: [
                Expanded(
                  //When scale decreases, the SizedBox will shrink and the FittedBox
                  //will scale the child to fit the maximum constraints of the ZoomView
                  child: FittedBox(
                    fit: BoxFit.fill,
                    child: SizedBox(
                      height: height * _scale,
                      width: width * _scale,
                      child: Center(
                        child: ScrollConfiguration(
                          behavior: const ScrollBehavior().copyWith(
                            overscroll: false,
                            //Disable all inputs on the list as we will handle them
                            //ourselves using the gesture detector and scroll controllers
                            dragDevices: <PointerDeviceKind>{},
                            scrollbars: false,
                          ),
                          child: SingleChildScrollView(
                            physics: const ClampingScrollPhysics(),
                            controller: widget.scrollAxis == Axis.vertical
                                ? _horizontalController
                                : _verticalController,
                            scrollDirection: widget.scrollAxis == Axis.vertical
                                ? Axis.horizontal
                                : Axis.vertical,
                            child: SizedBox(
                              width: widget.scrollAxis == Axis.vertical ? width : null,
                              height: widget.scrollAxis == Axis.vertical ? null : height,
                              child: widget.child,
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

///keeps record of the state of a trackpad. Should be set to none if
///the PointerDeviceKind is not a trackpad
enum TrackPadState { none, waiting, pan, scale }

///Details needed to perform a double tap zoom
final class ZoomViewDetails {
  final TapDownDetails tapDownDetails;
  final double height;
  final double width;
  final Function updateScale;
  final ScrollController verticalController;
  final ScrollController horizontalController;
  final AnimationController animationController;
  final AnimationController verticalAnimationController;
  final AnimationController horizontalAnimationController;
  final double scale;
  ZoomViewDetails({
    required this.verticalController,
    required this.horizontalController,
    required this.tapDownDetails,
    required this.height,
    required this.width,
    required this.updateScale,
    required this.animationController,
    required this.verticalAnimationController,
    required this.horizontalAnimationController,
    required this.scale,
  });

  ///Calculates the new vertical offset for the scroll controller
  double getVerticalOffset(double newScale) {
    return verticalController.position.pixels +
        (scale - newScale) * tapDownDetails.localPosition.dy;
  }

  ///Calculates the new horizontal offset for the scroll controller
  double getHorizontalOffset(double newScale) {
    return horizontalController.position.pixels +
        (scale - newScale) * tapDownDetails.localPosition.dx;
  }
}

///Handles the logic for a double tap zoom via the [ZoomView.onDoubleTapDown] callback
final class ZoomViewGestureHandler {
  int _index = 0;
  final List<double> zoomLevels;
  final Duration duration;
  ZoomViewGestureHandler({
    required this.zoomLevels,
    this.duration = const Duration(milliseconds: 100),
  });

  void onDoubleTap(ZoomViewDetails zoomViewDetails) {
    late double newScale;
    if (zoomViewDetails.scale > 1.0 && 1 == 1) {
      newScale = 1;
      _index = 0;
    } else {
      newScale = 1 / zoomLevels[_index];
      _index++;
      if (_index == zoomLevels.length) {
        _index = 0;
      }
    }

    final verticalOffset = zoomViewDetails.getVerticalOffset(newScale);
    final horizontalOffset = zoomViewDetails.getHorizontalOffset(newScale);

    if (duration != const Duration(milliseconds: 0)) {
            zoomViewDetails.animationController
        ..value = zoomViewDetails.scale
        ..animateTo(
          newScale,
          duration: duration,
          curve: Curves.linear,
        );

      zoomViewDetails.verticalAnimationController
        ..value = zoomViewDetails.verticalController.position.pixels
        ..animateTo(
          verticalOffset,
          duration: duration,
          curve: Curves.linear,
        );

      zoomViewDetails.horizontalAnimationController
        ..value = zoomViewDetails.horizontalController.position.pixels
        ..animateTo(
          horizontalOffset,
          duration: duration,
          curve: Curves.linear,
        );
      /*
      final animationController = zoomViewDetails.animationController;
      animationController.duration = duration;
      animationController.value = 0.0;
      late final Animation s;
      s = Tween(begin: zoomViewDetails.scale, end: newScale).animate(animationController)
        ..addListener(() {
          zoomViewDetails.updateScale(s.value);
        });

      late final Animation v;
      v = Tween(begin: zoomViewDetails.verticalController.position.pixels, end: verticalOffset)
          .animate(animationController)
        ..addListener(() {
          zoomViewDetails.verticalController.jumpTo(v.value);
        });

      late final Animation h;
      h = Tween(begin: zoomViewDetails.horizontalController.position.pixels, end: horizontalOffset)
          .animate(animationController)
        ..addListener(() {
          zoomViewDetails.horizontalController.jumpTo(h.value);
        });
      animationController.animateTo(1.0);
      */
    } else {
      zoomViewDetails.updateScale(newScale);
      zoomViewDetails.horizontalController.jumpTo(horizontalOffset);
      zoomViewDetails.verticalController.jumpTo(verticalOffset);
    }
  }
}

///Touch handlers coppied from Flutter ScrollableState
final class _TouchHandler {
  final ScrollController controller;
  _TouchHandler({required this.controller});
  final GlobalKey<RawGestureDetectorState> _gestureDetectorKey =
      GlobalKey<RawGestureDetectorState>();
  Drag? _drag;

  ScrollHoldController? _hold;

  void handleDragDown(DragDownDetails details) {
    assert(_drag == null);
    assert(_hold == null);
    _hold = controller.position.hold(disposeHold);
  }

  void handleDragStart(DragStartDetails details) {
    assert(_drag == null);
    _drag = controller.position.drag(details, disposeDrag);
    assert(_drag != null);
    assert(_hold == null);
  }

  void handleDragUpdate(DragUpdateDetails details) {
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _drag?.update(details);
  }

  void handleDragEnd(DragEndDetails details) {
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _drag?.end(details);
    assert(_drag == null);
  }

  void handleDragCancel() {
    if (_gestureDetectorKey.currentContext == null) {
      return;
    }
    // _hold might be null if the drag started.
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _hold?.cancel();
    _drag?.cancel();
    assert(_hold == null);
    assert(_drag == null);
  }

  void disposeHold() {
    _hold = null;
  }

  void disposeDrag() {
    _drag = null;
  }
}

double _clampDouble(double x, double min, double max) {
  if (x < min) {
    return min;
  }
  if (x > max) {
    return max;
  }
  return x;
}

The exact use case is to add tap and drag to zoom to this package: https://github.com/yakagami/zoom_view. The package allows a user to zoom into a ListView, which is not supported by Flutter natively. To do this it intercepts all drag events on the List via a ScaleGestureRecognizer.

I guess what I'm trying to do with this PR is to make TapAndPanGestureRecognizer be able to be just a double tap and tap and drag gesture recognizer. I would not like it to try to win the arena before it meets the minimum number of taps. Although, asuming TapAndPanGestureRecognizer can reliably detect double taps as our wrote below, maybe the correct solution here really is to add a TapAndScaleGestureRecognizer that handles everything. That might be the most clean API for other developers wanting the same features. I'm not sure how much work that would be but if you think that would be better Id be open to doing that instead.

Regarding detecting a double tap, can we just use details.consecutiveTapCount instead of tracking it ourselves?

I think you are right here. I must have missed that.

@Renzo-Olivares
Copy link
Contributor

Hi @yakagami, thank you for your patience on this one. That is an awesome package! This is definitely a tricky use-case. Handling some of the pan logic in TapAndPanGestureRecognizer is reasonable but not ideal. I think the clean way to approach this would be through a TapAndScaleGestureRecognizer considering that a scale is a superset of a pan it would be better to handle all of these use-cases in one recognizer instead of the two that are fighting against each other. At this time I don't have the bandwidth to review a change like that, but I encourage you to try to build this out and include it in your own package. We should keep the issue open for further discussion, and maybe change it to a TapAndScaleGestureRecognizer proposal instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: text input Entering text in a text field or keyboard related problems f: gestures flutter/packages/flutter/gestures repository. framework flutter/packages/flutter repository. See also f: labels. fyi-text-input For the attention of Text Input team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add double tap and pan/double tap and drag gesture
3 participants