Unverified Commit 6490a4e9 authored by Todd Volkert's avatar Todd Volkert Committed by GitHub

Add SerialTapGestureRecognizer (#83082)

This gesture recognizer can be used to recognize the classic
"onclick" behavior, where each tap in a series fires an event
with an incrementing "count" of how many taps have occurred
in series. This can in turn be used to build widgets that respond
to both tap and double-tap, or for widgets like the YouTube TV
player, where each subsequent tap in a series of >2 taps adjusts
the playhead another 15 seconds.

Fixes https://github.com/flutter/flutter/issues/81657
parent 36cee180
...@@ -587,3 +587,418 @@ class MultiTapGestureRecognizer extends GestureRecognizer { ...@@ -587,3 +587,418 @@ class MultiTapGestureRecognizer extends GestureRecognizer {
@override @override
String get debugDescription => 'multitap'; String get debugDescription => 'multitap';
} }
/// Signature used by [SerialTapGestureRecognizer.onSerialTapDown] for when a
/// pointer that might cause a serial tap has contacted the screen at a
/// particular location.
typedef GestureSerialTapDownCallback = void Function(SerialTapDownDetails details);
/// Details for [GestureSerialTapDownCallback], such as the tap count within
/// the series.
///
/// See also:
///
/// * [SerialTapGestureRecognizer], which passes this information to its
/// [SerialTapGestureRecognizer.onSerialTapDown] callback.
class SerialTapDownDetails {
/// Creates details for a [GestureSerialTapDownCallback].
///
/// The `count` argument must be greater than zero.
SerialTapDownDetails({
this.globalPosition = Offset.zero,
Offset? localPosition,
required this.kind,
this.buttons = 0,
this.count = 1,
}) : assert(count > 0),
localPosition = localPosition ?? globalPosition;
/// The global position at which the pointer contacted the screen.
final Offset globalPosition;
/// The local position at which the pointer contacted the screen.
final Offset localPosition;
/// The kind of the device that initiated the event.
final PointerDeviceKind kind;
/// Which buttons were pressed when the pointer contacted the screen.
///
/// See also:
///
/// * [PointerEvent.buttons], which this field reflects.
final int buttons;
/// The number of consecutive taps that this "tap down" represents.
///
/// This value will always be greater than zero. When the first pointer in a
/// possible series contacts the screen, this value will be `1`, the second
/// tap in a double-tap will be `2`, and so on.
///
/// If a tap is determined to not be in the same series as the tap that
/// preceded it (e.g. because too much time elapsed between the two taps or
/// the two taps had too much distance between them), then this count will
/// reset back to `1`, and a new series will have begun.
final int count;
}
/// Signature used by [SerialTapGestureRecognizer.onSerialTapCancel] for when a
/// pointer that previously triggered a [GestureSerialTapDownCallback] will not
/// end up completing the serial tap.
typedef GestureSerialTapCancelCallback = void Function(SerialTapCancelDetails details);
/// Details for [GestureSerialTapCancelCallback], such as the tap count within
/// the series.
///
/// See also:
///
/// * [SerialTapGestureRecognizer], which passes this information to its
/// [SerialTapGestureRecognizer.onSerialTapCancel] callback.
class SerialTapCancelDetails {
/// Creates details for a [GestureSerialTapCancelCallback].
///
/// The `count` argument must be greater than zero.
SerialTapCancelDetails({
this.count = 1,
}) : assert(count != null),
assert(count > 0);
/// The number of consecutive taps that were in progress when the gesture was
/// interrupted.
///
/// This number will match the corresponding count that was specified in
/// [SerialTapDownDetails.count] for the tap that is being canceled. See
/// that field for more information on how this count is reported.
final int count;
}
/// Signature used by [SerialTapGestureRecognizer.onSerialTapUp] for when a
/// pointer that will trigger a serial tap has stopped contacting the screen.
typedef GestureSerialTapUpCallback = void Function(SerialTapUpDetails details);
/// Details for [GestureSerialTapUpCallback], such as the tap count within
/// the series.
///
/// See also:
///
/// * [SerialTapGestureRecognizer], which passes this information to its
/// [SerialTapGestureRecognizer.onSerialTapUp] callback.
class SerialTapUpDetails {
/// Creates details for a [GestureSerialTapUpCallback].
///
/// The `count` argument must be greater than zero.
SerialTapUpDetails({
this.globalPosition = Offset.zero,
Offset? localPosition,
this.kind,
this.count = 1,
}) : assert(count > 0),
localPosition = localPosition ?? globalPosition;
/// The global position at which the pointer contacted the screen.
final Offset globalPosition;
/// The local position at which the pointer contacted the screen.
final Offset localPosition;
/// The kind of the device that initiated the event.
final PointerDeviceKind? kind;
/// The number of consecutive taps that this tap represents.
///
/// This value will always be greater than zero. When the first pointer in a
/// possible series completes its tap, this value will be `1`, the second
/// tap in a double-tap will be `2`, and so on.
///
/// If a tap is determined to not be in the same series as the tap that
/// preceded it (e.g. because too much time elapsed between the two taps or
/// the two taps had too much distance between them), then this count will
/// reset back to `1`, and a new series will have begun.
final int count;
}
/// Recognizes serial taps (taps in a series).
///
/// A collection of taps are considered to be _in a series_ if they occur in
/// rapid succession in the same location (within a tolerance). The number of
/// taps in the series is its count. A double-tap, for instance, is a special
/// case of a tap series with a count of two.
///
/// ### Gesture arena behavior
///
/// [SerialTapGestureRecognizer] competes on all pointer events (regardless of
/// button). It will declare defeat if it determines that a gesture is not a
/// tap (e.g. if the pointer is dragged too far while it's contacting the
/// screen). It will immediately declare victory for every tap that it
/// recognizes.
///
/// Each time a pointer contacts the screen, this recognizer will enter that
/// gesture into the arena. This means that this recognizer will yield multiple
/// winning entries in the arena for a single tap series as the series
/// progresses.
///
/// If this recognizer loses the arena (either by declaring defeat or by
/// another recognizer declaring victory) while the pointer is contacting the
/// screen, it will fire [onSerialTapCancel], and [onSerialTapUp] will not
/// be fired.
///
/// ### Button behavior
///
/// A tap series is defined to have the same buttons across all taps. If a tap
/// with a different combination of buttons is delivered in the middle of a
/// series, it will "steal" the series and begin a new series, starting the
/// count over.
///
/// ### Interleaving tap behavior
///
/// A tap must be _completed_ in order for a subsequent tap to be considered
/// "in the same series" as that tap. Thus, if tap A is in-progress (the down
/// event has been received, but the corresponding up event has not yet been
/// received), and tap B begins (another pointer contacts the screen), tap A
/// will fire [onSerialTapCancel], and tap B will begin a new series (tap B's
/// [SerialTapDownDetails.count] will be 1).
///
/// ### Relation to `TapGestureRecognizer` and `DoubleTapGestureRecognizer`
///
/// [SerialTapGestureRecognizer] fires [onSerialTapDown] and [onSerialTapUp]
/// for every tap that it recognizes (passing the count in the details),
/// regardless of whether that tap is a single-tap, double-tap, etc. This
/// makes it especially useful when you want to respond to every tap in a
/// series. Contrast this with [DoubleTapGestureRecognizer], which only fires
/// if the user completes a double-tap, and [TapGestureRecognizer], which
/// _doesn't_ fire if the recognizer is competing with a
/// `DoubleTapGestureRecognizer`, and the user double-taps.
///
/// For example, consider a list item that should be _selected_ on the first
/// tap and _cause an edit dialog to open_ on a double-tap. If you use both
/// [TapGestureRecognizer] and [DoubleTapGestureRecognizer], there are a few
/// problems:
///
/// 1. If the user single-taps the list item, it will not select
/// the list item until after enough time has passed to rule out a
/// double-tap.
/// 2. If the user double-taps the list item, it will not select the list
/// item at all.
///
/// The solution is to use [SerialTapGestureRecognizer] and use the tap count
/// to either select the list item or open the edit dialog.
///
/// ### When competing with `TapGestureRecognizer` and `DoubleTapGestureRecognizer`
///
/// Unlike [TapGestureRecognizer] and [DoubleTapGestureRecognizer],
/// [SerialTapGestureRecognizer] aggressively declares victory when it detects
/// a tap, so when it is competing with those gesture recognizers, it will beat
/// them in the arena, regardless of which recognizer entered the arena first.
class SerialTapGestureRecognizer extends GestureRecognizer {
/// Creates a serial tap gesture recognizer.
SerialTapGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(debugOwner: debugOwner, supportedDevices: supportedDevices);
/// A pointer has contacted the screen at a particular location, which might
/// be the start of a serial tap.
///
/// If this recognizer loses the arena before the serial tap is completed
/// (either because the gesture does not end up being a tap or because another
/// recognizer wins the arena), [onSerialTapCancel] is called next. Otherwise,
/// [onSerialTapUp] is called next.
///
/// The [SerialTapDownDetails.count] that is passed to this callback
/// specifies the series tap count.
GestureSerialTapDownCallback? onSerialTapDown;
/// A pointer that previously triggered [onSerialTapDown] will not end up
/// triggering the corresponding [onSerialTapUp].
///
/// If the user completes the serial tap, [onSerialTapUp] is called instead.
///
/// The [SerialTapCancelDetails.count] that is passed to this callback will
/// match the [SerialTapDownDetails.count] that was passed to the
/// [onSerialTapDown] callback.
GestureSerialTapCancelCallback? onSerialTapCancel;
/// A pointer has stopped contacting the screen at a particular location,
/// representing a serial tap.
///
/// If the user didn't complete the tap, or if another recognizer won the
/// arena, then [onSerialTapCancel] is called instead.
///
/// The [SerialTapUpDetails.count] that is passed to this callback specifies
/// the series tap count and will match the [SerialTapDownDetails.count] that
/// was passed to the [onSerialTapDown] callback.
GestureSerialTapUpCallback? onSerialTapUp;
Timer? _serialTapTimer;
final List<_TapTracker> _completedTaps = <_TapTracker>[];
final Map<int, GestureDisposition> _gestureResolutions = <int, GestureDisposition>{};
_TapTracker? _pendingTap;
/// Indicates whether this recognizer is currently tracking a pointer that's
/// in contact with the screen.
///
/// If this is true, it implies that [onSerialTapDown] has fired, but neither
/// [onSerialTapCancel] nor [onSerialTapUp] have yet fired.
bool get isTrackingPointer => _pendingTap != null;
@override
bool isPointerAllowed(PointerDownEvent event) {
if (onSerialTapDown == null &&
onSerialTapCancel == null &&
onSerialTapUp == null) {
return false;
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
if ((_completedTaps.isNotEmpty && !_representsSameSeries(_completedTaps.last, event))
|| _pendingTap != null) {
_reset();
}
_trackTap(event);
}
bool _representsSameSeries(_TapTracker tap, PointerDownEvent event) {
return tap.hasElapsedMinTime() // touch screens often detect touches intermittently
&& tap.hasSameButton(event)
&& tap.isWithinGlobalTolerance(event, kDoubleTapSlop);
}
void _trackTap(PointerDownEvent event) {
_stopSerialTapTimer();
if (onSerialTapDown != null) {
final SerialTapDownDetails details = SerialTapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
buttons: event.buttons,
count: _completedTaps.length + 1,
);
invokeCallback<void>('onSerialTapDown', () => onSerialTapDown!(details));
}
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
);
assert(_pendingTap == null);
_pendingTap = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform);
}
void _handleEvent(PointerEvent event) {
assert(_pendingTap != null);
assert(_pendingTap!.pointer == event.pointer);
final _TapTracker tracker = _pendingTap!;
if (event is PointerUpEvent) {
_registerTap(event, tracker);
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
_reset();
}
} else if (event is PointerCancelEvent) {
_reset();
}
}
@override
void acceptGesture(int pointer) {
assert(_pendingTap != null);
assert(_pendingTap!.pointer == pointer);
_gestureResolutions[pointer] = GestureDisposition.accepted;
}
@override
void rejectGesture(int pointer) {
_gestureResolutions[pointer] = GestureDisposition.rejected;
_reset();
}
void _rejectPendingTap() {
assert(_pendingTap != null);
final _TapTracker tracker = _pendingTap!;
_pendingTap = null;
// Order is important here; the `resolve` call can yield a re-entrant
// `reset()`, so we need to check cancel here while we can trust the
// length of our _completedTaps list.
_checkCancel(_completedTaps.length + 1);
if (!_gestureResolutions.containsKey(tracker.pointer)) {
tracker.entry.resolve(GestureDisposition.rejected);
}
_stopTrackingPointer(tracker);
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
if (_pendingTap != null) {
_rejectPendingTap();
}
_pendingTap = null;
_completedTaps.clear();
_gestureResolutions.clear();
_stopSerialTapTimer();
}
void _registerTap(PointerUpEvent event, _TapTracker tracker) {
assert(tracker == _pendingTap);
assert(tracker.pointer == event.pointer);
_startSerialTapTimer();
assert(_gestureResolutions[event.pointer] != GestureDisposition.rejected);
if (!_gestureResolutions.containsKey(event.pointer)) {
tracker.entry.resolve(GestureDisposition.accepted);
}
assert(_gestureResolutions[event.pointer] == GestureDisposition.accepted);
_stopTrackingPointer(tracker);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
_pendingTap = null;
_checkUp(event, tracker);
_completedTaps.add(tracker);
}
void _stopTrackingPointer(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startSerialTapTimer() {
_serialTapTimer ??= Timer(kDoubleTapTimeout, _reset);
}
void _stopSerialTapTimer() {
if (_serialTapTimer != null) {
_serialTapTimer!.cancel();
_serialTapTimer = null;
}
}
void _checkUp(PointerUpEvent event, _TapTracker tracker) {
if (onSerialTapUp != null) {
final SerialTapUpDetails details = SerialTapUpDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(tracker.pointer),
count: _completedTaps.length + 1,
);
invokeCallback<void>('onSerialTapUp', () => onSerialTapUp!(details));
}
}
void _checkCancel(int count) {
if (onSerialTapCancel != null) {
final SerialTapCancelDetails details = SerialTapCancelDetails(
count: count,
);
invokeCallback<void>('onSerialTapCancel', () => onSerialTapCancel!(details));
}
}
@override
String get debugDescription => 'serial tap';
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'gesture_tester.dart';
// Anything longer than [kDoubleTapTimeout] will reset the serial tap count.
final Duration kSerialTapDelay = kDoubleTapTimeout ~/ 2;
void main() {
setUp(ensureGestureBinding);
late List<String> events;
late SerialTapGestureRecognizer serial;
setUp(() {
events = <String>[];
serial = SerialTapGestureRecognizer()
..onSerialTapDown = (SerialTapDownDetails details) {
events.add('down#${details.count}');
}
..onSerialTapCancel = (SerialTapCancelDetails details) {
events.add('cancel#${details.count}');
}
..onSerialTapUp = (SerialTapUpDetails details) {
events.add('up#${details.count}');
};
});
// Down/up pair 1: normal tap sequence
const PointerDownEvent down1 = PointerDownEvent(
pointer: 1,
position: Offset(10.0, 10.0),
);
const PointerCancelEvent cancel1 = PointerCancelEvent(
pointer: 1,
);
const PointerUpEvent up1 = PointerUpEvent(
pointer: 1,
position: Offset(11.0, 9.0),
);
// Down/up pair 2: normal tap sequence close to pair 1
const PointerDownEvent down2 = PointerDownEvent(
pointer: 2,
position: Offset(12.0, 12.0),
);
const PointerUpEvent up2 = PointerUpEvent(
pointer: 2,
position: Offset(13.0, 11.0),
);
// Down/up pair 3: normal tap sequence close to pair 1
const PointerDownEvent down3 = PointerDownEvent(
pointer: 3,
position: Offset(12.0, 12.0),
);
const PointerUpEvent up3 = PointerUpEvent(
pointer: 3,
position: Offset(13.0, 11.0),
);
// Down/up pair 4: normal tap sequence far away from pair 1
const PointerDownEvent down4 = PointerDownEvent(
pointer: 4,
position: Offset(130.0, 130.0),
);
const PointerUpEvent up4 = PointerUpEvent(
pointer: 4,
position: Offset(131.0, 129.0),
);
// Down/move/up sequence 5: intervening motion
const PointerDownEvent down5 = PointerDownEvent(
pointer: 5,
position: Offset(10.0, 10.0),
);
const PointerMoveEvent move5 = PointerMoveEvent(
pointer: 5,
position: Offset(25.0, 25.0),
);
const PointerUpEvent up5 = PointerUpEvent(
pointer: 5,
position: Offset(25.0, 25.0),
);
// Down/up pair 7: normal tap sequence close to pair 1 but on secondary button
const PointerDownEvent down6 = PointerDownEvent(
pointer: 6,
position: Offset(10.0, 10.0),
buttons: kSecondaryMouseButton,
);
const PointerUpEvent up6 = PointerUpEvent(
pointer: 6,
position: Offset(11.0, 9.0),
);
testGesture('Recognizes serial taps', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
events.clear();
tester.async.elapse(kSerialTapDelay);
serial.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#2', 'up#2']);
events.clear();
tester.async.elapse(kSerialTapDelay);
serial.addPointer(down3);
tester.closeArena(3);
tester.route(down3);
tester.route(up3);
GestureBinding.instance!.gestureArena.sweep(3);
expect(events, <String>['down#3', 'up#3']);
});
// Because tap gesture will hold off on declaring victory.
testGesture('Wins over tap gesture below it in the tree', (GestureTester tester) {
bool recognizedSingleTap = false;
bool canceledSingleTap = false;
final TapGestureRecognizer singleTap = TapGestureRecognizer()
..onTap = () {
recognizedSingleTap = true;
}
..onTapCancel = () {
canceledSingleTap = true;
};
singleTap.addPointer(down1);
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.async.elapse(kPressTimeout); // To register the possible single tap.
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
expect(recognizedSingleTap, isFalse);
expect(canceledSingleTap, isTrue);
});
testGesture('Wins over tap gesture above it in the tree', (GestureTester tester) {
bool recognizedSingleTap = false;
bool canceledSingleTap = false;
final TapGestureRecognizer singleTap = TapGestureRecognizer()
..onTap = () {
recognizedSingleTap = true;
}
..onTapCancel = () {
canceledSingleTap = true;
};
serial.addPointer(down1);
singleTap.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.async.elapse(kPressTimeout); // To register the possible single tap.
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
expect(recognizedSingleTap, isFalse);
expect(canceledSingleTap, isTrue);
});
testGesture('Loses to release gesture below it in the tree', (GestureTester tester) {
bool recognizedRelease = false;
final ReleaseGestureRecognizer release = ReleaseGestureRecognizer()
..onRelease = () {
recognizedRelease = true;
};
release.addPointer(down1);
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'cancel#1']);
expect(recognizedRelease, isTrue);
});
testGesture('Wins over release gesture above it in the tree', (GestureTester tester) {
bool recognizedRelease = false;
final ReleaseGestureRecognizer release = ReleaseGestureRecognizer()
..onRelease = () {
recognizedRelease = true;
};
serial.addPointer(down1);
release.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
expect(recognizedRelease, isFalse);
});
testGesture('Fires cancel if competing recognizer declares victory', (GestureTester tester) {
final WinningGestureRecognizer winner = WinningGestureRecognizer();
winner.addPointer(down1);
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'cancel#1']);
});
testGesture('Wins over double-tap recognizer below it in the tree', (GestureTester tester) {
bool recognizedDoubleTap = false;
final DoubleTapGestureRecognizer doubleTap = DoubleTapGestureRecognizer()
..onDoubleTap = () {
recognizedDoubleTap = true;
};
doubleTap.addPointer(down1);
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
expect(recognizedDoubleTap, isFalse);
events.clear();
tester.async.elapse(kSerialTapDelay);
doubleTap.addPointer(down2);
serial.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#2', 'up#2']);
expect(recognizedDoubleTap, isFalse);
events.clear();
tester.async.elapse(kSerialTapDelay);
serial.addPointer(down3);
tester.closeArena(3);
tester.route(down3);
tester.route(up3);
GestureBinding.instance!.gestureArena.sweep(3);
expect(events, <String>['down#3', 'up#3']);
});
testGesture('Wins over double-tap recognizer above it in the tree', (GestureTester tester) {
bool recognizedDoubleTap = false;
final DoubleTapGestureRecognizer doubleTap = DoubleTapGestureRecognizer()
..onDoubleTap = () {
recognizedDoubleTap = true;
};
serial.addPointer(down1);
doubleTap.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
expect(recognizedDoubleTap, isFalse);
events.clear();
tester.async.elapse(kSerialTapDelay);
serial.addPointer(down2);
doubleTap.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#2', 'up#2']);
expect(recognizedDoubleTap, isFalse);
events.clear();
tester.async.elapse(kSerialTapDelay);
serial.addPointer(down3);
doubleTap.addPointer(down3);
tester.closeArena(3);
tester.route(down3);
tester.route(up3);
GestureBinding.instance!.gestureArena.sweep(3);
expect(events, <String>['down#3', 'up#3']);
});
testGesture('Fires cancel and resets for PointerCancelEvent', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(cancel1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'cancel#1']);
events.clear();
tester.async.elapse(const Duration(milliseconds: 100));
serial.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#1', 'up#1']);
});
testGesture('Fires cancel and resets when pointer dragged past slop tolerance', (GestureTester tester) {
serial.addPointer(down5);
tester.closeArena(5);
tester.route(down5);
tester.route(move5);
tester.route(up5);
GestureBinding.instance!.gestureArena.sweep(5);
expect(events, <String>['down#1', 'cancel#1']);
events.clear();
tester.async.elapse(const Duration(milliseconds: 1000));
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
});
testGesture('Resets if times out in between taps', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
events.clear();
tester.async.elapse(const Duration(milliseconds: 1000));
serial.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#1', 'up#1']);
});
testGesture('Resets if taps are far apart', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
events.clear();
tester.async.elapse(const Duration(milliseconds: 100));
serial.addPointer(down4);
tester.closeArena(4);
tester.route(down4);
tester.route(up4);
GestureBinding.instance!.gestureArena.sweep(4);
expect(events, <String>['down#1', 'up#1']);
});
testGesture('Serial taps with different buttons will start a new tap sequence', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>['down#1', 'up#1']);
events.clear();
tester.async.elapse(const Duration(milliseconds: 1000));
serial.addPointer(down6);
tester.closeArena(6);
tester.route(down6);
tester.route(up6);
GestureBinding.instance!.gestureArena.sweep(6);
expect(events, <String>['down#1', 'up#1']);
});
testGesture('Interleaving taps cancel first sequence and start second sequence', (GestureTester tester) {
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
serial.addPointer(down2);
tester.closeArena(2);
tester.route(down2);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
tester.route(up2);
GestureBinding.instance!.gestureArena.sweep(2);
expect(events, <String>['down#1', 'cancel#1', 'down#1', 'up#1']);
});
testGesture('Is no-op if no callbacks are specified', (GestureTester tester) {
serial = SerialTapGestureRecognizer();
serial.addPointer(down1);
tester.closeArena(1);
tester.route(down1);
expect(serial.isTrackingPointer, isFalse);
tester.route(up1);
GestureBinding.instance!.gestureArena.sweep(1);
expect(events, <String>[]);
});
testGesture('Works for non-primary button', (GestureTester tester) {
serial.addPointer(down6);
tester.closeArena(6);
tester.route(down6);
tester.route(up6);
GestureBinding.instance!.gestureArena.sweep(6);
expect(events, <String>['down#1', 'up#1']);
});
}
class WinningGestureRecognizer extends PrimaryPointerGestureRecognizer {
@override
String get debugDescription => 'winner';
@override
void handlePrimaryPointer(PointerEvent event) {
resolve(GestureDisposition.accepted);
}
}
class ReleaseGestureRecognizer extends PrimaryPointerGestureRecognizer {
VoidCallback? onRelease;
@override
String get debugDescription => 'release';
@override
void handlePrimaryPointer(PointerEvent event) {
if (event is PointerUpEvent) {
resolve(GestureDisposition.accepted);
if (onRelease != null) {
onRelease!();
}
}
}
}
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