diff --git a/packages/flutter/lib/src/gestures/eager.dart b/packages/flutter/lib/src/gestures/eager.dart index 245718773f04..76d2b3005818 100644 --- a/packages/flutter/lib/src/gestures/eager.dart +++ b/packages/flutter/lib/src/gestures/eager.dart @@ -24,6 +24,7 @@ class EagerGestureRecognizer extends OneSequenceGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override diff --git a/packages/flutter/lib/src/gestures/force_press.dart b/packages/flutter/lib/src/gestures/force_press.dart index 956b8f0c52bc..f1e27b0e9c29 100644 --- a/packages/flutter/lib/src/gestures/force_press.dart +++ b/packages/flutter/lib/src/gestures/force_press.dart @@ -133,6 +133,7 @@ class ForcePressGestureRecognizer extends OneSequenceGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }) : assert(startPressure != null), assert(peakPressure != null), assert(interpolation != null), diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index 414c2dcebba2..30bf899adca6 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -247,6 +247,8 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// The [duration] argument can be used to overwrite the default duration /// after which the long press will be recognized. /// + /// {@macro flutter.gestures.tap.TapGestureRecognizer.allowedButtonsFilter} + /// /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} LongPressGestureRecognizer({ Duration? duration, @@ -258,6 +260,7 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { super.kind, super.supportedDevices, super.debugOwner, + super.allowedButtonsFilter = _defaultButtonAcceptBehavior, }) : super( deadline: duration ?? kLongPressTimeout, ); @@ -268,6 +271,12 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { // different set of buttons, the gesture is canceled. int? _initialButtons; + // Accept the input if, and only if, a single button is pressed. + static bool _defaultButtonAcceptBehavior(int buttons) => + buttons == kPrimaryButton || + buttons == kSecondaryButton || + buttons == kTertiaryButton; + /// Called when a pointer has contacted the screen at a particular location /// with a primary button, which might be the start of a long-press. /// diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 500d296b2053..4718c711f0ee 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -59,9 +59,8 @@ typedef GestureVelocityTrackerBuilder = VelocityTracker Function(PointerEvent ev /// consider using one of its subclasses to recognize specific types for drag /// gestures. /// -/// [DragGestureRecognizer] competes on pointer events of [kPrimaryButton] -/// only when it has at least one non-null callback. If it has no callbacks, it -/// is a no-op. +/// [DragGestureRecognizer] competes on pointer events only when it has at +/// least one non-null callback. If it has no callbacks, it is a no-op. /// /// See also: /// @@ -84,10 +83,14 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { this.dragStartBehavior = DragStartBehavior.start, this.velocityTrackerBuilder = _defaultBuilder, super.supportedDevices, + super.allowedButtonsFilter = _defaultButtonAcceptBehavior, }) : assert(dragStartBehavior != null); static VelocityTracker _defaultBuilder(PointerEvent event) => VelocityTracker.withKind(event.kind); + // Accept the input if, and only if, [kPrimaryButton] is pressed. + static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton; + /// Configure the behavior of offsets passed to [onStart]. /// /// If set to [DragStartBehavior.start], the [onStart] callback will be called @@ -122,7 +125,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [DragDownDetails], which is passed as an argument to this callback. GestureDragDownCallback? onDown; @@ -137,7 +140,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [DragStartDetails], which is passed as an argument to this callback. GestureDragStartCallback? onStart; @@ -151,7 +154,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [DragUpdateDetails], which is passed as an argument to this callback. GestureDragUpdateCallback? onUpdate; @@ -166,7 +169,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [DragEndDetails], which is passed as an argument to this callback. GestureDragEndCallback? onEnd; @@ -174,7 +177,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. GestureDragCancelCallback? onCancel; /// The minimum distance an input pointer drag must have moved to @@ -251,18 +254,12 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { @override bool isPointerAllowed(PointerEvent event) { if (_initialButtons == null) { - switch (event.buttons) { - case kPrimaryButton: - if (onDown == null && - onStart == null && - onUpdate == null && - onEnd == null && - onCancel == null) { - return false; - } - break; - default: - return false; + if (onDown == null && + onStart == null && + onUpdate == null && + onEnd == null && + onCancel == null) { + return false; } } else { // There can be multiple drags simultaneously. Their effects are combined. @@ -449,7 +446,6 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } void _checkDown() { - assert(_initialButtons == kPrimaryButton); if (onDown != null) { final DragDownDetails details = DragDownDetails( globalPosition: _initialPosition.global, @@ -460,7 +456,6 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } void _checkStart(Duration timestamp, int pointer) { - assert(_initialButtons == kPrimaryButton); if (onStart != null) { final DragStartDetails details = DragStartDetails( sourceTimeStamp: timestamp, @@ -479,7 +474,6 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { required Offset globalPosition, Offset? localPosition, }) { - assert(_initialButtons == kPrimaryButton); if (onUpdate != null) { final DragUpdateDetails details = DragUpdateDetails( sourceTimeStamp: sourceTimeStamp, @@ -493,7 +487,6 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } void _checkEnd(int pointer) { - assert(_initialButtons == kPrimaryButton); if (onEnd == null) { return; } @@ -530,7 +523,6 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } void _checkCancel() { - assert(_initialButtons == kPrimaryButton); if (onCancel != null) { invokeCallback('onCancel', onCancel!); } @@ -570,6 +562,7 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override @@ -616,6 +609,7 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override @@ -654,6 +648,7 @@ class PanGestureRecognizer extends DragGestureRecognizer { PanGestureRecognizer({ super.debugOwner, super.supportedDevices, + super.allowedButtonsFilter, }); @override diff --git a/packages/flutter/lib/src/gestures/multidrag.dart b/packages/flutter/lib/src/gestures/multidrag.dart index 2ad89dbc055f..36f8b77cebb4 100644 --- a/packages/flutter/lib/src/gestures/multidrag.dart +++ b/packages/flutter/lib/src/gestures/multidrag.dart @@ -223,8 +223,12 @@ abstract class MultiDragGestureRecognizer extends GestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter = _defaultButtonAcceptBehavior, }); + // Accept the input if, and only if, [kPrimaryButton] is pressed. + static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton; + /// Called when this class recognizes the start of a drag gesture. /// /// The remaining notifications for this drag gesture are delivered to the @@ -382,6 +386,7 @@ class ImmediateMultiDragGestureRecognizer extends MultiDragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override @@ -439,6 +444,7 @@ class HorizontalMultiDragGestureRecognizer extends MultiDragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override @@ -496,6 +502,7 @@ class VerticalMultiDragGestureRecognizer extends MultiDragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); @override @@ -606,6 +613,7 @@ class DelayedMultiDragGestureRecognizer extends MultiDragGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }) : assert(delay != null); /// The amount of time the pointer must remain in the same place for the drag diff --git a/packages/flutter/lib/src/gestures/multitap.dart b/packages/flutter/lib/src/gestures/multitap.dart index 8c79dfc91c94..e16821891e72 100644 --- a/packages/flutter/lib/src/gestures/multitap.dart +++ b/packages/flutter/lib/src/gestures/multitap.dart @@ -113,8 +113,8 @@ class _TapTracker { /// 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. +/// [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. @@ -128,8 +128,13 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { ) 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 @@ -165,7 +170,7 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [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; @@ -178,7 +183,7 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [GestureDetector.onDoubleTap], which exposes this callback. GestureDoubleTapCallback? onDoubleTap; @@ -192,7 +197,7 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { /// /// See also: /// - /// * [kPrimaryButton], the button this callback responds to. + /// * [allowedButtonsFilter], which decides which button will be allowed. /// * [GestureDetector.onDoubleTapCancel], which exposes this callback. GestureTapCancelCallback? onDoubleTapCancel; @@ -203,19 +208,19 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { @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; + if (onDoubleTapDown == null && + onDoubleTap == null && + onDoubleTapCancel == null) { + return false; } } - return super.isPointerAllowed(event); + + // If second tap is not allowed, reset the state. + final bool isPointerAllowed = super.isPointerAllowed(event); + if (isPointerAllowed == false) { + _reset(); + } + return isPointerAllowed; } @override @@ -367,7 +372,6 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { } void _checkUp(int buttons) { - assert(buttons == kPrimaryButton); if (onDoubleTap != null) { invokeCallback('onDoubleTap', onDoubleTap!); } @@ -492,6 +496,7 @@ class MultiTapGestureRecognizer extends GestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); /// A pointer that might cause a tap has contacted the screen at a particular @@ -813,6 +818,7 @@ class SerialTapGestureRecognizer extends GestureRecognizer { SerialTapGestureRecognizer({ super.debugOwner, super.supportedDevices, + super.allowedButtonsFilter, }); /// A pointer has contacted the screen at a particular location, which might diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index f7e63f033351..37490d69d492 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -48,6 +48,11 @@ enum DragStartBehavior { start, } +/// Signature for `allowedButtonsFilter` in [GestureRecognizer]. +/// Used to filter the input buttons of incoming pointer events. +/// The parameter `buttons` comes from [PointerEvent.buttons]. +typedef AllowedButtonsFilter = bool Function(int buttons); + /// The base class that all gesture recognizers inherit from. /// /// Provides a basic API that can be used by classes that work with @@ -79,8 +84,10 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT ) PointerDeviceKind? kind, Set? supportedDevices, + AllowedButtonsFilter? allowedButtonsFilter, }) : assert(kind == null || supportedDevices == null), - _supportedDevices = kind == null ? supportedDevices : { kind }; + _supportedDevices = kind == null ? supportedDevices : { kind }, + _allowedButtonsFilter = allowedButtonsFilter ?? _defaultButtonAcceptBehavior; /// The recognizer's owner. /// @@ -98,6 +105,29 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// tracked and recognized. final Set? _supportedDevices; + /// {@template flutter.gestures.multidrag._allowedButtonsFilter} + /// Called when interaction starts. This limits the dragging behavior + /// for custom clicks (such as scroll click). Its parameter comes + /// from [PointerEvent.buttons]. + /// + /// Due to how [kPrimaryButton], [kSecondaryButton], etc., use integers, + /// bitwise operations can help filter how buttons are pressed. + /// For example, if someone simultaneously presses the primary and secondary + /// buttons, the default behavior will return false. The following code + /// accepts any button press with primary: + /// `(int buttons) => buttons & kPrimaryButton != 0`. + /// + /// When value is `(int buttons) => false`, allow no interactions. + /// When value is `(int buttons) => true`, allow all interactions. + /// + /// Defaults to all buttons. + /// {@endtemplate} + final AllowedButtonsFilter _allowedButtonsFilter; + + // The default value for [allowedButtonsFilter]. + // Accept any input. + static bool _defaultButtonAcceptBehavior(int buttons) => true; + /// Holds a mapping between pointer IDs and the kind of devices they are /// coming from. final Map _pointerToKind = {}; @@ -185,9 +215,9 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// Checks whether or not a pointer is allowed to be tracked by this recognizer. @protected bool isPointerAllowed(PointerDownEvent event) { - // Currently, it only checks for device kind. But in the future we could check - // for other things e.g. mouse button. - return _supportedDevices == null || _supportedDevices!.contains(event.kind); + return (_supportedDevices == null || + _supportedDevices!.contains(event.kind)) && + _allowedButtonsFilter(event.buttons); } /// Handles a pointer pan/zoom being added that's not allowed by this recognizer. @@ -298,6 +328,7 @@ abstract class OneSequenceGestureRecognizer extends GestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }); final Map _entries = {}; @@ -511,6 +542,7 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, }) : assert( preAcceptSlopTolerance == null || preAcceptSlopTolerance >= 0, 'The preAcceptSlopTolerance must be positive or null', diff --git a/packages/flutter/lib/src/gestures/scale.dart b/packages/flutter/lib/src/gestures/scale.dart index 6655ee6e9ed7..06aeae3d31ca 100644 --- a/packages/flutter/lib/src/gestures/scale.dart +++ b/packages/flutter/lib/src/gestures/scale.dart @@ -334,6 +334,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { ) super.kind, super.supportedDevices, + super.allowedButtonsFilter, this.dragStartBehavior = DragStartBehavior.down, this.trackpadScrollCausesScale = false, this.trackpadScrollToScaleFactor = kDefaultTrackpadScrollToScaleFactor, diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index c2ce8aecda50..9ffeabc90d78 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -149,7 +149,11 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer /// Creates a tap gesture recognizer. /// /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} - BaseTapGestureRecognizer({ super.debugOwner, super.supportedDevices }) + BaseTapGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + }) : super(deadline: kPressTimeout); bool _sentTapDown = false; @@ -354,6 +358,16 @@ abstract class BaseTapGestureRecognizer extends PrimaryPointerGestureRecognizer /// one non-null `onTertiaryTap*` callback. If it has no callbacks, it is a /// no-op. /// +/// {@template flutter.gestures.tap.TapGestureRecognizer.allowedButtonsFilter} +/// The [allowedButtonsFilter] argument only gives this recognizer the +/// ability to limit the buttons it accepts. It does not provide the +/// ability to recognize any buttons beyond the ones it already accepts: +/// kPrimaryButton, kSecondaryButton or kTertiaryButton. Therefore, a +/// combined value of `kPrimaryButton & kSecondaryButton` would be ignored, +/// but `kPrimaryButton | kSecondaryButton` would be allowed, as long as +/// only one of them is selected at a time. +/// {@endtemplate} +/// /// See also: /// /// * [GestureDetector.onTap], which uses this recognizer. @@ -362,7 +376,11 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// Creates a tap gesture recognizer. /// /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} - TapGestureRecognizer({ super.debugOwner, super.supportedDevices }); + TapGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + }); /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapDown} /// A pointer has contacted the screen at a particular location with a primary diff --git a/packages/flutter/lib/src/widgets/drag_target.dart b/packages/flutter/lib/src/widgets/drag_target.dart index b3c280a23d9f..a639402d39ac 100644 --- a/packages/flutter/lib/src/widgets/drag_target.dart +++ b/packages/flutter/lib/src/widgets/drag_target.dart @@ -179,6 +179,7 @@ class Draggable extends StatefulWidget { this.ignoringFeedbackPointer = true, this.rootOverlay = false, this.hitTestBehavior = HitTestBehavior.deferToChild, + this.allowedButtonsFilter, }) : assert(child != null), assert(feedback != null), assert(ignoringFeedbackSemantics != null), @@ -359,6 +360,9 @@ class Draggable extends StatefulWidget { /// Defaults to [HitTestBehavior.deferToChild]. final HitTestBehavior hitTestBehavior; + /// {@macro flutter.gestures.multidrag._allowedButtonsFilter} + final AllowedButtonsFilter? allowedButtonsFilter; + /// Creates a gesture recognizer that recognizes the start of the drag. /// /// Subclasses can override this function to customize when they start @@ -367,11 +371,11 @@ class Draggable extends StatefulWidget { MultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart) { switch (affinity) { case Axis.horizontal: - return HorizontalMultiDragGestureRecognizer()..onStart = onStart; + return HorizontalMultiDragGestureRecognizer(allowedButtonsFilter: allowedButtonsFilter)..onStart = onStart; case Axis.vertical: - return VerticalMultiDragGestureRecognizer()..onStart = onStart; + return VerticalMultiDragGestureRecognizer(allowedButtonsFilter: allowedButtonsFilter)..onStart = onStart; case null: - return ImmediateMultiDragGestureRecognizer()..onStart = onStart; + return ImmediateMultiDragGestureRecognizer(allowedButtonsFilter: allowedButtonsFilter)..onStart = onStart; } } @@ -409,6 +413,7 @@ class LongPressDraggable extends Draggable { super.ignoringFeedbackSemantics, super.ignoringFeedbackPointer, this.delay = kLongPressTimeout, + super.allowedButtonsFilter, }); /// Whether haptic feedback should be triggered on drag start. @@ -421,7 +426,7 @@ class LongPressDraggable extends Draggable { @override DelayedMultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart) { - return DelayedMultiDragGestureRecognizer(delay: delay) + return DelayedMultiDragGestureRecognizer(delay: delay, allowedButtonsFilter: allowedButtonsFilter) ..onStart = (Offset position) { final Drag? result = onStart(position); if (result != null && hapticFeedbackOnStart) { diff --git a/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart b/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart index 6641fc5490ed..06fd8d286f41 100644 --- a/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart +++ b/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart @@ -702,6 +702,7 @@ class TapAndDragGestureRecognizer extends OneSequenceGestureRecognizer with _Tap super.debugOwner, super.kind, super.supportedDevices, + super.allowedButtonsFilter, }) : _deadline = kPressTimeout, dragStartBehavior = DragStartBehavior.start, slopTolerance = kTouchSlop; diff --git a/packages/flutter/test/gestures/double_tap_test.dart b/packages/flutter/test/gestures/double_tap_test.dart index 38bb092702c5..3aad775e3076 100644 --- a/packages/flutter/test/gestures/double_tap_test.dart +++ b/packages/flutter/test/gestures/double_tap_test.dart @@ -152,6 +152,54 @@ void main() { expect(doubleTapCanceled, isFalse); }); + testGesture('Should recognize double tap with secondaryButton', (GestureTester tester) { + final DoubleTapGestureRecognizer tapSecondary = DoubleTapGestureRecognizer( + allowedButtonsFilter: (int buttons) => buttons == kSecondaryButton, + ); + tapSecondary.onDoubleTap = () { + doubleTapRecognized = true; + }; + tapSecondary.onDoubleTapDown = (TapDownDetails details) { + doubleTapDownDetails = details; + }; + tapSecondary.onDoubleTapCancel = () { + doubleTapCanceled = true; + }; + + // Down/up pair 7: normal tap sequence close to pair 6 + const PointerDownEvent down7 = PointerDownEvent( + pointer: 7, + position: Offset(10.0, 10.0), + buttons: kSecondaryMouseButton, + ); + + const PointerUpEvent up7 = PointerUpEvent( + pointer: 7, + position: Offset(11.0, 9.0), + ); + + tapSecondary.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + GestureBinding.instance.gestureArena.sweep(6); + expect(doubleTapDownDetails, isNull); + + tester.async.elapse(const Duration(milliseconds: 100)); + tapSecondary.addPointer(down7); + tester.closeArena(7); + expect(doubleTapDownDetails, isNotNull); + expect(doubleTapDownDetails!.globalPosition, down7.position); + expect(doubleTapDownDetails!.localPosition, down7.localPosition); + tester.route(down7); + expect(doubleTapRecognized, isFalse); + + tester.route(up7); + expect(doubleTapRecognized, isTrue); + GestureBinding.instance.gestureArena.sweep(2); + expect(doubleTapCanceled, isFalse); + }); + testGesture('Inter-tap distance cancels double tap', (GestureTester tester) { tap.addPointer(down1); tester.closeArena(1); @@ -493,6 +541,56 @@ void main() { expect(doubleTapCanceled, isFalse); }); + testGesture('Button change with allowedButtonsFilter should interrupt existing sequence', (GestureTester tester) { + final DoubleTapGestureRecognizer tapPrimary = DoubleTapGestureRecognizer( + allowedButtonsFilter: (int buttons) => buttons == kPrimaryButton, + ); + tapPrimary.onDoubleTap = () { + doubleTapRecognized = true; + }; + tapPrimary.onDoubleTapDown = (TapDownDetails details) { + doubleTapDownDetails = details; + }; + tapPrimary.onDoubleTapCancel = () { + doubleTapCanceled = true; + }; + + // Down1 -> down6 (different button from 1) -> down2 (same button as 1) + // Down1 and down2 could've been a double tap, but is interrupted by down 6. + // Down6 gets ignored because it's not a primary button. Regardless, the state + // is reset. + const Duration interval = Duration(milliseconds: 100); + assert(interval * 2 < kDoubleTapTimeout); + assert(interval > kDoubleTapMinTime); + + tapPrimary.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + + tester.async.elapse(interval); + + tapPrimary.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + GestureBinding.instance.gestureArena.sweep(6); + + tester.async.elapse(interval); + expect(doubleTapRecognized, isFalse); + + tapPrimary.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + expect(doubleTapRecognized, isFalse); + expect(doubleTapDownDetails, isNull); + expect(doubleTapCanceled, isFalse); + }); + testGesture('Button change should start a valid sequence', (GestureTester tester) { // Down6 -> down1 (different button from 6) -> down2 (same button as 1) @@ -624,6 +722,44 @@ void main() { doubleTap.dispose(); }); + testGesture('Buttons filter should cancel invalid taps', (GestureTester tester) { + final List recognized = []; + final DoubleTapGestureRecognizer doubleTap = DoubleTapGestureRecognizer( + allowedButtonsFilter: (int buttons) => false, + ) + ..onDoubleTap = () { + recognized.add('primary'); + }; + + // Down/up pair 7: normal tap sequence close to pair 6 + const PointerDownEvent down7 = PointerDownEvent( + pointer: 7, + position: Offset(10.0, 10.0), + ); + + const PointerUpEvent up7 = PointerUpEvent( + pointer: 7, + position: Offset(11.0, 9.0), + ); + + doubleTap.addPointer(down7); + tester.closeArena(7); + tester.route(down7); + tester.route(up7); + GestureBinding.instance.gestureArena.sweep(7); + + tester.async.elapse(const Duration(milliseconds: 100)); + doubleTap.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + + expect(recognized, []); + + recognized.clear(); + doubleTap.dispose(); + }); + // Regression test for https://github.com/flutter/flutter/issues/73667 testGesture('Unfinished DoubleTap does not prevent competing Tap', (GestureTester tester) { int tapCount = 0; diff --git a/packages/flutter/test/gestures/monodrag_test.dart b/packages/flutter/test/gestures/monodrag_test.dart index 42057341f99e..06864e971120 100644 --- a/packages/flutter/test/gestures/monodrag_test.dart +++ b/packages/flutter/test/gestures/monodrag_test.dart @@ -78,6 +78,57 @@ void main() { ), ); }); + + group('Recognizers on different button filters:', () { + final List recognized = []; + late HorizontalDragGestureRecognizer primaryRecognizer; + late HorizontalDragGestureRecognizer secondaryRecognizer; + setUp(() { + primaryRecognizer = HorizontalDragGestureRecognizer( + allowedButtonsFilter: (int buttons) => kPrimaryButton == buttons) + ..onStart = (DragStartDetails details) { + recognized.add('onStartPrimary'); + }; + secondaryRecognizer = HorizontalDragGestureRecognizer( + allowedButtonsFilter: (int buttons) => kSecondaryButton == buttons) + ..onStart = (DragStartDetails details) { + recognized.add('onStartSecondary'); + }; + }); + + tearDown(() { + recognized.clear(); + primaryRecognizer.dispose(); + secondaryRecognizer.dispose(); + }); + + testGesture('Primary button works', (GestureTester tester) { + const PointerDownEvent down1 = PointerDownEvent( + pointer: 6, + position: Offset(10.0, 10.0), + ); + + primaryRecognizer.addPointer(down1); + secondaryRecognizer.addPointer(down1); + tester.closeArena(down1.pointer); + tester.route(down1); + expect(recognized, ['onStartPrimary']); + }); + + testGesture('Secondary button works', (GestureTester tester) { + const PointerDownEvent down1 = PointerDownEvent( + pointer: 6, + position: Offset(10.0, 10.0), + buttons: kSecondaryMouseButton, + ); + + primaryRecognizer.addPointer(down1); + secondaryRecognizer.addPointer(down1); + tester.closeArena(down1.pointer); + tester.route(down1); + expect(recognized, ['onStartSecondary']); + }); + }); } class MockHitTestTarget implements HitTestTarget { diff --git a/packages/flutter/test/widgets/draggable_test.dart b/packages/flutter/test/widgets/draggable_test.dart index e3cce7bb60ed..8548daf5938c 100644 --- a/packages/flutter/test/widgets/draggable_test.dart +++ b/packages/flutter/test/widgets/draggable_test.dart @@ -3195,6 +3195,48 @@ void main() { expect(const LongPressDraggable(feedback: widget2, child: widget1).feedback, widget2); expect(LongPressDraggable(feedback: widget2, dragAnchorStrategy: dummyStrategy, child: widget1).dragAnchorStrategy, dummyStrategy); }); + + testWidgets('Test allowedButtonsFilter', (WidgetTester tester) async { + Widget build(bool Function(int buttons)? allowedButtonsFilter) { + return MaterialApp( + home: Draggable( + key: UniqueKey(), + allowedButtonsFilter: allowedButtonsFilter, + feedback: const Text('Dragging'), + child: const Text('Source'), + ), + ); + } + + await tester.pumpWidget(build(null)); + final Offset firstLocation = tester.getCenter(find.text('Source')); + expect(find.text('Dragging'), findsNothing); + final TestGesture gesture = await tester.startGesture(firstLocation, pointer: 7); + await tester.pump(); + expect(find.text('Dragging'), findsOneWidget); + await gesture.up(); + + await tester.pumpWidget(build((int buttons) => buttons == kSecondaryButton)); + expect(find.text('Dragging'), findsNothing); + final TestGesture gesture1 = await tester.startGesture(firstLocation, pointer: 8); + await tester.pump(); + expect(find.text('Dragging'), findsNothing); + await gesture1.up(); + + await tester.pumpWidget(build((int buttons) => buttons & kTertiaryButton != 0 || buttons & kPrimaryButton != 0)); + expect(find.text('Dragging'), findsNothing); + final TestGesture gesture2 = await tester.startGesture(firstLocation, pointer: 8); + await tester.pump(); + expect(find.text('Dragging'), findsOneWidget); + await gesture2.up(); + + await tester.pumpWidget(build((int buttons) => false)); + expect(find.text('Dragging'), findsNothing); + final TestGesture gesture3 = await tester.startGesture(firstLocation, pointer: 8); + await tester.pump(); + expect(find.text('Dragging'), findsNothing); + await gesture3.up(); + }); } Future _testLongPressDraggableHapticFeedback({ required WidgetTester tester, required bool hapticFeedbackOnStart, required int expectedHapticFeedbackCount }) async {