blob: 9721ebed3683b15b8de3438125dcd5e470d16ee6 [file] [log] [blame]
// 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 'dart:async';
import 'arena.dart';
import 'binding.dart';
import 'constants.dart';
import 'events.dart';
import 'pointer_router.dart';
import 'recognizer.dart';
import 'tap.dart';
export 'dart:ui' show Offset, PointerDeviceKind;
export 'events.dart' show PointerDownEvent;
export 'tap.dart' show GestureTapCancelCallback, GestureTapDownCallback, TapDownDetails, TapUpDetails;
/// Signature for callback when the user has tapped the screen at the same
/// location twice in quick succession.
///
/// See also:
///
/// * [GestureDetector.onDoubleTap], which matches this signature.
typedef GestureDoubleTapCallback = void Function();
/// Signature used by [MultiTapGestureRecognizer] for when a pointer that might
/// cause a tap has contacted the screen at a particular location.
typedef GestureMultiTapDownCallback = void Function(int pointer, TapDownDetails details);
/// Signature used by [MultiTapGestureRecognizer] for when a pointer that will
/// trigger a tap has stopped contacting the screen at a particular location.
typedef GestureMultiTapUpCallback = void Function(int pointer, TapUpDetails details);
/// Signature used by [MultiTapGestureRecognizer] for when a tap has occurred.
typedef GestureMultiTapCallback = void Function(int pointer);
/// Signature for when the pointer that previously triggered a
/// [GestureMultiTapDownCallback] will not end up causing a tap.
typedef GestureMultiTapCancelCallback = void Function(int pointer);
/// CountdownZoned tracks whether the specified duration has elapsed since
/// creation, honoring [Zone].
class _CountdownZoned {
_CountdownZoned({ required Duration duration }) {
Timer(duration, _onTimeout);
}
bool _timeout = false;
bool get timeout => _timeout;
void _onTimeout() {
_timeout = true;
}
}
/// TapTracker helps track individual tap sequences as part of a
/// larger gesture.
class _TapTracker {
_TapTracker({
required PointerDownEvent event,
required this.entry,
required Duration doubleTapMinTime,
required this.gestureSettings,
}) : pointer = event.pointer,
_initialGlobalPosition = event.position,
initialButtons = event.buttons,
_doubleTapMinTimeCountdown = _CountdownZoned(duration: doubleTapMinTime);
final DeviceGestureSettings? gestureSettings;
final int pointer;
final GestureArenaEntry entry;
final Offset _initialGlobalPosition;
final int initialButtons;
final _CountdownZoned _doubleTapMinTimeCountdown;
bool _isTrackingPointer = false;
void startTrackingPointer(PointerRoute route, Matrix4? transform) {
if (!_isTrackingPointer) {
_isTrackingPointer = true;
GestureBinding.instance.pointerRouter.addRoute(pointer, route, transform);
}
}
void stopTrackingPointer(PointerRoute route) {
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance.pointerRouter.removeRoute(pointer, route);
}
}
bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
final Offset offset = event.position - _initialGlobalPosition;
return offset.distance <= tolerance;
}
bool hasElapsedMinTime() {
return _doubleTapMinTimeCountdown.timeout;
}
bool hasSameButton(PointerDownEvent event) {
return event.buttons == initialButtons;
}
}
/// Recognizes when the user has tapped the screen at the same location twice in
/// quick succession.
///
/// [DoubleTapGestureRecognizer] competes on pointer events when it
/// has a non-null callback. If it has no callbacks, it is a no-op.
///
class DoubleTapGestureRecognizer extends GestureRecognizer {
/// Create a gesture recognizer for double taps.
///
/// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
DoubleTapGestureRecognizer({
super.debugOwner,
@Deprecated(
'Migrate to supportedDevices. '
'This feature was deprecated after v2.3.0-1.0.pre.',
)
super.kind,
super.supportedDevices,
super.allowedButtonsFilter = _defaultButtonAcceptBehavior,
});
// The default value for [allowedButtonsFilter].
// Accept the input if, and only if, [kPrimaryButton] is pressed.
static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton;
// Implementation notes:
//
// The double tap recognizer can be in one of four states. There's no
// explicit enum for the states, because they are already captured by
// the state of existing fields. Specifically:
//
// 1. Waiting on first tap: In this state, the _trackers list is empty, and
// _firstTap is null.
// 2. First tap in progress: In this state, the _trackers list contains all
// the states for taps that have begun but not completed. This list can
// have more than one entry if two pointers begin to tap.
// 3. Waiting on second tap: In this state, one of the in-progress taps has
// completed successfully. The _trackers list is again empty, and
// _firstTap records the successful tap.
// 4. Second tap in progress: Much like the "first tap in progress" state, but
// _firstTap is non-null. If a tap completes successfully while in this
// state, the callback is called and the state is reset.
//
// There are various other scenarios that cause the state to reset:
//
// - All in-progress taps are rejected (by time, distance, pointercancel, etc)
// - The long timer between taps expires
// - The gesture arena decides we have been rejected wholesale
/// A pointer has contacted the screen with a primary button at the same
/// location twice in quick succession, which might be the start of a double
/// tap.
///
/// This triggers immediately after the down event of the second tap.
///
/// If this recognizer doesn't win the arena, [onDoubleTapCancel] is called
/// next. Otherwise, [onDoubleTap] is called next.
///
/// See also:
///
/// * [allowedButtonsFilter], which decides which button will be allowed.
/// * [TapDownDetails], which is passed as an argument to this callback.
/// * [GestureDetector.onDoubleTapDown], which exposes this callback.
GestureTapDownCallback? onDoubleTapDown;
/// Called when the user has tapped the screen with a primary button at the
/// same location twice in quick succession.
///
/// This triggers when the pointer stops contacting the device after the
/// second tap.
///
/// See also:
///
/// * [allowedButtonsFilter], which decides which button will be allowed.
/// * [GestureDetector.onDoubleTap], which exposes this callback.
GestureDoubleTapCallback? onDoubleTap;
/// A pointer that previously triggered [onDoubleTapDown] will not end up
/// causing a double tap.
///
/// This triggers once the gesture loses the arena if [onDoubleTapDown] has
/// previously been triggered.
///
/// If this recognizer wins the arena, [onDoubleTap] is called instead.
///
/// See also:
///
/// * [allowedButtonsFilter], which decides which button will be allowed.
/// * [GestureDetector.onDoubleTapCancel], which exposes this callback.
GestureTapCancelCallback? onDoubleTapCancel;
Timer? _doubleTapTimer;
_TapTracker? _firstTap;
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_firstTap == null) {
if (onDoubleTapDown == null &&
onDoubleTap == null &&
onDoubleTapCancel == null) {
return false;
}
}
// If second tap is not allowed, reset the state.
final bool isPointerAllowed = super.isPointerAllowed(event);
if (isPointerAllowed == false) {
_reset();
}
return isPointerAllowed;
}
@override
void addAllowedPointer(PointerDownEvent event) {
if (_firstTap != null) {
if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
// Ignore out-of-bounds second taps.
return;
} else if (!_firstTap!.hasElapsedMinTime() || !_firstTap!.hasSameButton(event)) {
// Restart when the second tap is too close to the first (touch screens
// often detect touches intermittently), or when buttons mismatch.
_reset();
return _trackTap(event);
} else if (onDoubleTapDown != null) {
final TapDownDetails details = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
);
invokeCallback<void>('onDoubleTapDown', () => onDoubleTapDown!(details));
}
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
_stopDoubleTapTimer();
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
gestureSettings: gestureSettings,
);
_trackers[event.pointer] = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
if (_firstTap == null) {
_registerFirstTap(tracker);
} else {
_registerSecondTap(tracker);
}
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
_reject(tracker);
}
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
@override
void acceptGesture(int pointer) { }
@override
void rejectGesture(int pointer) {
_TapTracker? tracker = _trackers[pointer];
// If tracker isn't in the list, check if this is the first tap tracker
if (tracker == null &&
_firstTap != null &&
_firstTap!.pointer == pointer) {
tracker = _firstTap;
}
// If tracker is still null, we rejected ourselves already
if (tracker != null) {
_reject(tracker);
}
}
void _reject(_TapTracker tracker) {
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
if (_firstTap != null) {
if (tracker == _firstTap) {
_reset();
} else {
_checkCancel();
if (_trackers.isEmpty) {
_reset();
}
}
}
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
_stopDoubleTapTimer();
if (_firstTap != null) {
if (_trackers.isNotEmpty) {
_checkCancel();
}
// Note, order is important below in order for the resolve -> reject logic
// to work properly.
final _TapTracker tracker = _firstTap!;
_firstTap = null;
_reject(tracker);
GestureBinding.instance.gestureArena.release(tracker.pointer);
}
_clearTrackers();
}
void _registerFirstTap(_TapTracker tracker) {
_startDoubleTapTimer();
GestureBinding.instance.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_clearTrackers();
_firstTap = tracker;
}
void _registerSecondTap(_TapTracker tracker) {
_firstTap!.entry.resolve(GestureDisposition.accepted);
tracker.entry.resolve(GestureDisposition.accepted);
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_checkUp(tracker.initialButtons);
_reset();
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startDoubleTapTimer() {
_doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
}
void _stopDoubleTapTimer() {
if (_doubleTapTimer != null) {
_doubleTapTimer!.cancel();
_doubleTapTimer = null;
}
}
void _checkUp(int buttons) {
if (onDoubleTap != null) {
invokeCallback<void>('onDoubleTap', onDoubleTap!);
}
}
void _checkCancel() {
if (onDoubleTapCancel != null) {
invokeCallback<void>('onDoubleTapCancel', onDoubleTapCancel!);
}
}
@override
String get debugDescription => 'double tap';
}
/// TapGesture represents a full gesture resulting from a single tap sequence,
/// as part of a [MultiTapGestureRecognizer]. Tap gestures are passive, meaning
/// that they will not preempt any other arena member in play.
class _TapGesture extends _TapTracker {
_TapGesture({
required this.gestureRecognizer,
required PointerEvent event,
required Duration longTapDelay,
required super.gestureSettings,
}) : _lastPosition = OffsetPair.fromEventPosition(event),
super(
event: event as PointerDownEvent,
entry: GestureBinding.instance.gestureArena.add(event.pointer, gestureRecognizer),
doubleTapMinTime: kDoubleTapMinTime,
) {
startTrackingPointer(handleEvent, event.transform);
if (longTapDelay > Duration.zero) {
_timer = Timer(longTapDelay, () {
_timer = null;
gestureRecognizer._dispatchLongTap(event.pointer, _lastPosition);
});
}
}
final MultiTapGestureRecognizer gestureRecognizer;
bool _wonArena = false;
Timer? _timer;
OffsetPair _lastPosition;
OffsetPair? _finalPosition;
void handleEvent(PointerEvent event) {
assert(event.pointer == pointer);
if (event is PointerMoveEvent) {
if (!isWithinGlobalTolerance(event, computeHitSlop(event.kind, gestureSettings))) {
cancel();
} else {
_lastPosition = OffsetPair.fromEventPosition(event);
}
} else if (event is PointerCancelEvent) {
cancel();
} else if (event is PointerUpEvent) {
stopTrackingPointer(handleEvent);
_finalPosition = OffsetPair.fromEventPosition(event);
_check();
}
}
@override
void stopTrackingPointer(PointerRoute route) {
_timer?.cancel();
_timer = null;
super.stopTrackingPointer(route);
}
void accept() {
_wonArena = true;
_check();
}
void reject() {
stopTrackingPointer(handleEvent);
gestureRecognizer._dispatchCancel(pointer);
}
void cancel() {
// If we won the arena already, then entry is resolved, so resolving
// again is a no-op. But we still need to clean up our own state.
if (_wonArena) {
reject();
} else {
entry.resolve(GestureDisposition.rejected); // eventually calls reject()
}
}
void _check() {
if (_wonArena && _finalPosition != null) {
gestureRecognizer._dispatchTap(pointer, _finalPosition!);
}
}
}
/// Recognizes taps on a per-pointer basis.
///
/// [MultiTapGestureRecognizer] considers each sequence of pointer events that
/// could constitute a tap independently of other pointers: For example, down-1,
/// down-2, up-1, up-2 produces two taps, on up-1 and up-2.
///
/// See also:
///
/// * [TapGestureRecognizer]
class MultiTapGestureRecognizer extends GestureRecognizer {
/// Creates a multi-tap gesture recognizer.
///
/// The [longTapDelay] defaults to [Duration.zero], which means
/// [onLongTapDown] is called immediately after [onTapDown].
///
/// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
MultiTapGestureRecognizer({
this.longTapDelay = Duration.zero,
super.debugOwner,
@Deprecated(
'Migrate to supportedDevices. '
'This feature was deprecated after v2.3.0-1.0.pre.',
)
super.kind,
super.supportedDevices,
super.allowedButtonsFilter,
});
/// A pointer that might cause a tap has contacted the screen at a particular
/// location.
GestureMultiTapDownCallback? onTapDown;
/// A pointer that will trigger a tap has stopped contacting the screen at a
/// particular location.
GestureMultiTapUpCallback? onTapUp;
/// A tap has occurred.
GestureMultiTapCallback? onTap;
/// The pointer that previously triggered [onTapDown] will not end up causing
/// a tap.
GestureMultiTapCancelCallback? onTapCancel;
/// The amount of time between [onTapDown] and [onLongTapDown].
Duration longTapDelay;
/// A pointer that might cause a tap is still in contact with the screen at a
/// particular location after [longTapDelay].
GestureMultiTapDownCallback? onLongTapDown;
final Map<int, _TapGesture> _gestureMap = <int, _TapGesture>{};
@override
void addAllowedPointer(PointerDownEvent event) {
assert(!_gestureMap.containsKey(event.pointer));
_gestureMap[event.pointer] = _TapGesture(
gestureRecognizer: this,
event: event,
longTapDelay: longTapDelay,
gestureSettings: gestureSettings,
);
if (onTapDown != null) {
invokeCallback<void>('onTapDown', () {
onTapDown!(event.pointer, TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: event.kind,
));
});
}
}
@override
void acceptGesture(int pointer) {
assert(_gestureMap.containsKey(pointer));
_gestureMap[pointer]!.accept();
}
@override
void rejectGesture(int pointer) {
assert(_gestureMap.containsKey(pointer));
_gestureMap[pointer]!.reject();
assert(!_gestureMap.containsKey(pointer));
}
void _dispatchCancel(int pointer) {
assert(_gestureMap.containsKey(pointer));
_gestureMap.remove(pointer);
if (onTapCancel != null) {
invokeCallback<void>('onTapCancel', () => onTapCancel!(pointer));
}
}
void _dispatchTap(int pointer, OffsetPair position) {
assert(_gestureMap.containsKey(pointer));
_gestureMap.remove(pointer);
if (onTapUp != null) {
invokeCallback<void>('onTapUp', () {
onTapUp!(pointer, TapUpDetails(
kind: getKindForPointer(pointer),
localPosition: position.local,
globalPosition: position.global,
));
});
}
if (onTap != null) {
invokeCallback<void>('onTap', () => onTap!(pointer));
}
}
void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
assert(_gestureMap.containsKey(pointer));
if (onLongTapDown != null) {
invokeCallback<void>('onLongTapDown', () {
onLongTapDown!(
pointer,
TapDownDetails(
globalPosition: lastPosition.global,
localPosition: lastPosition.local,
kind: getKindForPointer(pointer),
),
);
});
}
}
@override
void dispose() {
final List<_TapGesture> localGestures = List<_TapGesture>.of(_gestureMap.values);
for (final _TapGesture gesture in localGestures) {
gesture.cancel();
}
// Rejection of each gesture should cause it to be removed from our map
assert(_gestureMap.isEmpty);
super.dispose();
}
@override
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 > 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({
super.debugOwner,
super.supportedDevices,
super.allowedButtonsFilter,
});
/// 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(
gestureSettings: gestureSettings,
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';
}