Skip to content
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

add minTaps argument to BaseTapAndDragGestureRecognizer and TapAndPanGestureRecognizer #164922

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
f: gestures flutter/packages/flutter/gestures repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add double tap and pan/double tap and drag gesture
1 participant