// 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 'package:vector_math/vector_math_64.dart'; import 'arena.dart'; import 'binding.dart'; import 'constants.dart'; import 'events.dart'; import 'gesture_settings.dart'; import 'pointer_router.dart'; import 'recognizer.dart'; import 'tap.dart'; /// 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 }) : assert(duration != null) { 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, }) : assert(doubleTapMinTime != null), assert(event != null), assert(event.buttons != null), 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 of [kPrimaryButton] /// only 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({ Object? debugOwner, @Deprecated( 'Migrate to supportedDevices. ' 'This feature was deprecated after v2.3.0-1.0.pre.', ) PointerDeviceKind? kind, Set<PointerDeviceKind>? supportedDevices, }) : super( debugOwner: debugOwner, kind: kind, supportedDevices: supportedDevices, ); // 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: /// /// * [kPrimaryButton], the button this callback responds to. /// * [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: /// /// * [kPrimaryButton], the button this callback responds to. /// * [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: /// /// * [kPrimaryButton], the button this callback responds to. /// * [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) { switch (event.buttons) { case kPrimaryButton: if (onDoubleTapDown == null && onDoubleTap == null && onDoubleTapCancel == null) return false; break; default: return false; } } return super.isPointerAllowed(event); } @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) { assert(buttons == kPrimaryButton); 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 DeviceGestureSettings? gestureSettings, }) : _lastPosition = OffsetPair.fromEventPosition(event), super( event: event as PointerDownEvent, entry: GestureBinding.instance.gestureArena.add(event.pointer, gestureRecognizer), doubleTapMinTime: kDoubleTapMinTime, gestureSettings: gestureSettings, ) { 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, Object? debugOwner, @Deprecated( 'Migrate to supportedDevices. ' 'This feature was deprecated after v2.3.0-1.0.pre.', ) PointerDeviceKind? kind, Set<PointerDeviceKind>? supportedDevices, }) : super( debugOwner: debugOwner, kind: kind, supportedDevices: supportedDevices, ); /// 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 != 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( 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'; }