Unverified Commit 0b0942a6 authored by Tong Mu's avatar Tong Mu Committed by GitHub

Refactor: Base tap gesture recognizer (#41329)

* Extracts the logic of TapGestureRecognizer into an abstract class BaseTapGestureRecognizer
* Fixes ModalBarrier unable to dismiss when competing
parent bedf46d0
...@@ -59,7 +59,7 @@ class _GestureArena { ...@@ -59,7 +59,7 @@ class _GestureArena {
bool isHeld = false; bool isHeld = false;
bool hasPendingSweep = false; bool hasPendingSweep = false;
/// If a gesture attempts to win while the arena is still open, it becomes the /// If a member attempts to win while the arena is still open, it becomes the
/// "eager winner". We look for an eager winner when closing the arena to new /// "eager winner". We look for an eager winner when closing the arena to new
/// participants, and if there is one, we resolve the arena in its favor at /// participants, and if there is one, we resolve the arena in its favor at
/// that time. /// that time.
......
...@@ -68,10 +68,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { ...@@ -68,10 +68,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// Configure the behavior of offsets sent to [onStart]. /// Configure the behavior of offsets sent to [onStart].
/// ///
/// If set to [DragStartBehavior.start], the [onStart] callback will be called at the time and /// If set to [DragStartBehavior.start], the [onStart] callback will be called
/// position when the gesture detector wins the arena. If [DragStartBehavior.down], /// at the time and position when this gesture recognizer wins the arena. If
/// [onStart] will be called at the time and position when a down event was /// [DragStartBehavior.down], [onStart] will be called at the time and
/// first detected. /// position when a down event was first detected.
/// ///
/// For more information about the gesture arena: /// For more information about the gesture arena:
/// https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation /// https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation
...@@ -80,9 +80,9 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { ...@@ -80,9 +80,9 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// ///
/// ## Example: /// ## Example:
/// ///
/// A finger presses down on the screen with offset (500.0, 500.0), /// A finger presses down on the screen with offset (500.0, 500.0), and then
/// and then moves to position (510.0, 500.0) before winning the arena. /// moves to position (510.0, 500.0) before winning the arena. With
/// With [dragStartBehavior] set to [DragStartBehavior.down], the [onStart] /// [dragStartBehavior] set to [DragStartBehavior.down], the [onStart]
/// callback will be called at the time corresponding to the touch's position /// callback will be called at the time corresponding to the touch's position
/// at (500.0, 500.0). If it is instead set to [DragStartBehavior.start], /// at (500.0, 500.0). If it is instead set to [DragStartBehavior.start],
/// [onStart] will be called at the time corresponding to the touch's position /// [onStart] will be called at the time corresponding to the touch's position
......
This diff is collapsed.
...@@ -191,99 +191,41 @@ class AnimatedModalBarrier extends AnimatedWidget { ...@@ -191,99 +191,41 @@ class AnimatedModalBarrier extends AnimatedWidget {
// //
// It is similar to [TapGestureRecognizer.onTapDown], but accepts any single // It is similar to [TapGestureRecognizer.onTapDown], but accepts any single
// button, which means the gesture also takes parts in gesture arenas. // button, which means the gesture also takes parts in gesture arenas.
class _AnyTapGestureRecognizer extends PrimaryPointerGestureRecognizer { class _AnyTapGestureRecognizer extends BaseTapGestureRecognizer {
_AnyTapGestureRecognizer({ _AnyTapGestureRecognizer({ Object debugOwner })
Object debugOwner, : super(debugOwner: debugOwner);
}) : super(debugOwner: debugOwner);
VoidCallback onAnyTapDown; VoidCallback onAnyTapDown;
bool _sentTapDown = false; @protected
bool _wonArenaForPrimaryPointer = false;
// The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a
// different set of buttons, the gesture is canceled.
int _initialButtons;
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
// `_initialButtons` must be assigned here instead of `handlePrimaryPointer`,
// because `acceptGesture` might be called before `handlePrimaryPointer`,
// which relies on `_initialButtons` to create `TapDownDetails`.
_initialButtons = event.buttons;
}
@override @override
void handlePrimaryPointer(PointerEvent event) { bool isPointerAllowed(PointerDownEvent event) {
if (event is PointerUpEvent || event is PointerCancelEvent) { if (onAnyTapDown == null)
resolve(GestureDisposition.rejected); return false;
_reset(); return super.isPointerAllowed(event);
} else if (event.buttons != _initialButtons) {
resolve(GestureDisposition.rejected);
stopTrackingPointer(primaryPointer);
}
} }
@protected
@override @override
void resolve(GestureDisposition disposition) { void handleTapDown({PointerDownEvent down}) {
if (_wonArenaForPrimaryPointer && disposition == GestureDisposition.rejected) { if (onAnyTapDown != null)
// This can happen if the gesture has been canceled. For example, when onAnyTapDown();
// the pointer has exceeded the touch slop, the buttons have been changed,
// or if the recognizer is disposed.
assert(_sentTapDown);
_reset();
}
super.resolve(disposition);
}
@override
void didExceedDeadlineWithEvent(PointerDownEvent event) {
_checkDown(event.pointer);
} }
@protected
@override @override
void acceptGesture(int pointer) { void handleTapUp({PointerDownEvent down, PointerUpEvent up}) {
super.acceptGesture(pointer); // Do nothing.
if (pointer == primaryPointer) {
_checkDown(pointer);
_wonArenaForPrimaryPointer = true;
_reset();
}
} }
@protected
@override @override
void rejectGesture(int pointer) { void handleTapCancel({PointerDownEvent down, PointerCancelEvent cancel, String reason}) {
super.rejectGesture(pointer); // Do nothing.
if (pointer == primaryPointer) {
// Another gesture won the arena.
assert(state != GestureRecognizerState.possible);
_reset();
}
}
void _checkDown(int pointer) {
if (_sentTapDown)
return;
if (onAnyTapDown != null)
onAnyTapDown();
_sentTapDown = true;
}
void _reset() {
_sentTapDown = false;
_wonArenaForPrimaryPointer = false;
_initialButtons = null;
} }
@override @override
String get debugDescription => 'any tap'; String get debugDescription => 'any tap';
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('wonArenaForPrimaryPointer', value: _wonArenaForPrimaryPointer, ifTrue: 'won arena'));
properties.add(FlagProperty('sentTapDown', value: _sentTapDown, ifTrue: 'sent tap down'));
}
} }
class _ModalBarrierSemanticsDelegate extends SemanticsGestureDelegate { class _ModalBarrierSemanticsDelegate extends SemanticsGestureDelegate {
......
...@@ -25,7 +25,7 @@ void main() { ...@@ -25,7 +25,7 @@ void main() {
tap.addPointer(event); tap.addPointer(event);
expect(log, hasLength(2)); expect(log, hasLength(2));
expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ ★ Opening new gesture arena.')); expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ ★ Opening new gesture arena.'));
expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Adding: TapGestureRecognizer#00000(state: ready)')); expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Adding: TapGestureRecognizer#00000(state: ready, button: 1)'));
log.clear(); log.clear();
GestureBinding.instance.gestureArena.close(1); GestureBinding.instance.gestureArena.close(1);
...@@ -43,7 +43,7 @@ void main() { ...@@ -43,7 +43,7 @@ void main() {
GestureBinding.instance.gestureArena.sweep(1); GestureBinding.instance.gestureArena.sweep(1);
expect(log, hasLength(2)); expect(log, hasLength(2));
expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ Sweeping with 1 member.')); expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ Sweeping with 1 member.'));
expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Winner: TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0))')); expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Winner: TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0), button: 1)'));
log.clear(); log.clear();
tap.dispose(); tap.dispose();
...@@ -83,9 +83,9 @@ void main() { ...@@ -83,9 +83,9 @@ void main() {
GestureBinding.instance.gestureArena.sweep(1); GestureBinding.instance.gestureArena.sweep(1);
expect(log, hasLength(3)); expect(log, hasLength(3));
expect(log[0], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0)) calling onTapDown callback.')); expect(log[0], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0), button: 1) calling onTapDown callback.'));
expect(log[1], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), sent tap down) calling onTapUp callback.')); expect(log[1], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), button: 1, sent tap down) calling onTapUp callback.'));
expect(log[2], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), sent tap down) calling onTap callback.')); expect(log[2], equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), button: 1, sent tap down) calling onTap callback.'));
log.clear(); log.clear();
tap.dispose(); tap.dispose();
...@@ -114,7 +114,7 @@ void main() { ...@@ -114,7 +114,7 @@ void main() {
tap.addPointer(event); tap.addPointer(event);
expect(log, hasLength(2)); expect(log, hasLength(2));
expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ ★ Opening new gesture arena.')); expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ ★ Opening new gesture arena.'));
expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Adding: TapGestureRecognizer#00000(state: ready)')); expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Adding: TapGestureRecognizer#00000(state: ready, button: 1)'));
log.clear(); log.clear();
GestureBinding.instance.gestureArena.close(1); GestureBinding.instance.gestureArena.close(1);
...@@ -132,10 +132,10 @@ void main() { ...@@ -132,10 +132,10 @@ void main() {
GestureBinding.instance.gestureArena.sweep(1); GestureBinding.instance.gestureArena.sweep(1);
expect(log, hasLength(5)); expect(log, hasLength(5));
expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ Sweeping with 1 member.')); expect(log[0], equalsIgnoringHashCodes('Gesture arena 1 ❙ Sweeping with 1 member.'));
expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Winner: TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0))')); expect(log[1], equalsIgnoringHashCodes('Gesture arena 1 ❙ Winner: TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0), button: 1)'));
expect(log[2], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0)) calling onTapDown callback.')); expect(log[2], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, finalPosition: Offset(12.0, 8.0), button: 1) calling onTapDown callback.'));
expect(log[3], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), sent tap down) calling onTapUp callback.')); expect(log[3], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), button: 1, sent tap down) calling onTapUp callback.'));
expect(log[4], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), sent tap down) calling onTap callback.')); expect(log[4], equalsIgnoringHashCodes(' ❙ TapGestureRecognizer#00000(state: ready, won arena, finalPosition: Offset(12.0, 8.0), button: 1, sent tap down) calling onTap callback.'));
log.clear(); log.clear();
tap.dispose(); tap.dispose();
...@@ -152,7 +152,7 @@ void main() { ...@@ -152,7 +152,7 @@ void main() {
expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready)')); expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready)'));
const PointerEvent event = PointerDownEvent(pointer: 1, position: Offset(10.0, 10.0)); const PointerEvent event = PointerDownEvent(pointer: 1, position: Offset(10.0, 10.0));
tap.addPointer(event); tap.addPointer(event);
tap.didExceedDeadlineWithEvent(event); tap.didExceedDeadline();
expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: possible, sent tap down)')); expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: possible, button: 1, sent tap down)'));
}); });
} }
...@@ -140,7 +140,7 @@ void main() { ...@@ -140,7 +140,7 @@ void main() {
tap.dispose(); tap.dispose();
}); });
testGesture('Should not recognize two overlapping taps', (GestureTester tester) { testGesture('Should not recognize two overlapping taps (FIFO)', (GestureTester tester) {
final TapGestureRecognizer tap = TapGestureRecognizer(); final TapGestureRecognizer tap = TapGestureRecognizer();
int tapsRecognized = 0; int tapsRecognized = 0;
...@@ -174,6 +174,40 @@ void main() { ...@@ -174,6 +174,40 @@ void main() {
tap.dispose(); tap.dispose();
}); });
testGesture('Should not recognize two overlapping taps (FILO)', (GestureTester tester) {
final TapGestureRecognizer tap = TapGestureRecognizer();
int tapsRecognized = 0;
tap.onTap = () {
tapsRecognized++;
};
tap.addPointer(down1);
tester.closeArena(1);
expect(tapsRecognized, 0);
tester.route(down1);
expect(tapsRecognized, 0);
tap.addPointer(down2);
tester.closeArena(2);
expect(tapsRecognized, 0);
tester.route(down1);
expect(tapsRecognized, 0);
tester.route(up2);
expect(tapsRecognized, 0);
GestureBinding.instance.gestureArena.sweep(2);
expect(tapsRecognized, 0);
tester.route(up1);
expect(tapsRecognized, 1);
GestureBinding.instance.gestureArena.sweep(1);
expect(tapsRecognized, 1);
tap.dispose();
});
testGesture('Distance cancels tap', (GestureTester tester) { testGesture('Distance cancels tap', (GestureTester tester) {
final TapGestureRecognizer tap = TapGestureRecognizer(); final TapGestureRecognizer tap = TapGestureRecognizer();
......
...@@ -130,7 +130,7 @@ void main() { ...@@ -130,7 +130,7 @@ void main() {
await tester.pump(); // begin transition await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition await tester.pump(const Duration(seconds: 1)); // end transition
// Tap on the barrier to dismiss it // Press the barrier to dismiss it
await tester.press(find.byKey(const ValueKey<String>('barrier'))); await tester.press(find.byKey(const ValueKey<String>('barrier')));
await tester.pump(); // begin transition await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition await tester.pump(const Duration(seconds: 1)); // end transition
...@@ -155,7 +155,7 @@ void main() { ...@@ -155,7 +155,7 @@ void main() {
await tester.pump(); // begin transition await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition await tester.pump(const Duration(seconds: 1)); // end transition
// Tap on the barrier to dismiss it // Press the barrier to dismiss it
await tester.press(find.byKey(const ValueKey<String>('barrier')), buttons: kSecondaryButton); await tester.press(find.byKey(const ValueKey<String>('barrier')), buttons: kSecondaryButton);
await tester.pump(); // begin transition await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition await tester.pump(const Duration(seconds: 1)); // end transition
...@@ -164,6 +164,31 @@ void main() { ...@@ -164,6 +164,31 @@ void main() {
reason: 'The route should have been dismissed by tapping the barrier.'); reason: 'The route should have been dismissed by tapping the barrier.');
}); });
testWidgets('ModalBarrier may pop the Navigator when competing with other gestures', (WidgetTester tester) async {
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
'/': (BuildContext context) => FirstWidget(),
'/modal': (BuildContext context) => SecondWidgetWithCompetence (),
};
await tester.pumpWidget(MaterialApp(routes: routes));
// Initially the barrier is not visible
expect(find.byKey(const ValueKey<String>('barrier')), findsNothing);
// Tapping on X routes to the barrier
await tester.tap(find.text('X'));
await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition
// Tap on the barrier to dismiss it
await tester.tap(find.byKey(const ValueKey<String>('barrier')));
await tester.pump(); // begin transition
await tester.pump(const Duration(seconds: 1)); // end transition
expect(find.byKey(const ValueKey<String>('barrier')), findsNothing,
reason: 'The route should have been dismissed by tapping the barrier.');
});
testWidgets('ModalBarrier does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async { testWidgets('ModalBarrier does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async {
bool willPopCalled = false; bool willPopCalled = false;
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
...@@ -316,3 +341,22 @@ class SecondWidget extends StatelessWidget { ...@@ -316,3 +341,22 @@ class SecondWidget extends StatelessWidget {
); );
} }
} }
class SecondWidgetWithCompetence extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
const ModalBarrier(
key: ValueKey<String>('barrier'),
dismissible: true,
),
GestureDetector(
onVerticalDragStart: (_) {},
behavior: HitTestBehavior.translucent,
child: Container(),
)
],
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment