diff --git a/packages/flutter/lib/src/gestures/converter.dart b/packages/flutter/lib/src/gestures/converter.dart index fc71a6816c..7d15ac66ef 100644 --- a/packages/flutter/lib/src/gestures/converter.dart +++ b/packages/flutter/lib/src/gestures/converter.dart @@ -47,12 +47,16 @@ class _PointerState { // https://github.com/flutter/flutter/issues/30454 int _synthesiseDownButtons(int buttons, PointerDeviceKind kind) { switch (kind) { + case PointerDeviceKind.mouse: + return buttons; case PointerDeviceKind.touch: case PointerDeviceKind.stylus: case PointerDeviceKind.invertedStylus: return buttons | kPrimaryButton; default: - return buttons; + // We have no information about the device but we know we never want + // buttons to be 0 when the pointer is down. + return buttons == 0 ? kPrimaryButton : buttons; } } diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index 600b25f509..7e192dae78 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -8,22 +8,35 @@ import 'package:flutter/foundation.dart'; export 'dart:ui' show Offset, PointerDeviceKind; -/// The bit of [PointerEvent.buttons] that corresponds to the "primary -/// action" on any device. +/// The bit of [PointerEvent.buttons] that corresponds to a cross-device +/// behavior of "primary operation". /// -/// More specifially, +/// More specifially, it includes: /// -/// * For touch screen, it's when the pointer contacts the screen. -/// * For stylus and inverted stylus, it's when the pen contacts the screen. -/// * For mouse, it's when the primary button is pressed. +/// * [kTouchContact]: The pointer contacts the touch screen. +/// * [kStylusContact]: The stylus contacts the screen. +/// * [kPrimaryMouseButton]: The primary mouse button. /// /// See also: /// -/// * [kTouchContact]: an alias of this constant when used by touch screen. -/// * [kStylusContact]: an alias of this constant when used by stylus. -/// * [kPrimaryMouseButton]: an alias of this constant when used by mouse. +/// * [kSecondaryButton], which describes a cross-device behavior of +/// "secondary operation". const int kPrimaryButton = 0x01; +/// The bit of [PointerEvent.buttons] that corresponds to a cross-device +/// behavior of "secondary operation". +/// +/// It is equivalent to: +/// +/// * [kPrimaryStylusButton]: The stylus contacts the screen. +/// * [kSecondaryMouseButton]: The primary mouse button. +/// +/// See also: +/// +/// * [kPrimaryButton], which describes a cross-device behavior of +/// "primary operation". +const int kSecondaryButton = 0x02; + /// The bit of [PointerEvent.buttons] that corresponds to the primary mouse button. /// /// The primary mouse button is typically the left button on the top of the @@ -31,28 +44,40 @@ const int kPrimaryButton = 0x01; /// /// See also: /// -/// * [kTouchContact]: an alias of this constant when used by touch screen. +/// * [kPrimaryButton], which has the same value but describes its cross-device +/// concept. const int kPrimaryMouseButton = kPrimaryButton; /// The bit of [PointerEvent.buttons] that corresponds to the secondary mouse button. /// /// The secondary mouse button is typically the right button on the top of the /// mouse but can be reconfigured to be a different physical button. -const int kSecondaryMouseButton = 0x02; +/// +/// See also: +/// +/// * [kSecondaryButton], which has the same value but describes its cross-device +/// concept. +const int kSecondaryMouseButton = kSecondaryButton; /// The bit of [PointerEvent.buttons] that corresponds to when a stylus /// contacting the screen. /// /// See also: /// -/// * [kPrimaryButton]: an alias of this constant for any device. +/// * [kPrimaryButton], which has the same value but describes its cross-device +/// concept. const int kStylusContact = kPrimaryButton; /// The bit of [PointerEvent.buttons] that corresponds to the primary stylus button. /// /// The primary stylus button is typically the top of the stylus and near the /// tip but can be reconfigured to be a different physical button. -const int kPrimaryStylusButton = 0x02; +/// +/// See also: +/// +/// * [kSecondaryButton], which has the same value but describes its cross-device +/// concept. +const int kPrimaryStylusButton = kSecondaryButton; /// The bit of [PointerEvent.buttons] that corresponds to the middle mouse button. /// @@ -84,7 +109,8 @@ const int kForwardMouseButton = 0x10; /// /// See also: /// -/// * [kPrimaryButton]: an alias of this constant for any device. +/// * [kPrimaryButton], which has the same value but describes its cross-device +/// concept. const int kTouchContact = kPrimaryButton; /// The bit of [PointerEvent.buttons] that corresponds to the nth mouse button. @@ -104,6 +130,47 @@ int nthMouseButton(int number) => (kPrimaryMouseButton << (number - 1)) & kMaxUn /// for some stylus buttons. int nthStylusButton(int number) => (kPrimaryStylusButton << (number - 1)) & kMaxUnsignedSMI; +/// Returns the button of `buttons` with the smallest integer. +/// +/// The `buttons` parameter is a bitfield where each set bit represents a button. +/// This function returns the set bit closest to the least significant bit. +/// +/// It returns zero when `buttons` is zero. +/// +/// Example: +/// +/// ```dart +/// assert(rightmostButton(0x1) == 0x1); +/// assert(rightmostButton(0x11) == 0x1); +/// assert(rightmostButton(0) == 0); +/// ``` +/// +/// See also: +/// +/// * [isSingleButton], which checks if a `buttons` contains exactly one button. +int smallestButton(int buttons) => buttons & (-buttons); + +/// Returns whether `buttons` contains one and only one button. +/// +/// The `buttons` parameter is a bitfield where each set bit represents a button. +/// This function returns whether there is only one set bit in the given integer. +/// +/// It returns false when `buttons` is zero. +/// +/// Example: +/// +/// ```dart +/// assert(isSingleButton(0x1) == true); +/// assert(isSingleButton(0x11) == false); +/// assert(isSingleButton(0) == false); +/// ``` +/// +/// See also: +/// +/// * [smallestButton], which returns the button in a `buttons` bitfield with +/// the smallest integer button. +bool isSingleButton(int buttons) => buttons != 0 && (smallestButton(buttons) == buttons); + /// Base class for touch, stylus, or mouse events. /// /// Pointer events operate in the coordinate space of the screen, scaled to diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index abbe4dc4e0..dcbfb40388 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -109,6 +109,10 @@ class LongPressEndDetails { /// until it's recognized. Once the gesture is accepted, the finger can be /// moved, triggering [onLongPressMoveUpdate] callbacks, unless the /// [postAcceptSlopTolerance] constructor argument is specified. +/// +/// [LongPressGestureRecognizer] 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. class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// Creates a long-press gesture recognizer. /// @@ -133,91 +137,161 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { ); bool _longPressAccepted = false; - Offset _longPressOrigin; + // The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a + // different set of buttons, the gesture is canceled. + int _initialButtons; - /// Called when a long press gesture has been recognized. + /// Called when a long press gesture by a primary button has been recognized. /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. /// * [onLongPressStart], which has the same timing but has data for the /// press location. GestureLongPressCallback onLongPress; - /// Callback for long press start with gesture location. + /// Called when a long press gesture by a primary button has been recognized. /// /// See also: /// - /// * [onLongPress], which has the same timing but without the location data. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPress], which has the same timing but without details. + /// * [LongPressStartDetails], which is passed as an argument to this callback. GestureLongPressStartCallback onLongPressStart; - /// Callback for moving the gesture after the lang press is recognized. - GestureLongPressMoveUpdateCallback onLongPressMoveUpdate; - - /// Called when the pointer stops contacting the screen after the long-press. + /// Called when moving after the long press by a primary button is recognized. /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressMoveUpdateDetails], which is passed as an argument to this + /// callback. + GestureLongPressMoveUpdateCallback onLongPressMoveUpdate; + + /// Called when the pointer stops contacting the screen after a long-press + /// by a primary button. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. /// * [onLongPressEnd], which has the same timing but has data for the up /// gesture location. GestureLongPressUpCallback onLongPressUp; - /// Callback for long press end with gesture location. + /// Called when the pointer stops contacting the screen after a long-press + /// by a primary button. /// /// See also: /// - /// * [onLongPressUp], which has the same timing but without the location data. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPressUp], which has the same timing, but without details. + /// * [LongPressEndDetails], which is passed as an argument to this + /// callback. GestureLongPressEndCallback onLongPressEnd; + @override + bool isPointerAllowed(PointerDownEvent event) { + switch (event.buttons) { + case kPrimaryButton: + if (onLongPressStart == null && + onLongPress == null && + onLongPressMoveUpdate == null && + onLongPressEnd == null && + onLongPressUp == null) + return false; + break; + default: + return false; + } + return super.isPointerAllowed(event); + } + @override void didExceedDeadline() { + // Exceeding the deadline puts the gesture in the accepted state. resolve(GestureDisposition.accepted); _longPressAccepted = true; super.acceptGesture(primaryPointer); - if (onLongPress != null) { - invokeCallback('onLongPress', onLongPress); - } - if (onLongPressStart != null) { - invokeCallback('onLongPressStart', () { - onLongPressStart(LongPressStartDetails( - globalPosition: _longPressOrigin, - )); - }); - } + _checkLongPressStart(); } @override void handlePrimaryPointer(PointerEvent event) { if (event is PointerUpEvent) { if (_longPressAccepted == true) { - if (onLongPressUp != null) { - invokeCallback('onLongPressUp', onLongPressUp); - } - if (onLongPressEnd != null) { - invokeCallback('onLongPressEnd', () { - onLongPressEnd(LongPressEndDetails( - globalPosition: event.position, - )); - }); - } - _longPressAccepted = false; + _checkLongPressEnd(event); } else { + // Pointer is lifted before timeout. resolve(GestureDisposition.rejected); } - } else if (event is PointerDownEvent || event is PointerCancelEvent) { + _reset(); + } else if (event is PointerCancelEvent) { + _reset(); + } else if (event is PointerDownEvent) { // The first touch. - _longPressAccepted = false; _longPressOrigin = event.position; - } else if (event is PointerMoveEvent && _longPressAccepted && onLongPressMoveUpdate != null) { - invokeCallback('onLongPressMoveUpdate', () { - onLongPressMoveUpdate(LongPressMoveUpdateDetails( - globalPosition: event.position, - offsetFromOrigin: event.position - _longPressOrigin, - )); - }); + _initialButtons = event.buttons; + } else if (event is PointerMoveEvent) { + if (event.buttons != _initialButtons) { + resolve(GestureDisposition.rejected); + stopTrackingPointer(primaryPointer); + } else if (_longPressAccepted) { + _checkLongPressMoveUpdate(event); + } } } + void _checkLongPressStart() { + assert(_initialButtons == kPrimaryButton); + final LongPressStartDetails details = LongPressStartDetails( + globalPosition: _longPressOrigin, + ); + if (onLongPressStart != null) + invokeCallback('onLongPressStart', + () => onLongPressStart(details)); + if (onLongPress != null) + invokeCallback('onLongPress', onLongPress); + } + + void _checkLongPressMoveUpdate(PointerEvent event) { + assert(_initialButtons == kPrimaryButton); + final LongPressMoveUpdateDetails details = LongPressMoveUpdateDetails( + globalPosition: event.position, + offsetFromOrigin: event.position - _longPressOrigin, + ); + if (onLongPressMoveUpdate != null) + invokeCallback('onLongPressMoveUpdate', + () => onLongPressMoveUpdate(details)); + } + + void _checkLongPressEnd(PointerEvent event) { + assert(_initialButtons == kPrimaryButton); + final LongPressEndDetails details = LongPressEndDetails( + globalPosition: event.position, + ); + if (onLongPressEnd != null) + invokeCallback('onLongPressEnd', () => onLongPressEnd(details)); + if (onLongPressUp != null) + invokeCallback('onLongPressUp', onLongPressUp); + } + + void _reset() { + _longPressAccepted = false; + _longPressOrigin = null; + _initialButtons = null; + } + + @override + void resolve(GestureDisposition disposition) { + if (_longPressAccepted && disposition == GestureDisposition.rejected) { + // This can happen if the gesture has been canceled. For example when + // the buttons have changed. + _reset(); + } + super.resolve(disposition); + } + @override void acceptGesture(int pointer) { // Winning the arena isn't important here since it may happen from a sweep. diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index f582e06493..b334f58a70 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -43,6 +43,10 @@ typedef GestureDragCancelCallback = void Function(); /// 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. +/// /// See also: /// /// * [HorizontalDragGestureRecognizer], for left and right drags. @@ -84,13 +88,20 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// at (510.0, 500.0). DragStartBehavior dragStartBehavior; - /// A pointer has contacted the screen and might begin to move. + /// A pointer has contacted the screen with a primary button and might begin + /// to move. /// /// The position of the pointer is provided in the callback's `details` /// argument, which is a [DragDownDetails] object. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [DragDownDetails], which is passed as an argument to this callback. GestureDragDownCallback onDown; - /// A pointer has contacted the screen and has begun to move. + /// A pointer has contacted the screen with a primary button and has begun to + /// move. /// /// The position of the pointer is provided in the callback's `details` /// argument, which is a [DragStartDetails] object. @@ -99,23 +110,43 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// called on the initial touch down, if set to [DragStartBehavior.down] or /// when the drag gesture is first detected, if set to /// [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [DragStartDetails], which is passed as an argument to this callback. GestureDragStartCallback onStart; - /// A pointer that is in contact with the screen and moving has moved again. + /// A pointer that is in contact with the screen with a primary button and + /// moving has moved again. /// /// The distance travelled by the pointer since the last update is provided in /// the callback's `details` argument, which is a [DragUpdateDetails] object. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [DragUpdateDetails], which is passed as an argument to this callback. GestureDragUpdateCallback onUpdate; - /// A pointer that was previously in contact with the screen and moving is no - /// longer in contact with the screen and was moving at a specific velocity - /// when it stopped contacting the screen. + /// A pointer that was previously in contact with the screen with a primary + /// button and moving is no longer in contact with the screen and was moving + /// at a specific velocity when it stopped contacting the screen. /// /// The velocity is provided in the callback's `details` argument, which is a /// [DragEndDetails] object. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [DragEndDetails], which is passed as an argument to this callback. GestureDragEndCallback onEnd; /// The pointer that previously triggered [onDown] did not complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. GestureDragCancelCallback onCancel; /// The minimum distance an input pointer drag must have moved to @@ -141,6 +172,9 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { Offset _initialPosition; Offset _pendingDragOffset; Duration _lastPendingEventTimestamp; + // The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a + // different set of buttons, the gesture is canceled. + int _initialButtons; bool _isFlingGesture(VelocityEstimate estimate); Offset _getDeltaForDetails(Offset delta); @@ -149,6 +183,30 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { final Map _velocityTrackers = {}; + @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; + } + } else { + // There can be multiple drags simultaneously. Their effects are combined. + if (event.buttons != _initialButtons) { + return false; + } + } + return super.isPointerAllowed(event); + } + @override void addAllowedPointer(PointerEvent event) { startTrackingPointer(event.pointer); @@ -156,10 +214,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { if (_state == _DragState.ready) { _state = _DragState.possible; _initialPosition = event.position; + _initialButtons = event.buttons; _pendingDragOffset = Offset.zero; _lastPendingEventTimestamp = event.timeStamp; - if (onDown != null) - invokeCallback('onDown', () => onDown(DragDownDetails(globalPosition: _initialPosition))); + _checkDown(); } else if (_state == _DragState.accepted) { resolve(GestureDisposition.accepted); } @@ -176,16 +234,19 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } if (event is PointerMoveEvent) { + if (event.buttons != _initialButtons) { + resolve(GestureDisposition.rejected); + stopTrackingPointer(event.pointer); + return; + } final Offset delta = event.delta; if (_state == _DragState.accepted) { - if (onUpdate != null) { - invokeCallback('onUpdate', () => onUpdate(DragUpdateDetails( - sourceTimeStamp: event.timeStamp, - delta: _getDeltaForDetails(delta), - primaryDelta: _getPrimaryValueFromOffset(delta), - globalPosition: event.position, - ))); - } + _checkUpdate( + sourceTimeStamp: event.timeStamp, + delta: _getDeltaForDetails(delta), + primaryDelta: _getPrimaryValueFromOffset(delta), + globalPosition: event.position, + ); } else { _pendingDragOffset += delta; _lastPendingEventTimestamp = event.timeStamp; @@ -214,19 +275,14 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { } _pendingDragOffset = Offset.zero; _lastPendingEventTimestamp = null; - if (onStart != null) { - invokeCallback('onStart', () => onStart(DragStartDetails( - sourceTimeStamp: timestamp, - globalPosition: _initialPosition, - ))); - } - if (updateDelta != Offset.zero && onUpdate != null) { - invokeCallback('onUpdate', () => onUpdate(DragUpdateDetails( + _checkStart(timestamp); + if (updateDelta != Offset.zero) { + _checkUpdate( sourceTimeStamp: timestamp, delta: updateDelta, primaryDelta: _getPrimaryValueFromOffset(updateDelta), globalPosition: _initialPosition + updateDelta, // Only adds delta for down behaviour - ))); + ); } } } @@ -238,41 +294,101 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { @override void didStopTrackingLastPointer(int pointer) { - if (_state == _DragState.possible) { - resolve(GestureDisposition.rejected); - _state = _DragState.ready; - if (onCancel != null) - invokeCallback('onCancel', onCancel); - return; - } - final bool wasAccepted = _state == _DragState.accepted; - _state = _DragState.ready; - if (wasAccepted && onEnd != null) { - final VelocityTracker tracker = _velocityTrackers[pointer]; - assert(tracker != null); + assert(_state != _DragState.ready); + switch(_state) { + case _DragState.ready: + break; - final VelocityEstimate estimate = tracker.getVelocityEstimate(); - if (estimate != null && _isFlingGesture(estimate)) { - final Velocity velocity = Velocity(pixelsPerSecond: estimate.pixelsPerSecond) - .clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity); - invokeCallback('onEnd', () => onEnd(DragEndDetails( - velocity: velocity, - primaryVelocity: _getPrimaryValueFromOffset(velocity.pixelsPerSecond), - )), debugReport: () { - return '$estimate; fling at $velocity.'; - }); - } else { - invokeCallback('onEnd', () => onEnd(DragEndDetails( - velocity: Velocity.zero, - primaryVelocity: 0.0, - )), debugReport: () { - if (estimate == null) - return 'Could not estimate velocity.'; - return '$estimate; judged to not be a fling.'; - }); - } + case _DragState.possible: + resolve(GestureDisposition.rejected); + _checkCancel(); + break; + + case _DragState.accepted: + _checkEnd(pointer); + break; } _velocityTrackers.clear(); + _initialButtons = null; + _state = _DragState.ready; + } + + void _checkDown() { + assert(_initialButtons == kPrimaryButton); + final DragDownDetails details = DragDownDetails( + globalPosition: _initialPosition, + ); + if (onDown != null) + invokeCallback('onDown', () => onDown(details)); + } + + void _checkStart(Duration timestamp) { + assert(_initialButtons == kPrimaryButton); + final DragStartDetails details = DragStartDetails( + sourceTimeStamp: timestamp, + globalPosition: _initialPosition, + ); + if (onStart != null) + invokeCallback('onStart', () => onStart(details)); + } + + void _checkUpdate({ + Duration sourceTimeStamp, + Offset delta, + double primaryDelta, + Offset globalPosition, + }) { + assert(_initialButtons == kPrimaryButton); + final DragUpdateDetails details = DragUpdateDetails( + sourceTimeStamp: sourceTimeStamp, + delta: delta, + primaryDelta: primaryDelta, + globalPosition: globalPosition, + ); + if (onUpdate != null) + invokeCallback('onUpdate', () => onUpdate(details)); + } + + void _checkEnd(int pointer) { + assert(_initialButtons == kPrimaryButton); + if (onEnd == null) + return; + + final VelocityTracker tracker = _velocityTrackers[pointer]; + assert(tracker != null); + + DragEndDetails details; + void Function() debugReport; + + final VelocityEstimate estimate = tracker.getVelocityEstimate(); + if (estimate != null && _isFlingGesture(estimate)) { + final Velocity velocity = Velocity(pixelsPerSecond: estimate.pixelsPerSecond) + .clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity); + details = DragEndDetails( + velocity: velocity, + primaryVelocity: _getPrimaryValueFromOffset(velocity.pixelsPerSecond), + ); + debugReport = () { + return '$estimate; fling at $velocity.'; + }; + } else { + details = DragEndDetails( + velocity: Velocity.zero, + primaryVelocity: 0.0, + ); + debugReport = () { + if (estimate == null) + return 'Could not estimate velocity.'; + return '$estimate; judged to not be a fling.'; + }; + } + invokeCallback('onEnd', () => onEnd(details), debugReport: debugReport); + } + + void _checkCancel() { + assert(_initialButtons == kPrimaryButton); + if (onCancel != null) + invokeCallback('onCancel', onCancel); } @override diff --git a/packages/flutter/lib/src/gestures/multitap.dart b/packages/flutter/lib/src/gestures/multitap.dart index 57e5a5eed1..ea5a50fd18 100644 --- a/packages/flutter/lib/src/gestures/multitap.dart +++ b/packages/flutter/lib/src/gestures/multitap.dart @@ -16,6 +16,10 @@ 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 @@ -60,13 +64,16 @@ class _TapTracker { @required Duration doubleTapMinTime, }) : assert(doubleTapMinTime != null), assert(event != null), + assert(event.buttons != null), pointer = event.pointer, _initialPosition = event.position, + initialButtons = event.buttons, _doubleTapMinTimeCountdown = _CountdownZoned(duration: doubleTapMinTime); final int pointer; final GestureArenaEntry entry; final Offset _initialPosition; + final int initialButtons; final _CountdownZoned _doubleTapMinTimeCountdown; bool _isTrackingPointer = false; @@ -93,10 +100,18 @@ class _TapTracker { 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. /// @@ -126,26 +141,53 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { // - The long timer between taps expires // - The gesture arena decides we have been rejected wholesale - /// Called when the user has tapped the screen at the same location twice in - /// quick succession. + /// 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 2nd tap, + /// immediately after [onDoubleTapUp]. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. GestureDoubleTapCallback onDoubleTap; Timer _doubleTapTimer; _TapTracker _firstTap; final Map _trackers = {}; + @override + bool isPointerAllowed(PointerEvent event) { + if (_firstTap == null) { + switch (event.buttons) { + case kPrimaryButton: + if (onDoubleTap == null) + return false; + break; + default: + return false; + } + } + return super.isPointerAllowed(event); + } + @override void addAllowedPointer(PointerEvent event) { if (_firstTap != null) { if (!_firstTap.isWithinTolerance(event, kDoubleTapSlop)) { // Ignore out-of-bounds second taps. return; - } else if (!_firstTap.hasElapsedMinTime()) { - // Restart when the second tap is too close to the first. + } else if (!_firstTap.hasElapsedMinTime() || !_firstTap.hasSameButton(event)) { + // Restart when the second tap is too close to the first, or when buttons + // mismatch. _reset(); - return addAllowedPointer(event); + return _trackFirstTap(event); } } + _trackFirstTap(event); + } + + void _trackFirstTap(PointerEvent event) { _stopDoubleTapTimer(); final _TapTracker tracker = _TapTracker( event: event, @@ -235,8 +277,7 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { tracker.entry.resolve(GestureDisposition.accepted); _freezeTracker(tracker); _trackers.remove(tracker.pointer); - if (onDoubleTap != null) - invokeCallback('onDoubleTap', onDoubleTap); + _checkUp(tracker.initialButtons); _reset(); } @@ -260,6 +301,12 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { } } + void _checkUp(int buttons) { + assert(buttons == kPrimaryButton); + if (onDoubleTap != null) + invokeCallback('onDoubleTap', onDoubleTap); + } + @override String get debugDescription => 'double tap'; } diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index d5672255ad..284ddb17a0 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -9,7 +9,7 @@ import 'constants.dart'; import 'events.dart'; import 'recognizer.dart'; -/// Details for [GestureTapDownCallback], such as position. +/// Details for [GestureTapDownCallback], such as position /// /// See also: /// @@ -45,8 +45,6 @@ typedef GestureTapDownCallback = void Function(TapDownDetails details); /// * [GestureDetector.onTapUp], which receives this information. /// * [TapGestureRecognizer], which passes this information to one of its callbacks. class TapUpDetails { - /// Creates details for a [GestureTapUpCallback]. - /// /// The [globalPosition] argument must not be null. TapUpDetails({ this.globalPosition = Offset.zero }) : assert(globalPosition != null); @@ -95,14 +93,10 @@ typedef GestureTapCancelCallback = void Function(); /// pointer interactions during a tap sequence are not recognized as additional /// taps. For example, down-1, down-2, up-1, up-2 produces only one tap on up-1. /// -/// The lifecycle of events for a tap gesture is as follows: -/// -/// * [onTapDown], which triggers after a short timeout ([deadline]) even if the -/// gesture has not won its arena yet. -/// * [onTapUp] and [onTap], which trigger when the pointer is released if the -/// gesture wins the arena. -/// * [onTapCancel], which triggers instead of [onTapUp] and [onTap] in the case -/// of the gesture not winning the arena. +/// [TapGestureRecognizer] competes on pointer events of [kPrimaryButton] only +/// when it has at least one non-null `onTap*` callback, and events of +/// [kSecondaryButton] only when it has at least one non-null `onSecondaryTap*` +/// callback. If it has no callbacks, it is a no-op. /// /// See also: /// @@ -112,22 +106,25 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { /// Creates a tap gesture recognizer. TapGestureRecognizer({ Object debugOwner }) : super(deadline: kPressTimeout, debugOwner: debugOwner); - /// A pointer that might cause a tap has contacted the screen at a particular - /// location. + /// A pointer that might cause a tap of a primary button has contacted the + /// screen at a particular location. /// - /// This triggers before the gesture has won the arena, after a short timeout - /// ([deadline]). + /// This triggers once a short timeout ([deadline]) has elapsed, or once + /// the gestures has won the arena, whichever comes first. /// /// If the gesture doesn't win the arena, [onTapCancel] is called next. /// Otherwise, [onTapUp] is called next. /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [onSecondaryTapDown], a similar callback but for a secondary button. + /// * [TapDownDetails], which is passed as an argument to this callback. /// * [GestureDetector.onTapDown], which exposes this callback. GestureTapDownCallback onTapDown; - /// A pointer that will trigger a tap has stopped contacting the screen at a - /// particular location. + /// A pointer that will trigger a tap of a primary button has stopped + /// contacting the screen at a particular location. /// /// This triggers once the gesture has won the arena, immediately before /// [onTap]. @@ -136,11 +133,13 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { /// /// See also: /// - /// * [GestureDetector.onTapUp], which exposes this callback. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onSecondaryTapUp], a similar callback but for a secondary button. /// * [TapUpDetails], which is passed as an argument to this callback. + /// * [GestureDetector.onTapUp], which exposes this callback. GestureTapUpCallback onTapUp; - /// A tap has occurred. + /// A tap of a primary button has occurred. /// /// This triggers once the gesture has won the arena, immediately after /// [onTapUp]. @@ -149,6 +148,8 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [onTapUp], which has the same timing but with details. /// * [GestureDetector.onTap], which exposes this callback. GestureTapCallback onTap; @@ -161,37 +162,121 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [onSecondaryTapCancel], a similar callback but for a secondary button. /// * [GestureDetector.onTapCancel], which exposes this callback. GestureTapCancelCallback onTapCancel; + /// A pointer that might cause a tap of a secondary button has contacted the + /// screen at a particular location. + /// + /// This triggers once a short timeout ([deadline]) has elapsed, or once + /// the gestures has won the arena, whichever comes first. + /// + /// If the gesture doesn't win the arena, [onSecondaryTapCancel] is called next. + /// Otherwise, [onSecondaryTapUp] is called next. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [onPrimaryTapDown], a similar callback but for a primary button. + /// * [TapDownDetails], which is passed as an argument to this callback. + /// * [GestureDetector.onSecondaryTapDown], which exposes this callback. + GestureTapDownCallback onSecondaryTapDown; + + /// A pointer that will trigger a tap of a secondary button has stopped + /// contacting the screen at a particular location. + /// + /// This triggers once the gesture has won the arena. + /// + /// If the gesture doesn't win the arena, [onSecondaryTapCancel] is called + /// instead. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [onPrimaryTapUp], a similar callback but for a primary button. + /// * [TapUpDetails], which is passed as an argument to this callback. + /// * [GestureDetector.onSecondaryTapUp], which exposes this callback. + GestureTapUpCallback onSecondaryTapUp; + + /// The pointer that previously triggered [onSecondaryTapDown] will not end up + /// causing a tap. + /// + /// This triggers if the gesture loses the arena. + /// + /// If the gesture wins the arena, [onSecondaryTapUp] is called instead. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [onPrimaryTapCancel], a similar callback but for a primary button. + /// * [GestureDetector.onTapCancel], which exposes this callback. + GestureTapCancelCallback onSecondaryTapCancel; + bool _sentTapDown = false; bool _wonArenaForPrimaryPointer = false; Offset _finalPosition; + // The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a + // different set of buttons, the gesture is canceled. + int _initialButtons; + + @override + bool isPointerAllowed(PointerDownEvent event) { + switch (event.buttons) { + case kPrimaryButton: + if (onTapDown == null && + onTap == null && + onTapUp == null && + onTapCancel == null) + return false; + break; + case kSecondaryButton: + if (onSecondaryTapDown == null && + onSecondaryTapUp == null && + onSecondaryTapCancel == null) + return false; + break; + default: + return false; + } + return super.isPointerAllowed(event); + } + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + // `_initialButtons` must be assigned here instead of `handlePrimaryPointer`, + // because `acceptGesture` might be called before `handlePrimaryPointer`, + // which relies on `_initialButtons` to create `TapDownDetails`. + _initialButtons = event.buttons; + } @override void handlePrimaryPointer(PointerEvent event) { if (event is PointerUpEvent) { _finalPosition = event.position; - if (_wonArenaForPrimaryPointer) { - resolve(GestureDisposition.accepted); - _checkUp(); - } + _checkUp(); } else if (event is PointerCancelEvent) { - if (_sentTapDown && onTapCancel != null) { - invokeCallback('onTapCancel', onTapCancel); + resolve(GestureDisposition.rejected); + if (_sentTapDown) { + _checkCancel(''); } _reset(); + } else if (event.buttons != _initialButtons) { + resolve(GestureDisposition.rejected); + stopTrackingPointer(primaryPointer); } } @override void resolve(GestureDisposition disposition) { if (_wonArenaForPrimaryPointer && disposition == GestureDisposition.rejected) { - // This can happen if the superclass decides the primary pointer - // exceeded the touch slop, or if the recognizer is disposed. + // This can happen if the gesture has been canceled. For example, when + // the pointer has exceeded the touch slop, the buttons have been changed, + // or if the recognizer is disposed. assert(_sentTapDown); - if (onTapCancel != null) - invokeCallback('spontaneous onTapCancel', onTapCancel); + _checkCancel('spontaneous '); _reset(); } super.resolve(disposition); @@ -218,27 +303,70 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { if (pointer == primaryPointer) { // Another gesture won the arena. assert(state != GestureRecognizerState.possible); - if (_sentTapDown && onTapCancel != null) - invokeCallback('forced onTapCancel', onTapCancel); + if (_sentTapDown) + _checkCancel('forced '); _reset(); } } void _checkDown() { - if (!_sentTapDown) { - if (onTapDown != null) - invokeCallback('onTapDown', () { onTapDown(TapDownDetails(globalPosition: initialPosition)); }); - _sentTapDown = true; + if (_sentTapDown) { + return; } + final TapDownDetails details = TapDownDetails( + globalPosition: initialPosition, + ); + switch (_initialButtons) { + case kPrimaryButton: + if (onTapDown != null) + invokeCallback('onTapDown', () => onTapDown(details)); + break; + case kSecondaryButton: + if (onSecondaryTapDown != null) + invokeCallback('onSecondaryTapDown', + () => onSecondaryTapDown(details)); + break; + default: + } + _sentTapDown = true; } void _checkUp() { - if (_finalPosition != null) { - if (onTapUp != null) - invokeCallback('onTapUp', () { onTapUp(TapUpDetails(globalPosition: _finalPosition)); }); - if (onTap != null) - invokeCallback('onTap', onTap); - _reset(); + if (!_wonArenaForPrimaryPointer || _finalPosition == null) { + return; + } + final TapUpDetails details = TapUpDetails( + globalPosition: _finalPosition, + ); + switch (_initialButtons) { + case kPrimaryButton: + if (onTapUp != null) + invokeCallback('onTapUp', () => onTapUp(details)); + if (onTap != null) + invokeCallback('onTap', onTap); + break; + case kSecondaryButton: + if (onSecondaryTapUp != null) + invokeCallback('onSecondaryTapUp', + () => onSecondaryTapUp(details)); + break; + default: + } + _reset(); + } + + void _checkCancel(String note) { + switch (_initialButtons) { + case kPrimaryButton: + if (onTapCancel != null) + invokeCallback('${note}onTapCancel', onTapCancel); + break; + case kSecondaryButton: + if (onSecondaryTapCancel != null) + invokeCallback('${note}onSecondaryTapCancel', + onSecondaryTapCancel); + break; + default: } } @@ -246,6 +374,7 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { _sentTapDown = false; _wonArenaForPrimaryPointer = false; _finalPosition = null; + _initialButtons = null; } @override @@ -257,5 +386,6 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { properties.add(FlagProperty('wonArenaForPrimaryPointer', value: _wonArenaForPrimaryPointer, ifTrue: 'won arena')); properties.add(DiagnosticsProperty('finalPosition', _finalPosition, defaultValue: null)); properties.add(FlagProperty('sentTapDown', value: _sentTapDown, ifTrue: 'sent tap down')); + // TODO(tongmu): Add property _initialButtons and update related tests } } diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 32d3212d5c..e916ec3554 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -166,6 +166,9 @@ class GestureDetector extends StatelessWidget { this.onTapUp, this.onTap, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onDoubleTap, this.onLongPress, this.onLongPressStart, @@ -229,28 +232,37 @@ class GestureDetector extends StatelessWidget { /// {@macro flutter.widgets.child} final Widget child; - /// A pointer that might cause a tap has contacted the screen at a particular - /// location. + /// A pointer that might cause a tap with a primary button has contacted the + /// screen at a particular location. /// /// This is called after a short timeout, even if the winning gesture has not /// yet been selected. If the tap gesture wins, [onTapUp] will be called, /// otherwise [onTapCancel] will be called. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureTapDownCallback onTapDown; - /// A pointer that will trigger a tap has stopped contacting the screen at a - /// particular location. + /// A pointer that will trigger a tap with a primary button has stopped + /// contacting the screen at a particular location. /// /// This triggers immediately before [onTap] in the case of the tap gesture /// winning. If the tap gesture did not win, [onTapCancel] is called instead. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureTapUpCallback onTapUp; - /// A tap has occurred. + /// A tap with a primary button has occurred. /// /// This triggers when the tap gesture wins. If the tap gesture did not win, /// [onTapCancel] is called instead. /// /// See also: /// + /// * [kPrimaryButton], the button this callback responds to. /// * [onTapUp], which is called at the same time but includes details /// regarding the pointer position. final GestureTapCallback onTap; @@ -260,104 +272,222 @@ class GestureDetector extends StatelessWidget { /// /// This is called after [onTapDown], and instead of [onTapUp] and [onTap], if /// the tap gesture did not win. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureTapCancelCallback onTapCancel; - /// The user has tapped the screen at the same location twice in quick - /// succession. + /// A pointer that might cause a tap with a secondary button has contacted the + /// screen at a particular location. + /// + /// This is called after a short timeout, even if the winning gesture has not + /// yet been selected. If the tap gesture wins, [onSecondaryTapUp] will be + /// called, otherwise [onSecondaryTapCancel] will be called. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapDownCallback onSecondaryTapDown; + + /// A pointer that will trigger a tap with a secondary button has stopped + /// contacting the screen at a particular location. + /// + /// This triggers in the case of the tap gesture winning. If the tap gesture + /// did not win, [onSecondaryTapCancel] is called instead. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapUpCallback onSecondaryTapUp; + + /// The pointer that previously triggered [onSecondaryTapDown] will not end up + /// causing a tap. + /// + /// This is called after [onSecondaryTapDown], and instead of + /// [onSecondaryTapUp], if the tap gesture did not win. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCancelCallback onSecondaryTapCancel; + + /// The user has tapped the screen with a primary button at the same location + /// twice in quick succession. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureTapCallback onDoubleTap; - /// Called when a long press gesture has been recognized. + /// Called when a long press gesture with a primary button has been recognized. /// /// Triggered when a pointer has remained in contact with the screen at the /// same location for a long period of time. /// /// See also: /// - /// * [onLongPressStart], which has the same timing but has data for the - /// press location. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPressStart], which has the same timing but has gesture details. final GestureLongPressCallback onLongPress; - /// Callback for long press start with gesture location. + /// Called when a long press gesture with a primary button has been recognized. /// /// Triggered when a pointer has remained in contact with the screen at the /// same location for a long period of time. /// /// See also: /// - /// * [onLongPress], which has the same timing but without the location data. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPress], which has the same timing but without the gesture details. final GestureLongPressStartCallback onLongPressStart; - /// A pointer has been drag-moved after a long press. + /// A pointer has been drag-moved after a long press with a primary button. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureLongPressMoveUpdateCallback onLongPressMoveUpdate; - /// A pointer that has triggered a long-press has stopped contacting the screen. + /// A pointer that has triggered a long-press with a primary button has + /// stopped contacting the screen. /// /// See also: /// - /// * [onLongPressEnd], which has the same timing but has data for the up - /// gesture location. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPressEnd], which has the same timing but has gesture details. final GestureLongPressUpCallback onLongPressUp; - /// A pointer that has triggered a long-press has stopped contacting the screen. + /// A pointer that has triggered a long-press with a primary button has + /// stopped contacting the screen. /// /// See also: /// - /// * [onLongPressUp], which has the same timing but without the location data. + /// * [kPrimaryButton], the button this callback responds to. + /// * [onLongPressUp], which has the same timing but without the gesture + /// details. final GestureLongPressEndCallback onLongPressEnd; - /// A pointer has contacted the screen and might begin to move vertically. + /// A pointer has contacted the screen with a primary button and might begin + /// to move vertically. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragDownCallback onVerticalDragDown; - /// A pointer has contacted the screen and has begun to move vertically. + /// A pointer has contacted the screen with a primary button and has begun to + /// move vertically. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragStartCallback onVerticalDragStart; - /// A pointer that is in contact with the screen and moving vertically has - /// moved in the vertical direction. + /// A pointer that is in contact with the screen with a primary button and + /// moving vertically has moved in the vertical direction. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragUpdateCallback onVerticalDragUpdate; - /// A pointer that was previously in contact with the screen and moving - /// vertically is no longer in contact with the screen and was moving at a - /// specific velocity when it stopped contacting the screen. + /// A pointer that was previously in contact with the screen with a primary + /// button and moving vertically is no longer in contact with the screen and + /// was moving at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragEndCallback onVerticalDragEnd; /// The pointer that previously triggered [onVerticalDragDown] did not /// complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragCancelCallback onVerticalDragCancel; - /// A pointer has contacted the screen and might begin to move horizontally. + /// A pointer has contacted the screen with a primary button and might begin + /// to move horizontally. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragDownCallback onHorizontalDragDown; - /// A pointer has contacted the screen and has begun to move horizontally. + /// A pointer has contacted the screen with a primary button and has begun to + /// move horizontally. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragStartCallback onHorizontalDragStart; - /// A pointer that is in contact with the screen and moving horizontally has - /// moved in the horizontal direction. + /// A pointer that is in contact with the screen with a primary button and + /// moving horizontally has moved in the horizontal direction. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragUpdateCallback onHorizontalDragUpdate; - /// A pointer that was previously in contact with the screen and moving - /// horizontally is no longer in contact with the screen and was moving at a - /// specific velocity when it stopped contacting the screen. + /// A pointer that was previously in contact with the screen with a primary + /// button and moving horizontally is no longer in contact with the screen and + /// was moving at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragEndCallback onHorizontalDragEnd; /// The pointer that previously triggered [onHorizontalDragDown] did not /// complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragCancelCallback onHorizontalDragCancel; - /// A pointer has contacted the screen and might begin to move. + /// A pointer has contacted the screen with a primary button and might begin + /// to move. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragDownCallback onPanDown; - /// A pointer has contacted the screen and has begun to move. + /// A pointer has contacted the screen with a primary button and has begun to + /// move. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragStartCallback onPanStart; - /// A pointer that is in contact with the screen and moving has moved again. + /// A pointer that is in contact with the screen with a primary button and + /// moving has moved again. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragUpdateCallback onPanUpdate; - /// A pointer that was previously in contact with the screen and moving - /// is no longer in contact with the screen and was moving at a specific - /// velocity when it stopped contacting the screen. + /// A pointer that was previously in contact with the screen with a primary + /// button and moving is no longer in contact with the screen and was moving + /// at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragEndCallback onPanEnd; /// The pointer that previously triggered [onPanDown] did not complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. final GestureDragCancelCallback onPanCancel; /// The pointers in contact with the screen have established a focal point and @@ -440,7 +570,15 @@ class GestureDetector extends StatelessWidget { Widget build(BuildContext context) { final Map gestures = {}; - if (onTapDown != null || onTapUp != null || onTap != null || onTapCancel != null) { + if ( + onTapDown != null || + onTapUp != null || + onTap != null || + onTapCancel != null || + onSecondaryTapDown != null || + onSecondaryTapUp != null || + onSecondaryTapCancel != null + ) { gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(debugOwner: this), (TapGestureRecognizer instance) { @@ -448,7 +586,10 @@ class GestureDetector extends StatelessWidget { ..onTapDown = onTapDown ..onTapUp = onTapUp ..onTap = onTap - ..onTapCancel = onTapCancel; + ..onTapCancel = onTapCancel + ..onSecondaryTapDown = onSecondaryTapDown + ..onSecondaryTapUp = onSecondaryTapUp + ..onSecondaryTapCancel = onSecondaryTapCancel; }, ); } @@ -797,8 +938,14 @@ class RawGestureDetectorState extends State { void _handleSemanticsLongPress() { final LongPressGestureRecognizer recognizer = _recognizers[LongPressGestureRecognizer]; assert(recognizer != null); + if (recognizer.onLongPressStart != null) + recognizer.onLongPressStart(const LongPressStartDetails()); if (recognizer.onLongPress != null) recognizer.onLongPress(); + if (recognizer.onLongPressEnd != null) + recognizer.onLongPressEnd(const LongPressEndDetails()); + if (recognizer.onLongPressUp != null) + recognizer.onLongPressUp(); } void _handleSemanticsHorizontalDragUpdate(DragUpdateDetails updateDetails) { diff --git a/packages/flutter/test/gestures/debug_test.dart b/packages/flutter/test/gestures/debug_test.dart index 1a323758c5..56bb27220c 100644 --- a/packages/flutter/test/gestures/debug_test.dart +++ b/packages/flutter/test/gestures/debug_test.dart @@ -147,7 +147,8 @@ void main() { }); test('TapGestureRecognizer _sentTapDown toString', () { - final TapGestureRecognizer tap = TapGestureRecognizer(); + final TapGestureRecognizer tap = TapGestureRecognizer() + ..onTap = () {}; // Add a callback so that event can be added expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready)')); const PointerEvent event = PointerDownEvent(pointer: 1, position: Offset(10.0, 10.0)); tap.addPointer(event); diff --git a/packages/flutter/test/gestures/double_tap_test.dart b/packages/flutter/test/gestures/double_tap_test.dart index 5b58230309..79802bd844 100644 --- a/packages/flutter/test/gestures/double_tap_test.dart +++ b/packages/flutter/test/gestures/double_tap_test.dart @@ -75,7 +75,7 @@ void main() { position: Offset(25.0, 25.0), ); - // Down/up pair 5: normal tap sequence identical to pair 1 with different pointer + // Down/up pair 5: normal tap sequence identical to pair 1 const PointerDownEvent down5 = PointerDownEvent( pointer: 5, position: Offset(10.0, 10.0), @@ -86,6 +86,18 @@ void main() { position: Offset(11.0, 9.0), ); + // Down/up pair 6: normal tap sequence close to pair 1 but on secondary button + const PointerDownEvent down6 = PointerDownEvent( + pointer: 6, + position: Offset(10.0, 10.0), + buttons: kSecondaryMouseButton, + ); + + const PointerUpEvent up6 = PointerUpEvent( + pointer: 6, + position: Offset(11.0, 9.0), + ); + testGesture('Should recognize double tap', (GestureTester tester) { final DoubleTapGestureRecognizer tap = DoubleTapGestureRecognizer(); @@ -606,4 +618,183 @@ void main() { tap.dispose(); }); + + group('Enforce consistent-button restriction:', () { + testGesture('Button change should interrupt existing sequence', (GestureTester tester) { + // 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. + + const Duration interval = Duration(milliseconds: 100); + assert(interval * 2 < kDoubleTapTimeout); + assert(interval > kDoubleTapMinTime); + + final DoubleTapGestureRecognizer tap = DoubleTapGestureRecognizer(); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + + tester.async.elapse(interval); + + tap.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + GestureBinding.instance.gestureArena.sweep(6); + + tester.async.elapse(interval); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + testGesture('Button change should start a valid sequence', (GestureTester tester) { + // Down6 -> down1 (different button from 6) -> down2 (same button as 1) + + const Duration interval = Duration(milliseconds: 100); + assert(interval * 2 < kDoubleTapTimeout); + assert(interval > kDoubleTapMinTime); + + final DoubleTapGestureRecognizer tap = DoubleTapGestureRecognizer(); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + GestureBinding.instance.gestureArena.sweep(6); + + tester.async.elapse(interval); + + tap.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + + expect(doubleTapRecognized, isFalse); + tester.async.elapse(interval); + + tap.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + expect(doubleTapRecognized, isTrue); + + tap.dispose(); + }); + }); + + group('Recognizers listening on different buttons do not form competition:', () { + // This test is assisted by tap recognizers. If a tap gesture has + // no competing recognizers, a pointer down event triggers its onTapDown + // immediately; if there are competitors, onTapDown is triggered after a + // timeout. + // The following tests make sure that double tap recognizers do not form + // competition with a tap gesture recognizer listening on a different button. + + final List recognized = []; + TapGestureRecognizer tapPrimary; + TapGestureRecognizer tapSecondary; + DoubleTapGestureRecognizer doubleTap; + setUp(() { + tapPrimary = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('tapPrimary'); + }; + tapSecondary = TapGestureRecognizer() + ..onSecondaryTapDown = (TapDownDetails details) { + recognized.add('tapSecondary'); + }; + doubleTap = DoubleTapGestureRecognizer() + ..onDoubleTap = () { + recognized.add('doubleTap'); + }; + }); + + tearDown(() { + recognized.clear(); + tapPrimary.dispose(); + tapSecondary.dispose(); + doubleTap.dispose(); + }); + + testGesture('A primary double tap recognizer does not form competion with a secondary tap recognizer', (GestureTester tester) { + doubleTap.addPointer(down6); + tapSecondary.addPointer(down6); + tester.closeArena(down6.pointer); + + tester.route(down6); + expect(recognized, ['tapSecondary']); + }); + + testGesture('A primary double tap recognizer forms competion with a primary tap recognizer', (GestureTester tester) { + doubleTap.addPointer(down1); + tapPrimary.addPointer(down1); + tester.closeArena(down1.pointer); + + tester.route(down1); + expect(recognized, []); + + tester.async.elapse(const Duration(milliseconds: 300)); + expect(recognized, ['tapPrimary']); + }); + }); + + testGesture('A secondary double tap should not trigger primary', (GestureTester tester) { + final List recognized = []; + final DoubleTapGestureRecognizer doubleTap = DoubleTapGestureRecognizer() + ..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), + buttons: kSecondaryMouseButton, + ); + + const PointerUpEvent up7 = PointerUpEvent( + pointer: 7, + position: Offset(11.0, 9.0), + ); + + doubleTap.addPointer(down6); + tester.closeArena(6); + tester.route(down6); + tester.route(up6); + GestureBinding.instance.gestureArena.sweep(6); + + tester.async.elapse(const Duration(milliseconds: 100)); + doubleTap.addPointer(down7); + tester.closeArena(7); + tester.route(down7); + tester.route(up7); + expect(recognized, []); + + recognized.clear(); + doubleTap.dispose(); + }); } diff --git a/packages/flutter/test/gestures/drag_test.dart b/packages/flutter/test/gestures/drag_test.dart index 42ae065a5e..3874c7ab39 100644 --- a/packages/flutter/test/gestures/drag_test.dart +++ b/packages/flutter/test/gestures/drag_test.dart @@ -13,7 +13,7 @@ void main() { testGesture('Should recognize pan', (GestureTester tester) { final PanGestureRecognizer pan = PanGestureRecognizer(); - final TapGestureRecognizer tap = TapGestureRecognizer(); + final TapGestureRecognizer tap = TapGestureRecognizer()..onTap = () {}; bool didStartPan = false; pan.onStart = (_) { @@ -81,7 +81,8 @@ void main() { testGesture('Should report most recent point to onStart by default', (GestureTester tester) { final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); - final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer(); + final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer() + ..onStart = (_) {}; Offset positionAtOnStart; drag.onStart = (DragStartDetails details) { @@ -103,9 +104,9 @@ void main() { }); testGesture('Should report most recent point to onStart with a start configuration', (GestureTester tester) { - final HorizontalDragGestureRecognizer drag = - HorizontalDragGestureRecognizer(); - final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer(); + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); + final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer() + ..onStart = (_) {}; Offset positionAtOnStart; drag.onStart = (DragStartDetails details) { @@ -218,9 +219,11 @@ void main() { // TODO(jslavitz): Revert these tests. testGesture('Should report initial down point to onStart with a down configuration', (GestureTester tester) { - final HorizontalDragGestureRecognizer drag = - HorizontalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down; - final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down; + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer() + ..dragStartBehavior = DragStartBehavior.down; + final VerticalDragGestureRecognizer competingDrag = VerticalDragGestureRecognizer() + ..dragStartBehavior = DragStartBehavior.down + ..onStart = (_) {}; Offset positionAtOnStart; drag.onStart = (DragStartDetails details) { @@ -596,4 +599,248 @@ void main() { drag.dispose(); }); + + group('Enforce consistent-button restriction:', () { + PanGestureRecognizer pan; + TapGestureRecognizer tap; + final List logs = []; + + setUp(() { + tap = TapGestureRecognizer() + ..onTap = () {}; // Need a callback to enable competition + pan = PanGestureRecognizer() + ..onStart = (DragStartDetails details) { + logs.add('start'); + } + ..onDown = (DragDownDetails details) { + logs.add('down'); + } + ..onUpdate = (DragUpdateDetails details) { + logs.add('update'); + } + ..onCancel = () { + logs.add('cancel'); + } + ..onEnd = (DragEndDetails details) { + logs.add('end'); + }; + }); + + tearDown(() { + pan.dispose(); + tap.dispose(); + logs.clear(); + }); + + testGesture('Button change before acceptance should lead to immediate cancel', (GestureTester tester) { + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + pan.addPointer(down); + tap.addPointer(down); + tester.closeArena(5); + + tester.route(down); + expect(logs, ['down']); + // Move out of slop so make sure button changes takes priority over slops + tester.route(pointer.move(const Offset(30.0, 30.0), buttons: kSecondaryButton)); + expect(logs, ['down', 'cancel']); + + tester.route(pointer.up()); + }); + + testGesture('Button change before acceptance should not prevent the next drag', (GestureTester tester) { + { // First drag (which is canceled) + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + pan.addPointer(down); + tap.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + tester.route(pointer.move(const Offset(10.0, 10.0), buttons: kSecondaryButton)); + tester.route(pointer.up()); + expect(logs, ['down', 'cancel']); + } + logs.clear(); + + final TestPointer pointer2 = TestPointer(6, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down2 = pointer2.down(const Offset(10.0, 10.0)); + pan.addPointer(down2); + tap.addPointer(down2); + tester.closeArena(down2.pointer); + tester.route(down2); + expect(logs, ['down']); + + tester.route(pointer2.move(const Offset(30.0, 30.0))); + expect(logs, ['down', 'start']); + + tester.route(pointer2.up()); + expect(logs, ['down', 'start', 'end']); + }); + + testGesture('Button change after acceptance should lead to immediate end', (GestureTester tester) { + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + pan.addPointer(down); + tap.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + expect(logs, ['down']); + tester.route(pointer.move(const Offset(30.0, 30.0))); + expect(logs, ['down', 'start']); + tester.route(pointer.move(const Offset(30.0, 30.0), buttons: kSecondaryButton)); + expect(logs, ['down', 'start', 'end']); + + // Make sure no further updates are sent + tester.route(pointer.move(const Offset(50.0, 50.0))); + expect(logs, ['down', 'start', 'end']); + + tester.route(pointer.up()); + }); + + testGesture('Button change after acceptance should not prevent the next drag', (GestureTester tester) { + { // First drag (which is canceled) + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + pan.addPointer(down); + tap.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + + tester.route(pointer.move(const Offset(30.0, 30.0))); + + tester.route(pointer.move(const Offset(30.0, 31.0), buttons: kSecondaryButton)); + tester.route(pointer.up()); + expect(logs, ['down', 'start', 'end']); + } + logs.clear(); + + final TestPointer pointer2 = TestPointer(6, PointerDeviceKind.mouse, kPrimaryButton); + final PointerDownEvent down2 = pointer2.down(const Offset(10.0, 10.0)); + pan.addPointer(down2); + tap.addPointer(down2); + tester.closeArena(down2.pointer); + tester.route(down2); + expect(logs, ['down']); + + tester.route(pointer2.move(const Offset(30.0, 30.0))); + expect(logs, ['down', 'start']); + + tester.route(pointer2.up()); + expect(logs, ['down', 'start', 'end']); + }); + }); + + group('Recognizers listening on different buttons do not form competition:', () { + // This test is assisted by tap recognizers. If a tap gesture has + // no competing recognizers, a pointer down event triggers its onTapDown + // immediately; if there are competitors, onTapDown is triggered after a + // timeout. + // The following tests make sure that drag recognizers do not form + // competition with a tap gesture recognizer listening on a different button. + + final List recognized = []; + TapGestureRecognizer tapPrimary; + TapGestureRecognizer tapSecondary; + PanGestureRecognizer pan; + setUp(() { + tapPrimary = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('tapPrimary'); + }; + tapSecondary = TapGestureRecognizer() + ..onSecondaryTapDown = (TapDownDetails details) { + recognized.add('tapSecondary'); + }; + pan = PanGestureRecognizer() + ..onStart = (_) { + recognized.add('drag'); + }; + }); + + tearDown(() { + recognized.clear(); + tapPrimary.dispose(); + tapSecondary.dispose(); + pan.dispose(); + }); + + testGesture('A primary pan recognizer does not form competion with a secondary tap recognizer', (GestureTester tester) { + final TestPointer pointer = TestPointer( + 1, + PointerDeviceKind.touch, + kSecondaryButton, + ); + final PointerDownEvent down = pointer.down(const Offset(10, 10)); + pan.addPointer(down); + tapSecondary.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + expect(recognized, ['tapSecondary']); + }); + + testGesture('A primary pan recognizer forms competion with a primary tap recognizer', (GestureTester tester) { + final TestPointer pointer = TestPointer( + 1, + PointerDeviceKind.touch, + kPrimaryButton, + ); + final PointerDownEvent down = pointer.down(const Offset(10, 10)); + pan.addPointer(down); + tapPrimary.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + expect(recognized, []); + + tester.route(pointer.up()); + expect(recognized, ['tapPrimary']); + }); + }); + + testGesture('A secondary drag should not trigger primary', (GestureTester tester) { + final List recognized = []; + final TapGestureRecognizer tap = TapGestureRecognizer() + ..onTap = () {}; // Need a listener to enable competetion. + final PanGestureRecognizer pan = PanGestureRecognizer() + ..onDown = (DragDownDetails details) { + recognized.add('primaryDown'); + } + ..onStart = (DragStartDetails details) { + recognized.add('primaryStart'); + } + ..onUpdate = (DragUpdateDetails details) { + recognized.add('primaryUpdate'); + } + ..onEnd = (DragEndDetails details) { + recognized.add('primaryEnd'); + } + ..onCancel = () { + recognized.add('primaryCancel'); + }; + + final TestPointer pointer = TestPointer( + 5, + PointerDeviceKind.touch, + kSecondaryButton, + ); + + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + pan.addPointer(down); + tap.addPointer(down); + tester.closeArena(5); + tester.route(down); + tester.route(pointer.move(const Offset(20.0, 30.0))); + tester.route(pointer.move(const Offset(20.0, 25.0))); + tester.route(pointer.up()); + expect(recognized, []); + recognized.clear(); + + pan.dispose(); + tap.dispose(); + recognized.clear(); + }); } diff --git a/packages/flutter/test/gestures/events_test.dart b/packages/flutter/test/gestures/events_test.dart index 17a11e70fb..75ce0bb564 100644 --- a/packages/flutter/test/gestures/events_test.dart +++ b/packages/flutter/test/gestures/events_test.dart @@ -20,6 +20,20 @@ void main() { expect(nthStylusButton(2), kSecondaryStylusButton); }); + testGesture('smallestButton tests', (GestureTester tester) { + expect(smallestButton(0x0), equals(0x0)); + expect(smallestButton(0x1), equals(0x1)); + expect(smallestButton(0x200), equals(0x200)); + expect(smallestButton(0x220), equals(0x20)); + }); + + testGesture('isSingleButton tests', (GestureTester tester) { + expect(isSingleButton(0x0), isFalse); + expect(isSingleButton(0x1), isTrue); + expect(isSingleButton(0x200), isTrue); + expect(isSingleButton(0x220), isFalse); + }); + group('Default values of PointerEvents:', () { // Some parameters are intentionally set to a non-trivial value. diff --git a/packages/flutter/test/gestures/gesture_binding_test.dart b/packages/flutter/test/gestures/gesture_binding_test.dart index 37dfbabf83..3bfdf918da 100644 --- a/packages/flutter/test/gestures/gesture_binding_test.dart +++ b/packages/flutter/test/gestures/gesture_binding_test.dart @@ -318,7 +318,38 @@ void main() { } }); - test('Should not synthesise kPrimaryButton for certain devices', () { + test('Should synthesise kPrimaryButton for unknown devices', () { + final Offset location = const Offset(10.0, 10.0) * ui.window.devicePixelRatio; + const PointerDeviceKind kind = PointerDeviceKind.unknown; + final ui.PointerDataPacket packet = ui.PointerDataPacket( + data: [ + ui.PointerData(change: ui.PointerChange.add, kind: kind, physicalX: location.dx, physicalY: location.dy), + ui.PointerData(change: ui.PointerChange.hover, kind: kind, physicalX: location.dx, physicalY: location.dy), + ui.PointerData(change: ui.PointerChange.down, kind: kind, physicalX: location.dx, physicalY: location.dy), + ui.PointerData(change: ui.PointerChange.move, kind: kind, physicalX: location.dx, physicalY: location.dy), + ui.PointerData(change: ui.PointerChange.up, kind: kind, physicalX: location.dx, physicalY: location.dy), + ] + ); + + final List events = PointerEventConverter.expand( + packet.data, ui.window.devicePixelRatio).toList(); + + expect(events.length, 5); + expect(events[0].runtimeType, equals(PointerAddedEvent)); + expect(events[0].buttons, equals(0)); + expect(events[1].runtimeType, equals(PointerHoverEvent)); + expect(events[1].buttons, equals(0)); + expect(events[2].runtimeType, equals(PointerDownEvent)); + expect(events[2].buttons, equals(kPrimaryButton)); + expect(events[3].runtimeType, equals(PointerMoveEvent)); + expect(events[3].buttons, equals(kPrimaryButton)); + expect(events[4].runtimeType, equals(PointerUpEvent)); + expect(events[4].buttons, equals(0)); + + PointerEventConverter.clearPointers(); + }); + + test('Should not synthesise kPrimaryButton for mouse', () { final Offset location = const Offset(10.0, 10.0) * ui.window.devicePixelRatio; for (PointerDeviceKind kind in [ PointerDeviceKind.mouse, diff --git a/packages/flutter/test/gestures/long_press_test.dart b/packages/flutter/test/gestures/long_press_test.dart index 7653458fac..7588ecd64e 100644 --- a/packages/flutter/test/gestures/long_press_test.dart +++ b/packages/flutter/test/gestures/long_press_test.dart @@ -7,6 +7,7 @@ import 'package:flutter/gestures.dart'; import '../flutter_test_alternative.dart'; import 'gesture_tester.dart'; +// Down/move/up pair 1: normal tap sequence const PointerDownEvent down = PointerDownEvent( pointer: 5, position: Offset(10, 10), @@ -22,6 +23,29 @@ const PointerMoveEvent move = PointerMoveEvent( position: Offset(100, 200), ); +// Down/up pair 2: normal tap sequence far away from pair 1 +const PointerDownEvent down2 = PointerDownEvent( + pointer: 6, + position: Offset(10, 10), +); + +const PointerUpEvent up2 = PointerUpEvent( + pointer: 6, + position: Offset(11, 9), +); + +// Down/up pair 3: tap sequence with secondary button +const PointerDownEvent down3 = PointerDownEvent( + pointer: 7, + position: Offset(30, 30), + buttons: kSecondaryButton, +); + +const PointerUpEvent up3 = PointerUpEvent( + pointer: 7, + position: Offset(31, 29), +); + void main() { setUp(ensureGestureBinding); @@ -187,6 +211,48 @@ void main() { longPress.dispose(); }); + + testGesture('Should not recognize long press with more than one buttons', (GestureTester tester) { + longPress.addPointer(const PointerDownEvent( + pointer: 5, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton | kMiddleMouseButton, + position: Offset(10, 10), + )); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(longPressDown, isFalse); + tester.route(up); + expect(longPressUp, isFalse); + + longPress.dispose(); + }); + + testGesture('Should cancel long press when buttons change before acceptance', (GestureTester tester) { + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isFalse); + tester.route(const PointerMoveEvent( + pointer: 5, + kind: PointerDeviceKind.mouse, + buttons: kMiddleMouseButton, + position: Offset(10, 10), + )); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 700)); + expect(longPressDown, isFalse); + tester.route(up); + expect(longPressUp, isFalse); + + longPress.dispose(); + }); }); group('long press drag', () { @@ -279,6 +345,107 @@ void main() { }); }); + group('Enforce consistent-button restriction:', () { + // In sequence between `down` and `up` but with buttons changed + const PointerMoveEvent moveR = PointerMoveEvent( + pointer: 5, + buttons: kSecondaryButton, + position: Offset(10, 10), + ); + + final List recognized = []; + + LongPressGestureRecognizer longPress; + + setUp(() { + longPress = LongPressGestureRecognizer() + ..onLongPressStart = (LongPressStartDetails details) { + recognized.add('start'); + } + ..onLongPressEnd = (LongPressEndDetails details) { + recognized.add('end'); + }; + }); + + tearDown(() { + longPress.dispose(); + recognized.clear(); + }); + + testGesture('Should cancel long press when buttons change before acceptance', (GestureTester tester) { + // First press + longPress.addPointer(down); + tester.closeArena(down.pointer); + tester.route(down); + tester.async.elapse(const Duration(milliseconds: 300)); + tester.route(moveR); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 700)); + tester.route(up); + expect(recognized, []); + }); + + testGesture('Buttons change before acceptance should not prevent the next long press', (GestureTester tester) { + // First press + longPress.addPointer(down); + tester.closeArena(down.pointer); + tester.route(down); + tester.async.elapse(const Duration(milliseconds: 300)); + tester.route(moveR); + tester.async.elapse(const Duration(milliseconds: 700)); + tester.route(up); + recognized.clear(); + + // Second press + longPress.addPointer(down2); + tester.closeArena(down2.pointer); + tester.route(down2); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(recognized, ['start']); + recognized.clear(); + + tester.route(up2); + expect(recognized, ['end']); + }); + + testGesture('Should cancel long press when buttons change after acceptance', (GestureTester tester) { + // First press + longPress.addPointer(down); + tester.closeArena(down.pointer); + tester.route(down); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(recognized, ['start']); + recognized.clear(); + + tester.route(moveR); + expect(recognized, []); + tester.route(up); + expect(recognized, []); + }); + + testGesture('Buttons change after acceptance should not prevent the next long press', (GestureTester tester) { + // First press + longPress.addPointer(down); + tester.closeArena(down.pointer); + tester.route(down); + tester.async.elapse(const Duration(milliseconds: 1000)); + tester.route(moveR); + tester.route(up); + recognized.clear(); + + // Second press + longPress.addPointer(down2); + tester.closeArena(down2.pointer); + tester.route(down2); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(recognized, ['start']); + recognized.clear(); + + tester.route(up2); + expect(recognized, ['end']); + }); + }); + testGesture('Can filter long press based on device kind', (GestureTester tester) { final LongPressGestureRecognizer mouseLongPress = LongPressGestureRecognizer(kind: PointerDeviceKind.mouse); @@ -318,4 +485,107 @@ void main() { mouseLongPress.dispose(); }); + + group('Recognizers listening on different buttons do not form competition:', () { + // This test is assisted by tap recognizers. If a tap gesture has + // no competing recognizers, a pointer down event triggers its onTapDown + // immediately; if there are competitors, onTapDown is triggered after a + // timeout. + // The following tests make sure that long press recognizers do not form + // competition with a tap gesture recognizer listening on a different button. + + final List recognized = []; + TapGestureRecognizer tapPrimary; + TapGestureRecognizer tapSecondary; + LongPressGestureRecognizer longPress; + setUp(() { + tapPrimary = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('tapPrimary'); + }; + tapSecondary = TapGestureRecognizer() + ..onSecondaryTapDown = (TapDownDetails details) { + recognized.add('tapSecondary'); + }; + longPress = LongPressGestureRecognizer() + ..onLongPressStart = (_) { + recognized.add('longPress'); + }; + }); + + tearDown(() { + recognized.clear(); + tapPrimary.dispose(); + tapSecondary.dispose(); + longPress.dispose(); + }); + + testGesture('A primary long press recognizer does not form competion with a secondary tap recognizer', (GestureTester tester) { + longPress.addPointer(down3); + tapSecondary.addPointer(down3); + tester.closeArena(down3.pointer); + + tester.route(down3); + expect(recognized, ['tapSecondary']); + }); + + testGesture('A primary long press recognizer forms competion with a primary tap recognizer', (GestureTester tester) { + longPress.addPointer(down); + tapPrimary.addPointer(down); + tester.closeArena(down.pointer); + + tester.route(down); + expect(recognized, []); + + tester.route(up); + expect(recognized, ['tapPrimary']); + }); + }); + + testGesture('A secondary long press should not trigger primary', (GestureTester tester) { + final List recognized = []; + final LongPressGestureRecognizer longPress = LongPressGestureRecognizer() + ..onLongPressStart = (LongPressStartDetails details) { + recognized.add('primaryStart'); + } + ..onLongPress = () { + recognized.add('primary'); + } + ..onLongPressMoveUpdate = (LongPressMoveUpdateDetails details) { + recognized.add('primaryUpdate'); + } + ..onLongPressEnd = (LongPressEndDetails details) { + recognized.add('primaryEnd'); + } + ..onLongPressUp = () { + recognized.add('primaryUp'); + }; + + const PointerDownEvent down2 = PointerDownEvent( + pointer: 2, + buttons: kSecondaryButton, + position: Offset(30.0, 30.0), + ); + + const PointerMoveEvent move2 = PointerMoveEvent( + pointer: 2, + buttons: kSecondaryButton, + position: Offset(100, 200), + ); + + const PointerUpEvent up2 = PointerUpEvent( + pointer: 2, + position: Offset(100, 201), + ); + + longPress.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.async.elapse(const Duration(milliseconds: 700)); + tester.route(move2); + tester.route(up2); + expect(recognized, []); + longPress.dispose(); + recognized.clear(); + }); } diff --git a/packages/flutter/test/gestures/tap_test.dart b/packages/flutter/test/gestures/tap_test.dart index e1305ad985..6bbef3d7e1 100644 --- a/packages/flutter/test/gestures/tap_test.dart +++ b/packages/flutter/test/gestures/tap_test.dart @@ -73,6 +73,18 @@ void main() { position: Offset(22.0, 22.0), ); + // Down/up sequence 5: tap sequence with secondary button + const PointerDownEvent down5 = PointerDownEvent( + pointer: 5, + position: Offset(20.0, 20.0), + buttons: kSecondaryButton, + ); + + const PointerUpEvent up5 = PointerUpEvent( + pointer: 5, + position: Offset(20.0, 20.0), + ); + testGesture('Should recognize tap', (GestureTester tester) { final TapGestureRecognizer tap = TapGestureRecognizer(); @@ -439,6 +451,48 @@ void main() { tap.dispose(); }); + testGesture('PointerCancelEvent after exceeding deadline cancels tap', (GestureTester tester) { + const PointerDownEvent down = PointerDownEvent( + pointer: 5, + position: Offset(10.0, 10.0), + ); + const PointerCancelEvent cancel = PointerCancelEvent( + pointer: 5, + position: Offset(10.0, 10.0), + ); + + final TapGestureRecognizer tap = TapGestureRecognizer(); + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer() + ..onStart = (_) {}; // Need a callback to compete + + final List recognized = []; + tap.onTapDown = (_) { + recognized.add('down'); + }; + tap.onTapUp = (_) { + recognized.add('up'); + }; + tap.onTap = () { + recognized.add('tap'); + }; + tap.onTapCancel = () { + recognized.add('cancel'); + }; + + tap.addPointer(down); + drag.addPointer(down); + tester.closeArena(5); + tester.route(down); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(recognized, ['down']); + tester.route(cancel); + expect(recognized, ['down', 'cancel']); + + tap.dispose(); + drag.dispose(); + }); + testGesture('losing tap gesture recognizer does not send onTapCancel', (GestureTester tester) { final TapGestureRecognizer tap = TapGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); @@ -467,4 +521,282 @@ void main() { tap.dispose(); drag.dispose(); }); + + group('Enforce consistent-button restriction:', () { + // Change buttons during down-up sequence 1 + const PointerMoveEvent move1lr = PointerMoveEvent( + pointer: 1, + position: Offset(10.0, 10.0), + buttons: kPrimaryMouseButton | kSecondaryMouseButton, + ); + const PointerMoveEvent move1r = PointerMoveEvent( + pointer: 1, + position: Offset(10.0, 10.0), + buttons: kSecondaryMouseButton, + ); + + final List recognized = []; + TapGestureRecognizer tap; + setUp(() { + tap = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('down'); + } + ..onTapUp = (TapUpDetails details) { + recognized.add('up'); + } + ..onTapCancel = () { + recognized.add('cancel'); + }; + }); + + tearDown(() { + tap.dispose(); + recognized.clear(); + }); + + testGesture('changing buttons before TapDown should cancel gesture without sending cancel', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(1); + expect(recognized, []); + + tester.route(move1lr); + expect(recognized, []); + + tester.route(move1r); + expect(recognized, []); + + tester.route(up1); + expect(recognized, []); + + tap.dispose(); + }); + + testGesture('changing buttons before TapDown should not prevent the next tap', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(1); + + tester.route(move1lr); + tester.route(move1r); + tester.route(up1); + expect(recognized, []); + + tap.addPointer(down2); + tester.closeArena(2); + tester.async.elapse(const Duration(milliseconds: 1000)); + tester.route(up2); + expect(recognized, ['down', 'up']); + + tap.dispose(); + }); + + testGesture('changing buttons after TapDown should cancel gesture and send cancel', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(1); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 1000)); + expect(recognized, ['down']); + + tester.route(move1lr); + expect(recognized, ['down', 'cancel']); + + tester.route(move1r); + expect(recognized, ['down', 'cancel']); + + tester.route(up1); + expect(recognized, ['down', 'cancel']); + + tap.dispose(); + }); + + testGesture('changing buttons after TapDown should not prevent the next tap', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(1); + tester.async.elapse(const Duration(milliseconds: 1000)); + + tester.route(move1lr); + tester.route(move1r); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(recognized, ['down', 'cancel']); + + tap.addPointer(down2); + tester.closeArena(2); + tester.async.elapse(const Duration(milliseconds: 1000)); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(recognized, ['down', 'cancel', 'down', 'up']); + + tap.dispose(); + }); + }); + + group('Recognizers listening on different buttons do not form competition:', () { + // If a tap gesture has no competitors, a pointer down event triggers + // onTapDown immediately; if there are competitors, onTapDown is triggered + // after a timeout. The following tests make sure that tap recognizers + // listening on different buttons do not form competition. + + final List recognized = []; + TapGestureRecognizer primary; + TapGestureRecognizer primary2; + TapGestureRecognizer secondary; + setUp(() { + primary = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('primaryDown'); + } + ..onTapUp = (TapUpDetails details) { + recognized.add('primaryUp'); + } + ..onTapCancel = () { + recognized.add('primaryCancel'); + }; + primary2 = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('primary2Down'); + } + ..onTapUp = (TapUpDetails details) { + recognized.add('primary2Up'); + } + ..onTapCancel = () { + recognized.add('primary2Cancel'); + }; + secondary = TapGestureRecognizer() + ..onSecondaryTapDown = (TapDownDetails details) { + recognized.add('secondaryDown'); + } + ..onSecondaryTapUp = (TapUpDetails details) { + recognized.add('secondaryUp'); + } + ..onSecondaryTapCancel = () { + recognized.add('secondaryCancel'); + }; + }); + + tearDown(() { + recognized.clear(); + primary.dispose(); + primary2.dispose(); + secondary.dispose(); + }); + + testGesture('A primary tap recognizer does not form competion with a secondary tap recognizer', (GestureTester tester) { + primary.addPointer(down1); + secondary.addPointer(down1); + tester.closeArena(1); + + tester.route(down1); + expect(recognized, ['primaryDown']); + recognized.clear(); + + tester.route(up1); + expect(recognized, ['primaryUp']); + }); + + testGesture('A primary tap recognizer forms competion with another primary tap recognizer', (GestureTester tester) { + primary.addPointer(down1); + primary2.addPointer(down1); + tester.closeArena(1); + + tester.route(down1); + expect(recognized, []); + + tester.async.elapse(const Duration(milliseconds: 500)); + expect(recognized, ['primaryDown', 'primary2Down']); + }); + }); + + group('Gestures of different buttons trigger correct callbacks:', () { + final List recognized = []; + TapGestureRecognizer tap; + const PointerCancelEvent cancel1 = PointerCancelEvent( + pointer: 1, + ); + const PointerCancelEvent cancel5 = PointerCancelEvent( + pointer: 5, + ); + + setUp(() { + tap = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + recognized.add('primaryDown'); + } + ..onTap = () { + recognized.add('primary'); + } + ..onTapUp = (TapUpDetails details) { + recognized.add('primaryUp'); + } + ..onTapCancel = () { + recognized.add('primaryCancel'); + } + ..onSecondaryTapDown = (TapDownDetails details) { + recognized.add('secondaryDown'); + } + ..onSecondaryTapUp = (TapUpDetails details) { + recognized.add('secondaryUp'); + } + ..onSecondaryTapCancel = () { + recognized.add('secondaryCancel'); + }; + }); + + tearDown(() { + recognized.clear(); + tap.dispose(); + }); + + testGesture('A primary tap should trigger primary callbacks', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(down1.pointer); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 500)); + expect(recognized, ['primaryDown']); + recognized.clear(); + + tester.route(up1); + expect(recognized, ['primaryUp', 'primary']); + GestureBinding.instance.gestureArena.sweep(down1.pointer); + }); + + testGesture('A primary tap cancel trigger primary callbacks', (GestureTester tester) { + tap.addPointer(down1); + tester.closeArena(down1.pointer); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 500)); + expect(recognized, ['primaryDown']); + recognized.clear(); + + tester.route(cancel1); + expect(recognized, ['primaryCancel']); + GestureBinding.instance.gestureArena.sweep(down1.pointer); + }); + + testGesture('A secondary tap should trigger secondary callbacks', (GestureTester tester) { + tap.addPointer(down5); + tester.closeArena(down5.pointer); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 500)); + expect(recognized, ['secondaryDown']); + recognized.clear(); + + tester.route(up5); + GestureBinding.instance.gestureArena.sweep(down5.pointer); + expect(recognized, ['secondaryUp']); + }); + + testGesture('A secondary tap cancel should trigger secondary callbacks', (GestureTester tester) { + tap.addPointer(down5); + tester.closeArena(down5.pointer); + expect(recognized, []); + tester.async.elapse(const Duration(milliseconds: 500)); + expect(recognized, ['secondaryDown']); + recognized.clear(); + + tester.route(cancel5); + GestureBinding.instance.gestureArena.sweep(down5.pointer); + expect(recognized, ['secondaryCancel']); + }); + }); } diff --git a/packages/flutter/test/widgets/platform_view_test.dart b/packages/flutter/test/widgets/platform_view_test.dart index e61443a600..1576ae08a7 100644 --- a/packages/flutter/test/widgets/platform_view_test.dart +++ b/packages/flutter/test/widgets/platform_view_test.dart @@ -592,7 +592,10 @@ void main() { viewType: 'webview', gestureRecognizers: >{ Factory( - () => VerticalDragGestureRecognizer(), + () { + return VerticalDragGestureRecognizer() + ..onStart = (_) {}; // Add callback to enable recognizer + }, ), }, layoutDirection: TextDirection.ltr, @@ -1263,7 +1266,10 @@ void main() { viewType: 'webview', gestureRecognizers: >{ Factory( - () => VerticalDragGestureRecognizer(), + () { + return VerticalDragGestureRecognizer() + ..onStart = (_) {}; // Add callback to enable recognizer + }, ), }, layoutDirection: TextDirection.ltr, diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart index 6395665a4a..e22545150b 100644 --- a/packages/flutter_test/lib/src/test_pointer.dart +++ b/packages/flutter_test/lib/src/test_pointer.dart @@ -22,9 +22,15 @@ class TestPointer { /// /// Multiple [TestPointer]s created with the same pointer identifier will /// interfere with each other if they are used in parallel. - TestPointer([this.pointer = 1, this.kind = PointerDeviceKind.touch]) + TestPointer([ + this.pointer = 1, + this.kind = PointerDeviceKind.touch, + int buttons = kPrimaryButton, + ]) : assert(kind != null), - assert(pointer != null); + assert(pointer != null), + assert(buttons != null), + _buttons = buttons; /// The pointer identifier used for events generated by this object. /// @@ -35,6 +41,11 @@ class TestPointer { /// [PointerDeviceKind.touch]. final PointerDeviceKind kind; + /// The kind of buttons to simulate on Down and Move events. Defaults to + /// [kPrimaryButton]. + int get buttons => _buttons; + int _buttons; + /// Whether the pointer simulated by this object is currently down. /// /// A pointer is released (goes up) by calling [up] or [cancel]. @@ -51,8 +62,14 @@ class TestPointer { /// If a custom event is created outside of this class, this function is used /// to set the [isDown]. - bool setDownInfo(PointerEvent event, Offset newLocation) { + bool setDownInfo( + PointerEvent event, + Offset newLocation, { + int buttons, + }) { _location = newLocation; + if (buttons != null) + _buttons = buttons; switch (event.runtimeType) { case PointerDownEvent: assert(!isDown); @@ -72,15 +89,25 @@ class TestPointer { /// /// By default, the time stamp on the event is [Duration.zero]. You can give a /// specific time stamp by passing the `timeStamp` argument. - PointerDownEvent down(Offset newLocation, {Duration timeStamp = Duration.zero}) { + /// + /// By default, the set of buttons in the last down or move event is used. + /// You can give a specific set of buttons by passing the `buttons` argument. + PointerDownEvent down( + Offset newLocation, { + Duration timeStamp = Duration.zero, + int buttons, + }) { assert(!isDown); _isDown = true; _location = newLocation; + if (buttons != null) + _buttons = buttons; return PointerDownEvent( timeStamp: timeStamp, kind: kind, pointer: pointer, position: location, + buttons: _buttons, ); } @@ -91,7 +118,14 @@ class TestPointer { /// /// [isDown] must be true when this is called, since move events can only /// be generated when the pointer is down. - PointerMoveEvent move(Offset newLocation, {Duration timeStamp = Duration.zero}) { + /// + /// By default, the set of buttons in the last down or move event is used. + /// You can give a specific set of buttons by passing the `buttons` argument. + PointerMoveEvent move( + Offset newLocation, { + Duration timeStamp = Duration.zero, + int buttons, + }) { assert( isDown, 'Move events can only be generated when the pointer is down. To ' @@ -99,12 +133,15 @@ class TestPointer { 'up, use hover() instead.'); final Offset delta = newLocation - location; _location = newLocation; + if (buttons != null) + _buttons = buttons; return PointerMoveEvent( timeStamp: timeStamp, kind: kind, pointer: pointer, position: newLocation, delta: delta, + buttons: _buttons, ); }