From b94bf87c706cf1193359f54bd48e36c541252903 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Fri, 1 Mar 2019 13:57:10 -0800 Subject: [PATCH] Text selection via mouse (#28290) --- packages/flutter/lib/src/gestures/eager.dart | 7 +- .../flutter/lib/src/gestures/force_press.dart | 7 +- .../flutter/lib/src/gestures/long_press.dart | 2 + .../flutter/lib/src/gestures/monodrag.dart | 21 ++- .../flutter/lib/src/gestures/multidrag.dart | 25 ++- .../flutter/lib/src/gestures/multitap.dart | 14 +- .../flutter/lib/src/gestures/recognizer.dart | 71 +++++++- packages/flutter/lib/src/gestures/scale.dart | 9 +- .../flutter/lib/src/material/text_field.dart | 31 +++- .../flutter/lib/src/rendering/editable.dart | 6 +- .../lib/src/rendering/platform_view.dart | 12 +- .../lib/src/widgets/text_selection.dart | 155 ++++++++++++++++-- packages/flutter/test/gestures/drag_test.dart | 81 ++++++++- .../test/gestures/long_press_test.dart | 40 +++++ .../flutter/test/gestures/multidrag_test.dart | 28 ++++ .../flutter/test/gestures/multitap_test.dart | 77 +++++++++ .../flutter/test/gestures/scale_test.dart | 95 +++++++++++ .../test/material/text_field_test.dart | 96 ++++++++++- .../test/widgets/text_selection_test.dart | 99 ++++++++++- packages/flutter_test/lib/src/controller.dart | 8 +- 20 files changed, 833 insertions(+), 51 deletions(-) diff --git a/packages/flutter/lib/src/gestures/eager.dart b/packages/flutter/lib/src/gestures/eager.dart index d280cab171d..ef5070993f1 100644 --- a/packages/flutter/lib/src/gestures/eager.dart +++ b/packages/flutter/lib/src/gestures/eager.dart @@ -12,8 +12,13 @@ import 'recognizer.dart'; /// all touch events inside the view bounds to the embedded Android view. /// See [AndroidView.gestureRecognizers] for more details. class EagerGestureRecognizer extends OneSequenceGestureRecognizer { + /// Create an eager gesture recognizer. + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + EagerGestureRecognizer({ PointerDeviceKind kind }): super(kind: kind); + @override - void addPointer(PointerDownEvent event) { + void addAllowedPointer(PointerDownEvent event) { // We call startTrackingPointer as this is where OneSequenceGestureRecognizer joins the arena. startTrackingPointer(event.pointer); resolve(GestureDisposition.accepted); diff --git a/packages/flutter/lib/src/gestures/force_press.dart b/packages/flutter/lib/src/gestures/force_press.dart index 6296a3f1f19..765509a67aa 100644 --- a/packages/flutter/lib/src/gestures/force_press.dart +++ b/packages/flutter/lib/src/gestures/force_press.dart @@ -116,16 +116,19 @@ class ForcePressGestureRecognizer extends OneSequenceGestureRecognizer { /// The [interpolation] callback must always return a value in the range 0.0 /// to 1.0 for values of `pressure` that are between `pressureMin` and /// `pressureMax`. + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} ForcePressGestureRecognizer({ this.startPressure = 0.4, this.peakPressure = 0.85, this.interpolation = _inverseLerp, Object debugOwner, + PointerDeviceKind kind, }) : assert(startPressure != null), assert(peakPressure != null), assert(interpolation != null), assert(peakPressure > startPressure), - super(debugOwner: debugOwner); + super(debugOwner: debugOwner, kind: kind); /// A pointer is in contact with the screen and has just pressed with a force /// exceeding the [startPressure]. Consequently, if there were other gesture @@ -205,7 +208,7 @@ class ForcePressGestureRecognizer extends OneSequenceGestureRecognizer { _ForceState _state = _ForceState.ready; @override - void addPointer(PointerEvent event) { + void addAllowedPointer(PointerEvent event) { // If the device has a maximum pressure of less than or equal to 1, it // doesn't have touch pressure sensing capabilities. Do not participate // in the gesture arena. diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index 780f4223d02..abbe4dc4e0b 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -123,10 +123,12 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// can be moved without limit once the long press is accepted. LongPressGestureRecognizer({ double postAcceptSlopTolerance, + PointerDeviceKind kind, Object debugOwner, }) : super( deadline: kLongPressTimeout, postAcceptSlopTolerance: postAcceptSlopTolerance, + kind: kind, debugOwner: debugOwner, ); diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 64223be4dd6..245f8050dbc 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -52,11 +52,14 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// Initialize the object. /// /// [dragStartBehavior] must not be null. + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} DragGestureRecognizer({ Object debugOwner, + PointerDeviceKind kind, this.dragStartBehavior = DragStartBehavior.start, }) : assert(dragStartBehavior != null), - super(debugOwner: debugOwner); + super(debugOwner: debugOwner, kind: kind); /// Configure the behavior of offsets sent to [onStart]. /// @@ -147,7 +150,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { final Map _velocityTrackers = {}; @override - void addPointer(PointerEvent event) { + void addAllowedPointer(PointerEvent event) { startTrackingPointer(event.pointer); _velocityTrackers[event.pointer] = VelocityTracker(); if (_state == _DragState.ready) { @@ -296,7 +299,12 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// track each touch point independently. class VerticalDragGestureRecognizer extends DragGestureRecognizer { /// Create a gesture recognizer for interactions in the vertical axis. - VerticalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + VerticalDragGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); @override bool _isFlingGesture(VelocityEstimate estimate) { @@ -330,7 +338,12 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { /// track each touch point independently. class HorizontalDragGestureRecognizer extends DragGestureRecognizer { /// Create a gesture recognizer for interactions in the horizontal axis. - HorizontalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + HorizontalDragGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); @override bool _isFlingGesture(VelocityEstimate estimate) { diff --git a/packages/flutter/lib/src/gestures/multidrag.dart b/packages/flutter/lib/src/gestures/multidrag.dart index ca028a7b0b9..cf338304bf5 100644 --- a/packages/flutter/lib/src/gestures/multidrag.dart +++ b/packages/flutter/lib/src/gestures/multidrag.dart @@ -189,7 +189,10 @@ abstract class MultiDragPointerState { /// start after a long-press gesture. abstract class MultiDragGestureRecognizer extends GestureRecognizer { /// Initialize the object. - MultiDragGestureRecognizer({ @required Object debugOwner }) : super(debugOwner: debugOwner); + MultiDragGestureRecognizer({ + @required Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); /// Called when this class recognizes the start of a drag gesture. /// @@ -200,7 +203,7 @@ abstract class MultiDragGestureRecognizer exten Map _pointers = {}; @override - void addPointer(PointerDownEvent event) { + void addAllowedPointer(PointerDownEvent event) { assert(_pointers != null); assert(event.pointer != null); assert(event.position != null); @@ -334,7 +337,10 @@ class _ImmediatePointerState extends MultiDragPointerState { /// start after a long-press gesture. class ImmediateMultiDragGestureRecognizer extends MultiDragGestureRecognizer<_ImmediatePointerState> { /// Create a gesture recognizer for tracking multiple pointers at once. - ImmediateMultiDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + ImmediateMultiDragGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); @override _ImmediatePointerState createNewPointerState(PointerDownEvent event) { @@ -380,7 +386,10 @@ class _HorizontalPointerState extends MultiDragPointerState { class HorizontalMultiDragGestureRecognizer extends MultiDragGestureRecognizer<_HorizontalPointerState> { /// Create a gesture recognizer for tracking multiple pointers at once /// but only if they first move horizontally. - HorizontalMultiDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + HorizontalMultiDragGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); @override _HorizontalPointerState createNewPointerState(PointerDownEvent event) { @@ -426,7 +435,10 @@ class _VerticalPointerState extends MultiDragPointerState { class VerticalMultiDragGestureRecognizer extends MultiDragGestureRecognizer<_VerticalPointerState> { /// Create a gesture recognizer for tracking multiple pointers at once /// but only if they first move vertically. - VerticalMultiDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + VerticalMultiDragGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); @override _VerticalPointerState createNewPointerState(PointerDownEvent event) { @@ -528,8 +540,9 @@ class DelayedMultiDragGestureRecognizer extends MultiDragGestureRecognizer<_Dela DelayedMultiDragGestureRecognizer({ this.delay = kLongPressTimeout, Object debugOwner, + PointerDeviceKind kind, }) : assert(delay != null), - super(debugOwner: debugOwner); + super(debugOwner: debugOwner, kind: kind); /// The amount of time the pointer must remain in the same place for the drag /// to be recognized. diff --git a/packages/flutter/lib/src/gestures/multitap.dart b/packages/flutter/lib/src/gestures/multitap.dart index be2b1a5dd3b..70ad8b29034 100644 --- a/packages/flutter/lib/src/gestures/multitap.dart +++ b/packages/flutter/lib/src/gestures/multitap.dart @@ -69,7 +69,12 @@ class _TapTracker { /// quick succession. class DoubleTapGestureRecognizer extends GestureRecognizer { /// Create a gesture recognizer for double taps. - DoubleTapGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + DoubleTapGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); // Implementation notes: // The double tap recognizer can be in one of four states. There's no @@ -100,7 +105,7 @@ class DoubleTapGestureRecognizer extends GestureRecognizer { final Map _trackers = {}; @override - void addPointer(PointerEvent event) { + void addAllowedPointer(PointerEvent event) { // Ignore out-of-bounds second taps. if (_firstTap != null && !_firstTap.isWithinTolerance(event, kDoubleTapSlop)) @@ -318,7 +323,8 @@ class MultiTapGestureRecognizer extends GestureRecognizer { MultiTapGestureRecognizer({ this.longTapDelay = Duration.zero, Object debugOwner, - }) : super(debugOwner: debugOwner); + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); /// A pointer that might cause a tap has contacted the screen at a particular /// location. @@ -345,7 +351,7 @@ class MultiTapGestureRecognizer extends GestureRecognizer { final Map _gestureMap = {}; @override - void addPointer(PointerEvent event) { + void addAllowedPointer(PointerEvent event) { assert(!_gestureMap.containsKey(event.pointer)); _gestureMap[event.pointer] = _TapGesture( gestureRecognizer: this, diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index 3f01c4c03e9..b95fc255a7a 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -33,7 +33,7 @@ typedef RecognizerCallback = T Function(); /// /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. enum DragStartBehavior { - /// Set the initial offset, at the position where the first down even was + /// Set the initial offset, at the position where the first down event was /// detected. down, @@ -58,7 +58,13 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// /// The argument is optional and is only used for debug purposes (e.g. in the /// [toString] serialization). - GestureRecognizer({ this.debugOwner }); + /// + /// {@template flutter.gestures.gestureRecognizer.kind} + /// It's possible to limit this recognizer to a specific [PointerDeviceKind] + /// by providing the optional [kind] argument. If [kind] is null, + /// the recognizer will accept pointer events from all device kinds. + /// {@endtemplate} + GestureRecognizer({ this.debugOwner, PointerDeviceKind kind }) : _kind = kind; /// The recognizer's owner. /// @@ -66,6 +72,10 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// this gesture recognizer was created, to aid in debugging. final Object debugOwner; + /// The kind of device that's allowed to be recognized. If null, events from + /// all device kinds will be tracked and recognized. + final PointerDeviceKind _kind; + /// Registers a new pointer that might be relevant to this gesture /// detector. /// @@ -78,7 +88,43 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// subsequent events for this pointer, and to add the pointer to /// the global gesture arena manager (see [GestureArenaManager]) to track /// that pointer. - void addPointer(PointerDownEvent event); + /// + /// This method is called for each and all pointers being added. In + /// most cases, you want to override [addAllowedPointer] instead. + void addPointer(PointerDownEvent event) { + if (isPointerAllowed(event)) { + addAllowedPointer(event); + } else { + handleNonAllowedPointer(event); + } + } + + /// Registers a new pointer that's been checked to be allowed by this gesture + /// recognizer. + /// + /// Subclasses of [GestureRecognizer] are supposed to override this method + /// instead of [addPointer] because [addPointer] will be called for each + /// pointer being added while [addAllowedPointer] is only called for pointers + /// that are allowed by this recognizer. + @protected + void addAllowedPointer(PointerDownEvent event) { } + + /// Handles a pointer being added that's not allowed by this recognizer. + /// + /// Subclasses can override this method and reject the gesture. + /// + /// See: + /// - [OneSequenceGestureRecognizer.handleNonAllowedPointer]. + @protected + void handleNonAllowedPointer(PointerDownEvent event) { } + + /// Checks whether or not a pointer is allowed to be tracked by this recognizer. + @protected + bool isPointerAllowed(PointerDownEvent event) { + // Currently, it only checks for device kind. But in the future we could check + // for other things e.g. mouse button. + return _kind == null || _kind == event.kind; + } /// Releases any resources used by the object. /// @@ -151,11 +197,21 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// simultaneous touches to each result in a separate tap. abstract class OneSequenceGestureRecognizer extends GestureRecognizer { /// Initialize the object. - OneSequenceGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + OneSequenceGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); final Map _entries = {}; final Set _trackedPointers = HashSet(); + @override + void handleNonAllowedPointer(PointerDownEvent event) { + resolve(GestureDisposition.rejected); + } + /// Called when a pointer event is routed to this recognizer. @protected void handleEvent(PointerEvent event); @@ -291,11 +347,14 @@ enum GestureRecognizerState { /// in the gesture arena, the gesture will be rejected. abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecognizer { /// Initializes the [deadline] field during construction of subclasses. + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} PrimaryPointerGestureRecognizer({ this.deadline, this.preAcceptSlopTolerance = kTouchSlop, this.postAcceptSlopTolerance = kTouchSlop, Object debugOwner, + PointerDeviceKind kind, }) : assert( preAcceptSlopTolerance == null || preAcceptSlopTolerance >= 0, 'The preAcceptSlopTolerance must be positive or null', @@ -304,7 +363,7 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni postAcceptSlopTolerance == null || postAcceptSlopTolerance >= 0, 'The postAcceptSlopTolerance must be positive or null', ), - super(debugOwner: debugOwner); + super(debugOwner: debugOwner, kind: kind); /// If non-null, the recognizer will call [didExceedDeadline] after this /// amount of time has elapsed since starting to track the primary pointer. @@ -346,7 +405,7 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni Timer _timer; @override - void addPointer(PointerDownEvent event) { + void addAllowedPointer(PointerDownEvent event) { startTrackingPointer(event.pointer); if (state == GestureRecognizerState.ready) { state = GestureRecognizerState.possible; diff --git a/packages/flutter/lib/src/gestures/scale.dart b/packages/flutter/lib/src/gestures/scale.dart index 34da3b93e98..ee501353b23 100644 --- a/packages/flutter/lib/src/gestures/scale.dart +++ b/packages/flutter/lib/src/gestures/scale.dart @@ -183,7 +183,12 @@ class _LineBetweenPointers{ /// are no longer in contact with the screen, the recognizer calls [onEnd]. class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { /// Create a gesture recognizer for interactions intended for scaling content. - ScaleGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); + /// + /// {@macro flutter.gestures.gestureRecognizer.kind} + ScaleGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); /// The pointers in contact with the screen have established a focal point and /// initial scale of 1.0. @@ -239,7 +244,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { } @override - void addPointer(PointerEvent event) { + void addAllowedPointer(PointerEvent event) { startTrackingPointer(event.pointer); _velocityTrackers[event.pointer] = VelocityTracker(); if (_state == _ScaleState.ready) { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 083665b908b..9b8b141e9d2 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -599,12 +599,12 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi } } - InteractiveInkFeature _createInkFeature(TapDownDetails details) { + InteractiveInkFeature _createInkFeature(Offset globalPosition) { final MaterialInkController inkController = Material.of(context); final ThemeData themeData = Theme.of(context); final BuildContext editableContext = _editableTextKey.currentContext; final RenderBox referenceBox = InputDecorator.containerOf(editableContext) ?? editableContext.findRenderObject(); - final Offset position = referenceBox.globalToLocal(details.globalPosition); + final Offset position = referenceBox.globalToLocal(globalPosition); final Color color = themeData.splashColor; InteractiveInkFeature splash; @@ -637,7 +637,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi void _handleTapDown(TapDownDetails details) { _renderEditable.handleTapDown(details); - _startSplash(details); + _startSplash(details.globalPosition); } void _handleForcePressStarted(ForcePressDetails details) { @@ -723,10 +723,29 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi } } - void _startSplash(TapDownDetails details) { + void _handleDragSelectionStart(DragStartDetails details) { + _renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + _startSplash(details.globalPosition); + } + + void _handleDragSelectionUpdate( + DragStartDetails startDetails, + DragUpdateDetails updateDetails, + ) { + _renderEditable.selectPositionAt( + from: startDetails.globalPosition, + to: updateDetails.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + + void _startSplash(Offset globalPosition) { if (_effectiveFocusNode.hasFocus) return; - final InteractiveInkFeature splash = _createInkFeature(details); + final InteractiveInkFeature splash = _createInkFeature(globalPosition); _splashes ??= HashSet(); _splashes.add(splash); _currentSplash = splash; @@ -888,6 +907,8 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate, onSingleLongTapEnd: _handleSingleLongTapEnd, onDoubleTapDown: _handleDoubleTapDown, + onDragSelectionStart: _handleDragSelectionStart, + onDragSelectionUpdate: _handleDragSelectionUpdate, behavior: HitTestBehavior.translucent, child: child, ), diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index cbb949246ba..adbbc63f793 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -55,6 +55,10 @@ enum SelectionChangedCause { /// Keyboard-triggered selection changes may be caused by the IME as well as /// by accessibility tools (e.g. TalkBack on Android). keyboard, + + /// The user used the mouse to change the selection by dragging over a piece + /// of text. + drag, } /// Signature for the callback that reports when the caret location changes. @@ -1235,7 +1239,7 @@ class RenderEditable extends RenderBox { } /// If [ignorePointer] is false (the default) then this method is called by - /// the internal gesture recognizer's [LongPressRecognizer.onLongPress] + /// the internal gesture recognizer's [LongPressGestureRecognizer.onLongPress] /// callback. /// /// When [ignorePointer] is true, an ancestor widget must respond to long diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index d5345aab6bd..189cedfb42a 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -367,7 +367,9 @@ class RenderUiKitView extends RenderBox { // When the team wins a gesture the recognizer notifies the engine that it should release // the touch sequence to the embedded UIView. class _UiKitViewGestureRecognizer extends OneSequenceGestureRecognizer { - _UiKitViewGestureRecognizer(this.controller, this.gestureRecognizerFactories) { + _UiKitViewGestureRecognizer(this.controller, this.gestureRecognizerFactories, { + PointerDeviceKind kind, + }): super(kind: kind) { team = GestureArenaTeam(); team.captain = this; _gestureRecognizers = gestureRecognizerFactories.map( @@ -387,7 +389,7 @@ class _UiKitViewGestureRecognizer extends OneSequenceGestureRecognizer { final UiKitViewController controller; @override - void addPointer(PointerDownEvent event) { + void addAllowedPointer(PointerDownEvent event) { startTrackingPointer(event.pointer); for (OneSequenceGestureRecognizer recognizer in _gestureRecognizers) { recognizer.addPointer(event); @@ -427,7 +429,9 @@ class _UiKitViewGestureRecognizer extends OneSequenceGestureRecognizer { // When the team wins the recognizer sends all the cached point events to the embedded Android view, and // sets itself to a "forwarding mode" where it will forward any new pointer event to the Android view. class _AndroidViewGestureRecognizer extends OneSequenceGestureRecognizer { - _AndroidViewGestureRecognizer(this.dispatcher, this.gestureRecognizerFactories) { + _AndroidViewGestureRecognizer(this.dispatcher, this.gestureRecognizerFactories, { + PointerDeviceKind kind, + }): super(kind: kind) { team = GestureArenaTeam(); team.captain = this; _gestureRecognizers = gestureRecognizerFactories.map( @@ -456,7 +460,7 @@ class _AndroidViewGestureRecognizer extends OneSequenceGestureRecognizer { Set _gestureRecognizers; @override - void addPointer(PointerDownEvent event) { + void addAllowedPointer(PointerDownEvent event) { startTrackingPointer(event.pointer); for (OneSequenceGestureRecognizer recognizer in _gestureRecognizers) { recognizer.addPointer(event); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index c3e35cda314..9ae01cb6f5a 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -8,7 +8,7 @@ import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/gestures.dart'; import 'basic.dart'; import 'container.dart'; @@ -20,6 +20,10 @@ import 'transitions.dart'; export 'package:flutter/services.dart' show TextSelectionDelegate; +/// A duration that controls how often the drag selection update callback is +/// called. +const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); + /// Which type of selection handle to be displayed. /// /// With mixed-direction text, both handles may be the same type. Examples: @@ -64,6 +68,19 @@ enum _TextSelectionHandlePosition { start, end } /// Used by [TextSelectionOverlay.onSelectionOverlayChanged]. typedef TextSelectionOverlayChanged = void Function(TextEditingValue value, Rect caretRect); +/// Signature for when a pointer that's dragging to select text has moved again. +/// +/// The first argument [startDetails] contains the details of the event that +/// initiated the dragging. +/// +/// The second argument [updateDetails] contains the details of the current +/// pointer movement. It's the same as the one passed to [DragGestureRecognizer.onUpdate]. +/// +/// This signature is different from [GestureDragUpdateCallback] to make it +/// easier for various text fields to use [TextSelectionGestureDetector] without +/// having to store the start position. +typedef DragSelectionUpdateCallback = void Function(DragStartDetails startDetails, DragUpdateDetails updateDetails); + /// An interface for building the selection UI, to be provided by the /// implementor of the toolbar widget. /// @@ -620,6 +637,9 @@ class TextSelectionGestureDetector extends StatefulWidget { this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, this.onDoubleTapDown, + this.onDragSelectionStart, + this.onDragSelectionUpdate, + this.onDragSelectionEnd, this.behavior, @required this.child, }) : assert(child != null), @@ -664,6 +684,19 @@ class TextSelectionGestureDetector extends StatefulWidget { /// time (within [kDoubleTapTimeout]) to a previous short tap. final GestureTapDownCallback onDoubleTapDown; + /// Called when a mouse starts dragging to select text. + final GestureDragStartCallback onDragSelectionStart; + + /// Called repeatedly as a mouse moves while dragging. + /// + /// The frequency of calls is throttled to avoid excessive text layout + /// operations in text fields. The throttling is controlled by the constant + /// [_kDragSelectionUpdateThrottle]. + final DragSelectionUpdateCallback onDragSelectionUpdate; + + /// Called when a mouse that was previously dragging is released. + final GestureDragEndCallback onDragSelectionEnd; + /// How this gesture detector should behave during hit testing. /// /// This defaults to [HitTestBehavior.deferToChild]. @@ -687,6 +720,7 @@ class _TextSelectionGestureDetectorState extends State gestures = {}; + + gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTapCancel = _handleTapCancel; + }, + ); + + if (widget.onSingleLongTapStart != null || + widget.onSingleLongTapMoveUpdate != null || + widget.onSingleLongTapEnd != null) { + gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + } + + if (widget.onDragSelectionStart != null || + widget.onDragSelectionUpdate != null || + widget.onDragSelectionEnd != null) { + // TODO(mdebbar): Support dragging in any direction (for multiline text). + // https://github.com/flutter/flutter/issues/28676 + gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => HorizontalDragGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.mouse), + (HorizontalDragGestureRecognizer instance) { + instance + // Text selection should start from the position of the first pointer + // down event. + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd; + }, + ); + } + + if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this), + (ForcePressGestureRecognizer instance) { + instance + ..onStart = widget.onForcePressStart != null ? _forcePressStarted : null + ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, excludeFromSemantics: true, behavior: widget.behavior, child: widget.child, diff --git a/packages/flutter/test/gestures/drag_test.dart b/packages/flutter/test/gestures/drag_test.dart index 14195d04871..42ae065a5ee 100644 --- a/packages/flutter/test/gestures/drag_test.dart +++ b/packages/flutter/test/gestures/drag_test.dart @@ -517,4 +517,83 @@ void main() { tester.route(pointer.up()); drag.dispose(); }); -} \ No newline at end of file + + testGesture('Can filter drags based on device kind', (GestureTester tester) { + final HorizontalDragGestureRecognizer drag = + HorizontalDragGestureRecognizer( + kind: PointerDeviceKind.mouse, + ) + ..dragStartBehavior = DragStartBehavior.down; + + bool didStartDrag = false; + drag.onStart = (_) { + didStartDrag = true; + }; + + double updatedDelta; + drag.onUpdate = (DragUpdateDetails details) { + updatedDelta = details.primaryDelta; + }; + + bool didEndDrag = false; + drag.onEnd = (DragEndDetails details) { + didEndDrag = true; + }; + + // Using a touch pointer to drag shouldn't be recognized. + final TestPointer touchPointer = TestPointer(5, PointerDeviceKind.touch); + final PointerDownEvent touchDown = touchPointer.down(const Offset(10.0, 10.0)); + drag.addPointer(touchDown); + tester.closeArena(5); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(touchDown); + // Still doesn't recognize the drag because it's coming from a touch pointer. + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(touchPointer.move(const Offset(20.0, 25.0))); + // Still doesn't recognize the drag because it's coming from a touch pointer. + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(touchPointer.up()); + // Still doesn't recognize the drag because it's coming from a touch pointer. + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + // Using a mouse pointer to drag should be recognized. + final TestPointer mousePointer = TestPointer(5, PointerDeviceKind.mouse); + final PointerDownEvent mouseDown = mousePointer.down(const Offset(10.0, 10.0)); + drag.addPointer(mouseDown); + tester.closeArena(5); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(mouseDown); + expect(didStartDrag, isTrue); + didStartDrag = false; + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(mousePointer.move(const Offset(20.0, 25.0))); + expect(didStartDrag, isFalse); + expect(updatedDelta, 10.0); + updatedDelta = null; + expect(didEndDrag, isFalse); + + tester.route(mousePointer.up()); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isTrue); + didEndDrag = false; + + drag.dispose(); + }); +} diff --git a/packages/flutter/test/gestures/long_press_test.dart b/packages/flutter/test/gestures/long_press_test.dart index 39035092443..7653458fac0 100644 --- a/packages/flutter/test/gestures/long_press_test.dart +++ b/packages/flutter/test/gestures/long_press_test.dart @@ -278,4 +278,44 @@ void main() { longPressDrag.dispose(); }); }); + + testGesture('Can filter long press based on device kind', (GestureTester tester) { + final LongPressGestureRecognizer mouseLongPress = LongPressGestureRecognizer(kind: PointerDeviceKind.mouse); + + bool mouseLongPressDown = false; + mouseLongPress.onLongPress = () { + mouseLongPressDown = true; + }; + + const PointerDownEvent mouseDown = PointerDownEvent( + pointer: 5, + position: Offset(10, 10), + kind: PointerDeviceKind.mouse, + ); + const PointerDownEvent touchDown = PointerDownEvent( + pointer: 5, + position: Offset(10, 10), + kind: PointerDeviceKind.touch, + ); + + // Touch events shouldn't be recognized. + mouseLongPress.addPointer(touchDown); + tester.closeArena(5); + expect(mouseLongPressDown, isFalse); + tester.route(touchDown); + expect(mouseLongPressDown, isFalse); + tester.async.elapse(const Duration(seconds: 2)); + expect(mouseLongPressDown, isFalse); + + // Mouse events are still recognized. + mouseLongPress.addPointer(mouseDown); + tester.closeArena(5); + expect(mouseLongPressDown, isFalse); + tester.route(mouseDown); + expect(mouseLongPressDown, isFalse); + tester.async.elapse(const Duration(seconds: 2)); + expect(mouseLongPressDown, isTrue); + + mouseLongPress.dispose(); + }); } diff --git a/packages/flutter/test/gestures/multidrag_test.dart b/packages/flutter/test/gestures/multidrag_test.dart index 207b3c0ff6e..5b8f8b9ca7c 100644 --- a/packages/flutter/test/gestures/multidrag_test.dart +++ b/packages/flutter/test/gestures/multidrag_test.dart @@ -61,4 +61,32 @@ void main() { expect(didStartDrag, isTrue); drag.dispose(); }); + + testGesture('MultiDrag: can filter based on device kind', (GestureTester tester) { + final DelayedMultiDragGestureRecognizer drag = + DelayedMultiDragGestureRecognizer(kind: PointerDeviceKind.touch); + + bool didStartDrag = false; + drag.onStart = (Offset position) { + didStartDrag = true; + return TestDrag(); + }; + + final TestPointer mousePointer = TestPointer(5, PointerDeviceKind.mouse); + final PointerDownEvent down = mousePointer.down(const Offset(10.0, 10.0)); + drag.addPointer(down); + tester.closeArena(5); + expect(didStartDrag, isFalse); + tester.async.flushMicrotasks(); + expect(didStartDrag, isFalse); + tester.route(mousePointer.move(const Offset(20.0, 20.0))); // move less than touch slop before delay expires + expect(didStartDrag, isFalse); + tester.async.elapse(kLongPressTimeout * 2); // expire delay + // Still false because it shouldn't recognize mouse events. + expect(didStartDrag, isFalse); + tester.route(mousePointer.move(const Offset(30.0, 70.0))); // move more than touch slop after delay expires + // And still false. + expect(didStartDrag, isFalse); + drag.dispose(); + }); } diff --git a/packages/flutter/test/gestures/multitap_test.dart b/packages/flutter/test/gestures/multitap_test.dart index 2482421c6e8..2ca54209684 100644 --- a/packages/flutter/test/gestures/multitap_test.dart +++ b/packages/flutter/test/gestures/multitap_test.dart @@ -69,4 +69,81 @@ void main() { tap.dispose(); }); + + testGesture('Can filter based on device kind', (GestureTester tester) { + final MultiTapGestureRecognizer tap = + MultiTapGestureRecognizer( + longTapDelay: kLongPressTimeout, + kind: PointerDeviceKind.touch, + ); + + final List log = []; + + tap.onTapDown = (int pointer, TapDownDetails details) { log.add('tap-down $pointer'); }; + tap.onTapUp = (int pointer, TapUpDetails details) { log.add('tap-up $pointer'); }; + tap.onTap = (int pointer) { log.add('tap $pointer'); }; + tap.onLongTapDown = (int pointer, TapDownDetails details) { log.add('long-tap-down $pointer'); }; + tap.onTapCancel = (int pointer) { log.add('tap-cancel $pointer'); }; + + + final TestPointer touchPointer5 = TestPointer(5, PointerDeviceKind.touch); + final PointerDownEvent down5 = touchPointer5.down(const Offset(10.0, 10.0)); + tap.addPointer(down5); + tester.closeArena(5); + expect(log, ['tap-down 5']); + log.clear(); + tester.route(down5); + expect(log, isEmpty); + + final TestPointer mousePointer6 = TestPointer(6, PointerDeviceKind.mouse); + final PointerDownEvent down6 = mousePointer6.down(const Offset(20.0, 20.0)); + tap.addPointer(down6); + tester.closeArena(6); + // Mouse down should be ignored by the recognizer. + expect(log, isEmpty); + + final TestPointer touchPointer7 = TestPointer(7, PointerDeviceKind.touch); + final PointerDownEvent down7 = touchPointer7.down(const Offset(15.0, 15.0)); + tap.addPointer(down7); + tester.closeArena(7); + expect(log, ['tap-down 7']); + log.clear(); + tester.route(down7); + expect(log, isEmpty); + + tester.route(touchPointer5.move(const Offset(11.0, 12.0))); + expect(log, isEmpty); + + // Move within the [kTouchSlop] range. + tester.route(mousePointer6.move(const Offset(21.0, 18.0))); + // Move beyond the slop range. + tester.route(mousePointer6.move(const Offset(50.0, 40.0))); + // Neither triggers any event because they originate from a mouse. + expect(log, isEmpty); + + tester.route(touchPointer7.move(const Offset(14.0, 13.0))); + expect(log, isEmpty); + + tester.route(touchPointer5.up()); + expect(log, [ + 'tap-up 5', + 'tap 5', + ]); + log.clear(); + + // Mouse up should be ignored. + tester.route(mousePointer6.up()); + expect(log, isEmpty); + + tester.async.elapse(kLongPressTimeout + kPressTimeout); + // Only the touch pointer (7) triggers a long-tap, not the mouse pointer (6). + expect(log, ['long-tap-down 7']); + log.clear(); + + tester.route(touchPointer7.move(const Offset(40.0, 30.0))); // move more than kTouchSlop from 15.0,15.0 + expect(log, ['tap-cancel 7']); + log.clear(); + + tap.dispose(); + }); } diff --git a/packages/flutter/test/gestures/scale_test.dart b/packages/flutter/test/gestures/scale_test.dart index e201d318b44..42c0ef91af3 100644 --- a/packages/flutter/test/gestures/scale_test.dart +++ b/packages/flutter/test/gestures/scale_test.dart @@ -220,6 +220,101 @@ void main() { tap.dispose(); }); + testGesture('Rejects scale gestures from unallowed device kinds', (GestureTester tester) { + final ScaleGestureRecognizer scale = ScaleGestureRecognizer(kind: PointerDeviceKind.touch); + + bool didStartScale = false; + scale.onStart = (ScaleStartDetails details) { + didStartScale = true; + }; + + double updatedScale; + scale.onUpdate = (ScaleUpdateDetails details) { + updatedScale = details.scale; + }; + + final TestPointer mousePointer = TestPointer(1, PointerDeviceKind.mouse); + + final PointerDownEvent down = mousePointer.down(const Offset(0.0, 0.0)); + scale.addPointer(down); + tester.closeArena(1); + + // One-finger panning + tester.route(down); + expect(didStartScale, isFalse); + expect(updatedScale, isNull); + + // Using a mouse, the scale gesture shouldn't even start. + tester.route(mousePointer.move(const Offset(20.0, 30.0))); + expect(didStartScale, isFalse); + expect(updatedScale, isNull); + + scale.dispose(); + }); + + testGesture('Scale gestures starting from allowed device kinds cannot be ended from unallowed devices', (GestureTester tester) { + final ScaleGestureRecognizer scale = ScaleGestureRecognizer(kind: PointerDeviceKind.touch); + + bool didStartScale = false; + Offset updatedFocalPoint; + scale.onStart = (ScaleStartDetails details) { + didStartScale = true; + updatedFocalPoint = details.focalPoint; + }; + + double updatedScale; + scale.onUpdate = (ScaleUpdateDetails details) { + updatedScale = details.scale; + updatedFocalPoint = details.focalPoint; + }; + + bool didEndScale = false; + scale.onEnd = (ScaleEndDetails details) { + didEndScale = true; + }; + + final TestPointer touchPointer = TestPointer(1, PointerDeviceKind.touch); + + final PointerDownEvent down = touchPointer.down(const Offset(0.0, 0.0)); + scale.addPointer(down); + tester.closeArena(1); + + // One-finger panning + tester.route(down); + expect(didStartScale, isTrue); + didStartScale = false; + expect(updatedScale, isNull); + expect(updatedFocalPoint, const Offset(0.0, 0.0)); + expect(didEndScale, isFalse); + + // The gesture can start using one touch finger. + tester.route(touchPointer.move(const Offset(20.0, 30.0))); + expect(updatedFocalPoint, const Offset(20.0, 30.0)); + updatedFocalPoint = null; + expect(updatedScale, 1.0); + updatedScale = null; + expect(didEndScale, isFalse); + + // Two-finger scaling + final TestPointer mousePointer = TestPointer(2, PointerDeviceKind.mouse); + final PointerDownEvent down2 = mousePointer.down(const Offset(10.0, 20.0)); + scale.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + + // Mouse-generated events are ignored. + expect(didEndScale, isFalse); + expect(updatedScale, isNull); + expect(didStartScale, isFalse); + + // Zoom in using a mouse doesn't work either. + tester.route(mousePointer.move(const Offset(0.0, 10.0))); + expect(updatedScale, isNull); + expect(didEndScale, isFalse); + + scale.dispose(); + }); + testGesture('Scale gesture competes with drag', (GestureTester tester) { final ScaleGestureRecognizer scale = ScaleGestureRecognizer(); final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 198941e882f..09e37a91881 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -12,7 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind; import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; @@ -533,6 +533,37 @@ void main() { expect(controller.selection.extentOffset, testValue.indexOf('f')+1); }); + testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + controller: controller, + ), + ) + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(controller.value.text, testValue); + await skipPastScrollingAnimation(tester); + + expect(controller.selection.isCollapsed, true); + + // Long press the 'e' using a mouse device. + final int eIndex = testValue.indexOf('e'); + final Offset ePos = textOffsetToPosition(tester, eIndex); + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + + // The cursor is placed just like a regular tap. + expect(controller.selection.baseOffset, eIndex); + expect(controller.selection.extentOffset, eIndex); + }); + testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -564,6 +595,69 @@ void main() { expect(controller.selection.extentOffset, -1); }); + testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }); + + testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.moveTo(gPos); + await tester.pump(); + await gesture.up(); + + expect(controller.selection.baseOffset, testValue.indexOf('e')); + expect(controller.selection.extentOffset, testValue.indexOf('g')); + }); + testWidgets('Can drag handles to change selection', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 1d112dc2a86..64d4d8c1076 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/widgets.dart'; void main() { @@ -13,9 +14,11 @@ void main() { int doubleTapDownCount; int forcePressStartCount; int forcePressEndCount; + int dragStartCount; + int dragUpdateCount; + int dragEndCount; const Offset forcePressOffset = Offset(400.0, 50.0); - void _handleTapDown(TapDownDetails details) { tapCount++; } void _handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; } void _handleSingleTapCancel() { singleTapCancelCount++; } @@ -23,6 +26,9 @@ void main() { void _handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; } void _handleForcePressStart(ForcePressDetails details) { forcePressStartCount++; } void _handleForcePressEnd(ForcePressDetails details) { forcePressEndCount++; } + void _handleDragSelectionStart(DragStartDetails details) { dragStartCount++; } + void _handleDragSelectionUpdate(DragStartDetails _, DragUpdateDetails details) { dragUpdateCount++; } + void _handleDragSelectionEnd(DragEndDetails details) { dragEndCount++; } setUp(() { tapCount = 0; @@ -32,6 +38,9 @@ void main() { doubleTapDownCount = 0; forcePressStartCount = 0; forcePressEndCount = 0; + dragStartCount = 0; + dragUpdateCount = 0; + dragEndCount = 0; }); Future pumpGestureDetector(WidgetTester tester) async { @@ -45,6 +54,9 @@ void main() { onDoubleTapDown: _handleDoubleTapDown, onForcePressStart: _handleForcePressStart, onForcePressEnd: _handleForcePressEnd, + onDragSelectionStart: _handleDragSelectionStart, + onDragSelectionUpdate: _handleDragSelectionUpdate, + onDragSelectionEnd: _handleDragSelectionEnd, child: Container(), ), ); @@ -275,4 +287,89 @@ void main() { expect(forcePressEndCount, 1); expect(doubleTapDownCount, 0); }); + + testWidgets('a long press from a touch device is recognized as a long single tap', (WidgetTester tester) async { + await pumpGestureDetector(tester); + + const int pointerValue = 1; + final TestGesture gesture = + await tester.startGesture(const Offset(200.0, 200.0), pointer: pointerValue, kind: PointerDeviceKind.touch); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tapCount, 1); + expect(singleTapUpCount, 0); + expect(singleLongTapStartCount, 1); + }); + + testWidgets('a long press from a mouse is just a tap', (WidgetTester tester) async { + await pumpGestureDetector(tester); + + const int pointerValue = 1; + final TestGesture gesture = + await tester.startGesture(const Offset(200.0, 200.0), pointer: pointerValue, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tapCount, 1); + expect(singleTapUpCount, 1); + expect(singleLongTapStartCount, 0); + }); + + testWidgets('a touch drag is not recognized for text selection', (WidgetTester tester) async { + await pumpGestureDetector(tester); + + const int pointerValue = 1; + final TestGesture gesture = + await tester.startGesture(const Offset(200.0, 200.0), pointer: pointerValue, kind: PointerDeviceKind.touch); + await tester.pump(); + await gesture.moveBy(const Offset(210.0, 200.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tapCount, 0); + expect(singleTapUpCount, 0); + expect(dragStartCount, 0); + expect(dragUpdateCount, 0); + expect(dragEndCount, 0); + }); + + testWidgets('a mouse drag is recognized for text selection', (WidgetTester tester) async { + await pumpGestureDetector(tester); + + const int pointerValue = 1; + final TestGesture gesture = + await tester.startGesture(const Offset(200.0, 200.0), pointer: pointerValue, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.moveBy(const Offset(210.0, 200.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tapCount, 0); + expect(singleTapUpCount, 0); + expect(dragStartCount, 1); + expect(dragUpdateCount, 1); + expect(dragEndCount, 1); + }); + + testWidgets('a slow mouse drag is still recognized for text selection', (WidgetTester tester) async { + await pumpGestureDetector(tester); + + const int pointerValue = 1; + final TestGesture gesture = + await tester.startGesture(const Offset(200.0, 200.0), pointer: pointerValue, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(seconds: 2)); + await gesture.moveBy(const Offset(210.0, 200.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(dragStartCount, 1); + expect(dragUpdateCount, 1); + expect(dragEndCount, 1); + }); } diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 34804f7d3d0..92d0ac8ce57 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -553,8 +553,12 @@ abstract class WidgetController { /// /// You can use [createGesture] if your gesture doesn't begin with an initial /// down gesture. - Future startGesture(Offset downLocation, {int pointer}) async { - final TestGesture result = await createGesture(pointer: pointer); + Future startGesture( + Offset downLocation, { + int pointer, + PointerDeviceKind kind = PointerDeviceKind.touch, + }) async { + final TestGesture result = await createGesture(pointer: pointer, kind: kind); await result.down(downLocation); return result; }