From cd0f15a770992ec41445bd212aac73572c278fd1 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Tue, 20 Dec 2022 17:01:04 -0800 Subject: [PATCH] Add support for double tap and drag for text selection (#109573) * Replace PanGestureRecognizer in TextSelection with TapAndDragGestureRecognizer * add tracking of _DragState to new tap_and_drag recognizer and remove some legacy double tap code from text_selection.dart and add logs" * add dragTapCount, a tap count that is persistent for an entire drag and is set to null on drag end vs the regular tap count which is reset on a timer * basic double tap to drag functionality and add a local dragTapCount in text_selection.dart to use with the timer callback * Add offsetFromOrigin and localOffsetFromOrigin to DragUpdateDetails similar to LongPressMoveUpdateDetails, eliminates the need to hold the state of lastDragStartDetails * make a generic baselongpressgesturerecognizer * Revert "make a generic baselongpressgesturerecognizer" This reverts commit aad8f7433bd01e4cd016d527af832c3b1f15fac5. * rename tap_and_drag to selection_recognizers * add mixin for consecutivetap * tap and long press gesture recognizer * Revert "Revert "make a generic baselongpressgesturerecognizer"" This reverts commit 181350c36718f644eada3e45c1b7b5939f90a340. * Revert "Revert "Revert "make a generic baselongpressgesturerecognizer""" This reverts commit 4d69775967858dfd66dd9429e1713da598908a85. * Add support for secondary button clicks on drag gesture recognizer and separate drag end and tap up callback * get test running * rename tapCount to consecutiveTapCount * dispose timer properly * add some comments to tests * Add comments * Make ConsecutiveTapMixin private and move logic to increment tap count into mixin * stop tracking pointer when gesture is rejected and detect drags on touch devices * onCancel for TapAndDrag * have the TapAndDragGestureRecognizer handle tap downs and tap ups on touch and mouse devices * add drag to move cursor for android and iOS, and pointer device kind to DragUpdateDetails * get tests running * refactor TapAndDragGestureRecognizer moving some logic into _check methods * Handle cancel properly on TapAndDragGestureRecognizer, having both onTapCancel and onDragCancel, also fix tests * Fix test mouse drag selects and cannot drag cursor, save _initialPosition based on dragStartBehavior (either on tapDown or dragStart) * determine if drag has a sufficient global distance to accept and fix some cancel behavior, making _checkCancel clearer * give up pointer on drag end * properly stop tracking pointer, fixes test for right click on Apple and non-apple platforms * clean up some comments from last commit * remove drag on touch for now * fix Can select text by dragging with a mouse due to dragStart only being fired on the first PointerMoveEvent, the previous pan gesture recognizer would fire both dragStart and dragUpdate * Revert "fix Can select text by dragging with a mouse due to dragStart only being fired on the first PointerMoveEvent, the previous pan gesture recognizer would fire both dragStart and dragUpdate" This reverts commit 124dc79bc3389672c76d7c014ce04edab297abc6. * correctly use _initialPosition for checkStart and call _checkUpdate after _checkStart if localDelta is not zero * updates * fix double tap chains * Add docs * Address analyzer * more analyzer, only issues left are with print statements * add deadlineTimer to fix conflict with ForcePressGestureRecognizer * Revert "add deadlineTimer to fix conflict with ForcePressGestureRecognizer" This reverts commit 3b29ddfff4cde4845edd481ecefb789fea2a0781. * remove unecessary changes to tests * secondaryButton should not drag * Revert "Revert "add deadlineTimer to fix conflict with ForcePressGestureRecognizer"" This reverts commit 0a008f029f5796acd48c17c1897c0b700d5ef3a7. * updates * Revert "updates" This reverts commit 4803b8443a2b67f0b8d29e9a01f712dfcb0f588c. * Revert "Revert "Revert "add deadlineTimer to fix conflict with ForcePressGestureRecognizer""" This reverts commit 79251a7af88d5dbb1460a960afc77e65dea18bff. * fix shift + tap + drag tests, this was happening because a double tap + drag was being registered and not a single tap, added a duration to pumpAndSettle to fix this * remove TapAndLongPressGestureRecognizer * fix cupertino text field tests related to shift + tap + drag * deadline timer try 2 * more logs * Should reset taps when tap cancel is called, and should wait until gesture is accepted to initiate a drag * should clear _down and _up when gesture is rejected * remove erroneous log * fix selectable text double tap chains test * dont restart timer until tap up * reset consecutiveTapCount on drag end * fix selectableText test * fix material text field tests * reject TapAndDragGestureRecognizer when it is neither a tap nor a drag * remove prints * clean up * shift aware * clean up * fix cupertino test * fix text field focus tests * Add 100ms delay to cupertino test, to prevent a double tap * clean up test comments * add comment to test * uncomment test * remove longpress changes * Fix drag on mobile * remove debug * Fix drag to move cursor on iOS * left over from drag fix * add tests for drag on touch devices * add test for double tap + drag mouse devices * add tests * Fix bug where initialPosition was used before it was set * Address some review comments and fix issue where if double tap was held too long then long press gesture recognizer would take over * remove _isDoubleTap flag since it is no longer needed due to previous commit * Add docs for onTapCancel and onDragCancel * analyzer fixes * Do not test selection handles on macOS, since macOS does not support touch * Add assert for dragStartBehavior * add double tap + drag tests to cupertino * use kDoubleTapTimeout instead of const Duration(milliseconds: 300) for readability * analyzer issues * update docs * update more docs * address comments * more doc updates * fix docs * unused import * fix docs * Add more tests * Add more tests and reject a tap up if we have exceeded the tap tolerance * updates * Address comments * fix test naming * update documentation * move selection_recognizers to selection_gestures * fix analyzer * fix analyzer * keysPressedOnDown instead of isShiftPressed * update docs * update docs * Add drag update throttle to TapAndDragGestureRecognizer * update comments * missed from merge * Replace _ConsecutiveTapMixin with _TapStatusTrackerMixin * updates * correctly cancel tap when when past tap tolerance with new implementation * Should call tap and drag cancel if we are giving up a pointer without succesfully tracking a PointerUpEvent * comments * move pastTapTolerance to tap tracker * move pastTapTolerance to tap tracker * clean up check for nulls and remove use of consecutiveTapCountWhileDragging * move call to super.acceptGesture to top * remove print * clean up * Fix tests where both PanGestureRecognizer and TapAndDragGestureRecognizer lost * clean up * _GestureState -> _DragState * more docs clean up * more clean up * Add onSecondaryTapCancel * Add docs * more docs * Fix broken isPointerAllowed when attempting a right click drag - the _initialButtons is never reset * revert debug flag * make primaryPointer private * Add support for upper count limit in TapAndDragGestureRecognizer, the tap counter should not be allowed to grow infinitely unless that is desired * fix analyzer * Use new TapDrag details objects and callbacks * clean up docs * clean up and add test for upperLimit * Add docs for TapAndDragGestureRecognizer and remove some ambiguity of onStart onUpdate and onEnd parameters * Address review comments * analyzer fixes * Call cancel before rejecting the gesture so we can still access _initialButtons * Recognizer should reject any pointer differing from the original * Revert "Recognizer should reject any pointer differing from the original" This reverts commit afd9807480bd11e119bdd2b7d520631511973bab. * Address reviewer comments * Correct cancel behavior * Fix consecutive tap + drag because _dragStart state was not being set when consecutive tap is greater than one * Add more tests * Add documentation on behavior with TapGestureRecognizer and DragGestureRecognizer * more docs * more docs * remove comments * updates * fix multiple pointer behavior * only handle the primary pointer * Clean up dangerous assumptions in gesture details objects * forgot from rebase * update docs * updates * Clean up some redundant code * remove whitespace * fix tests as a result of #115849 * update test docs * Fix same test from last commit for material variants * More clean up of redundant code and update docs * Clean up didStopTrackingLastPointer and untie TapAndDragGestureRecognizer cancel behavior from TapStatusTrackerMixin.currentUp state * untie pastTapTolerance * updates * Add slopTolerance * update docs * Have secondary tap handled by TapGestureRecognizer * update docs * fix analyzer and address comments * Add more docs * Update cancel behavior tol not call on tap cancel when a drag has been accepted * Change cancel behavior to only cancel if the tap down callback has been sent and merge tapcancel and dragcancel * update docs; * Rename selection_gestures to tap_and_drag_gestures * Address some reviewer comments * make deadline and slopTolerance private * updates * updates * Address review comments * remove _initialButtons * fix docs * trackTrap -> trackTap * fix analyzer * Add test to verify that tap up is called when recognizer accepts before handleEvent is called * implement Diagnosticable for Details objects; * sentTapDown == wonArenaForPrimaryPointer, so the implementation now only uses sentTapDown * Count user tap up immediately and do not wait to win the arena * Do not need to call super from TapAndDragGestureRecognizer.acceptGesture anymore because mixin implementation is gone * Do not start selection drag on Android, iOS, and Fuchshsia touch devices if renderEditable does not have focus, this fixes many scubas * Address reviewer comments * fix test * TapAndDragGestureRecognizer should wait for other recognizer to lose before winning the arena * Address review comments * Dont check for drag if the start was already found * Only check for a drag if it has not already been found" * fix from rebase Co-authored-by: Renzo Olivares --- .../flutter/lib/src/cupertino/text_field.dart | 4 +- .../lib/src/gestures/drag_details.dart | 6 +- .../flutter/lib/src/gestures/monodrag.dart | 8 + packages/flutter/lib/src/gestures/tap.dart | 18 + .../lib/src/material/selectable_text.dart | 2 +- .../flutter/lib/src/material/text_field.dart | 2 +- .../src/widgets/tap_and_drag_gestures.dart | 1288 +++++++++++++++++ .../lib/src/widgets/text_selection.dart | 430 +++--- packages/flutter/lib/widgets.dart | 1 + .../test/cupertino/text_field_test.dart | 223 ++- packages/flutter/test/gestures/tap_test.dart | 1 - .../test/material/text_field_test.dart | 259 +++- .../test/widgets/selectable_text_test.dart | 4 +- .../widgets/tap_and_drag_gestures_test.dart | 552 +++++++ .../test/widgets/text_selection_test.dart | 52 +- 15 files changed, 2635 insertions(+), 215 deletions(-) create mode 100644 packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart create mode 100644 packages/flutter/test/widgets/tap_and_drag_gestures_test.dart diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 977b9df22ecd..72508e3d02b4 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -102,7 +102,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe final _CupertinoTextFieldState _state; @override - void onSingleTapUp(TapUpDetails details) { + void onSingleTapUp(TapDragUpDetails details) { // Because TextSelectionGestureDetector listens to taps that happen on // widgets in front of it, tapping the clear button will also trigger // this handler. If the clear button widget recognizes the up event, @@ -120,7 +120,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe } @override - void onDragSelectionEnd(DragEndDetails details) { + void onDragSelectionEnd(TapDragEndDetails details) { _state._requestKeyboard(); } } diff --git a/packages/flutter/lib/src/gestures/drag_details.dart b/packages/flutter/lib/src/gestures/drag_details.dart index 40308accb8f3..0053f20d414f 100644 --- a/packages/flutter/lib/src/gestures/drag_details.dart +++ b/packages/flutter/lib/src/gestures/drag_details.dart @@ -109,10 +109,12 @@ class DragStartDetails { String toString() => '${objectRuntimeType(this, 'DragStartDetails')}($globalPosition)'; } +/// {@template flutter.gestures.dragdetails.GestureDragStartCallback} /// Signature for when a pointer has contacted the screen and has begun to move. /// /// The `details` object provides the position of the touch when it first /// touched the surface. +/// {@endtemplate} /// /// See [DragGestureRecognizer.onStart]. typedef GestureDragStartCallback = void Function(DragStartDetails details); @@ -126,7 +128,7 @@ typedef GestureDragStartCallback = void Function(DragStartDetails details); /// * [DragStartDetails], the details for [GestureDragStartCallback]. /// * [DragEndDetails], the details for [GestureDragEndCallback]. class DragUpdateDetails { - /// Creates details for a [DragUpdateDetails]. + /// Creates details for a [GestureDragUpdateCallback]. /// /// The [delta] argument must not be null. /// @@ -195,11 +197,13 @@ class DragUpdateDetails { String toString() => '${objectRuntimeType(this, 'DragUpdateDetails')}($delta)'; } +/// {@template flutter.gestures.dragdetails.GestureDragUpdateCallback} /// Signature for when a pointer that is in contact with the screen and moving /// has moved again. /// /// The `details` object provides the position of the touch and the distance it /// has traveled since the last update. +/// {@endtemplate} /// /// See [DragGestureRecognizer.onUpdate]. typedef GestureDragUpdateCallback = void Function(DragUpdateDetails details); diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 15e6d5e9d6e8..500d296b2053 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -26,11 +26,13 @@ enum _DragState { accepted, } +/// {@template flutter.gestures.monodrag.GestureDragEndCallback} /// Signature for when a pointer that was previously in contact with the screen /// and moving is no longer in contact with the screen. /// /// The velocity at which the pointer was moving when it stopped contacting /// the screen is available in the `details`. +/// {@endtemplate} /// /// Used by [DragGestureRecognizer.onEnd]. typedef GestureDragEndCallback = void Function(DragEndDetails details); @@ -124,8 +126,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// * [DragDownDetails], which is passed as an argument to this callback. GestureDragDownCallback? onDown; + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onStart} /// A pointer has contacted the screen with a primary button and has begun to /// move. + /// {@endtemplate} /// /// The position of the pointer is provided in the callback's `details` /// argument, which is a [DragStartDetails] object. The [dragStartBehavior] @@ -137,8 +141,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// * [DragStartDetails], which is passed as an argument to this callback. GestureDragStartCallback? onStart; + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onUpdate} /// A pointer that is in contact with the screen with a primary button and /// moving has moved again. + /// {@endtemplate} /// /// The distance traveled by the pointer since the last update is provided in /// the callback's `details` argument, which is a [DragUpdateDetails] object. @@ -149,9 +155,11 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// * [DragUpdateDetails], which is passed as an argument to this callback. GestureDragUpdateCallback? onUpdate; + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onEnd} /// 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. + /// {@endtemplate} /// /// The velocity is provided in the callback's `details` argument, which is a /// [DragEndDetails] object. diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index a05d839cfaee..c2ce8aecda50 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -45,11 +45,13 @@ class TapDownDetails { final Offset localPosition; } +/// {@template flutter.gestures.tap.GestureTapDownCallback} /// Signature for when a pointer that might cause a tap has contacted the /// screen. /// /// The position at which the pointer contacted the screen is available in the /// `details`. +/// {@endtemplate} /// /// See also: /// @@ -82,11 +84,13 @@ class TapUpDetails { final PointerDeviceKind kind; } +/// {@template flutter.gestures.tap.GestureTapUpCallback} /// Signature for when a pointer that will trigger a tap has stopped contacting /// the screen. /// /// The position at which the pointer stopped contacting the screen is available /// in the `details`. +/// {@endtemplate} /// /// See also: /// @@ -360,8 +364,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} TapGestureRecognizer({ super.debugOwner, super.supportedDevices }); + /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapDown} /// A pointer has contacted the screen at a particular location with a primary /// button, which might be the start of a tap. + /// {@endtemplate} /// /// This triggers after the down event, once a short timeout ([deadline]) has /// elapsed, or once the gestures has won the arena, whichever comes first. @@ -378,8 +384,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onTapDown], which exposes this callback. GestureTapDownCallback? onTapDown; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapUp} /// A pointer has stopped contacting the screen at a particular location, /// which is recognized as a tap of a primary button. + /// {@endtemplate} /// /// This triggers on the up event, if the recognizer wins the arena with it /// or has previously won, immediately followed by [onTap]. @@ -411,8 +419,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onTap], which exposes this callback. GestureTapCallback? onTap; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onTapCancel} /// A pointer that previously triggered [onTapDown] will not end up causing /// a tap. + /// {@endtemplate} /// /// This triggers once the gesture loses the arena if [onTapDown] has /// previously been triggered. @@ -428,8 +438,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onTapCancel], which exposes this callback. GestureTapCancelCallback? onTapCancel; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onSecondaryTap} /// A pointer has stopped contacting the screen, which is recognized as a tap /// of a secondary button. + /// {@endtemplate} /// /// This triggers on the up event, if the recognizer wins the arena with it or /// has previously won, immediately following [onSecondaryTapUp]. @@ -444,8 +456,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onSecondaryTap], which exposes this callback. GestureTapCallback? onSecondaryTap; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onSecondaryTapDown} /// A pointer has contacted the screen at a particular location with a /// secondary button, which might be the start of a secondary tap. + /// {@endtemplate} /// /// This triggers after the down event, once a short timeout ([deadline]) has /// elapsed, or once the gestures has won the arena, whichever comes first. @@ -462,8 +476,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onSecondaryTapDown], which exposes this callback. GestureTapDownCallback? onSecondaryTapDown; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onSecondaryTapUp} /// A pointer has stopped contacting the screen at a particular location, /// which is recognized as a tap of a secondary button. + /// {@endtemplate} /// /// This triggers on the up event if the recognizer wins the arena with it /// or has previously won. @@ -482,8 +498,10 @@ class TapGestureRecognizer extends BaseTapGestureRecognizer { /// * [GestureDetector.onSecondaryTapUp], which exposes this callback. GestureTapUpCallback? onSecondaryTapUp; + /// {@template flutter.gestures.tap.TapGestureRecognizer.onSecondaryTapCancel} /// A pointer that previously triggered [onSecondaryTapDown] will not end up /// causing a tap. + /// {@endtemplate} /// /// This triggers once the gesture loses the arena if [onSecondaryTapDown] /// has previously been triggered. diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index aeb5a3a36711..226623a83092 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -85,7 +85,7 @@ class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestur } @override - void onSingleTapUp(TapUpDetails details) { + void onSingleTapUp(TapDragUpDetails details) { editableText.hideToolbar(); if (delegate.selectionEnabled) { switch (Theme.of(_state.context).platform) { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index c2627bd6a8a0..83becd8241ec 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -65,7 +65,7 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete } @override - void onSingleTapUp(TapUpDetails details) { + void onSingleTapUp(TapDragUpDetails details) { super.onSingleTapUp(details); _state._requestKeyboard(); _state.widget.onTap?.call(); diff --git a/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart b/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart new file mode 100644 index 000000000000..e56f7ca074b6 --- /dev/null +++ b/packages/flutter/lib/src/widgets/tap_and_drag_gestures.dart @@ -0,0 +1,1288 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart' show HardwareKeyboard, LogicalKeyboardKey; + +double _getGlobalDistance(PointerEvent event, OffsetPair? originPosition) { + assert(originPosition != null); + final Offset offset = event.position - originPosition!.global; + return offset.distance; +} + +// The possible states of a [TapAndDragGestureRecognizer]. +// +// The recognizer advances from [ready] to [possible] when it starts tracking +// a pointer in [TapAndDragGestureRecognizer.addAllowedPointer]. Where it advances +// from there depends on the sequence of pointer events that is tracked by the +// recognizer, following the initial [PointerDownEvent]: +// +// * If a [PointerUpEvent] has not been tracked, the recognizer stays in the [possible] +// state as long as it continues to track a pointer. +// * If a [PointerMoveEvent] is tracked that has moved a sufficient global distance +// from the initial [PointerDownEvent] and it came before a [PointerUpEvent], then +// when this recognizer wins the arena, it will move from the [possible] state to [accepted]. +// * If a [PointerUpEvent] is tracked before the pointer has moved a sufficient global +// distance to be considered a drag, then this recognizer moves from the [possible] +// state to [ready]. +// * If a [PointerCancelEvent] is tracked then this recognizer moves from its current +// state to [ready]. +// +// Once the recognizer has stopped tracking any remaining pointers, the recognizer +// returns to the [ready] state. +enum _DragState { + // The recognizer is ready to start recognizing a drag. + ready, + + // The sequence of pointer events seen thus far is consistent with a drag but + // it has not been accepted definitively. + possible, + + // The sequence of pointer events has been accepted definitively as a drag. + accepted, +} + +/// {@macro flutter.gestures.tap.GestureTapDownCallback} +/// +/// The consecutive tap count at the time the pointer contacted the +/// screen is given by [TapDragDownDetails.consecutiveTapCount]. +/// +/// Used by [TapAndDragGestureRecognizer.onTapDown]. +typedef GestureTapDragDownCallback = void Function(TapDragDownDetails details); + +/// Details for [GestureTapDragDownCallback], such as the number of +/// consecutive taps. +/// +/// See also: +/// +/// * [TapAndDragGestureRecognizer], which passes this information to its +/// [TapAndDragGestureRecognizer.onTapDown] callback. +/// * [TapDragUpDetails], the details for [GestureTapDragUpCallback]. +/// * [TapDragStartDetails], the details for [GestureTapDragStartCallback]. +/// * [TapDragUpdateDetails], the details for [GestureTapDragUpdateCallback]. +/// * [TapDragEndDetails], the details for [GestureTapDragEndCallback]. +class TapDragDownDetails with Diagnosticable { + /// Creates details for a [GestureTapDragDownCallback]. + /// + /// The [globalPosition], [localPosition], [consecutiveTapCount], and + /// [keysPressedOnDown] arguments must be provided and must not be null. + TapDragDownDetails({ + required this.globalPosition, + required this.localPosition, + this.kind, + required this.consecutiveTapCount, + required this.keysPressedOnDown, + }); + + /// The global position at which the pointer contacted the screen. + final Offset globalPosition; + + /// The local position at which the pointer contacted the screen. + final Offset localPosition; + + /// The kind of the device that initiated the event. + final PointerDeviceKind? kind; + + /// If this tap is in a series of taps, then this value represents + /// the number in the series this tap is. + final int consecutiveTapCount; + + /// The keys that were pressed when the most recent [PointerDownEvent] occurred. + final Set keysPressedOnDown; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('globalPosition', globalPosition)); + properties.add(DiagnosticsProperty('localPosition', localPosition)); + properties.add(DiagnosticsProperty('kind', kind)); + properties.add(DiagnosticsProperty('consecutiveTapCount', consecutiveTapCount)); + properties.add(DiagnosticsProperty>('keysPressedOnDown', keysPressedOnDown)); + } +} + +/// {@macro flutter.gestures.tap.GestureTapUpCallback} +/// +/// The consecutive tap count at the time the pointer contacted the +/// screen is given by [TapDragUpDetails.consecutiveTapCount]. +/// +/// Used by [TapAndDragGestureRecognizer.onTapUp]. +typedef GestureTapDragUpCallback = void Function(TapDragUpDetails details); + +/// Details for [GestureTapDragUpCallback], such as the number of +/// consecutive taps. +/// +/// See also: +/// +/// * [TapAndDragGestureRecognizer], which passes this information to its +/// [TapAndDragGestureRecognizer.onTapUp] callback. +/// * [TapDragDownDetails], the details for [GestureTapDragDownCallback]. +/// * [TapDragStartDetails], the details for [GestureTapDragStartCallback]. +/// * [TapDragUpdateDetails], the details for [GestureTapDragUpdateCallback]. +/// * [TapDragEndDetails], the details for [GestureTapDragEndCallback]. +class TapDragUpDetails with Diagnosticable { + /// Creates details for a [GestureTapDragUpCallback]. + /// + /// The [kind], [globalPosition], [localPosition], [consecutiveTapCount], and + /// [keysPressedOnDown] arguments must be provided and must not be null. + TapDragUpDetails({ + required this.kind, + required this.globalPosition, + required this.localPosition, + required this.consecutiveTapCount, + required this.keysPressedOnDown, + }); + + /// The global position at which the pointer contacted the screen. + final Offset globalPosition; + + /// The local position at which the pointer contacted the screen. + final Offset localPosition; + + /// The kind of the device that initiated the event. + final PointerDeviceKind kind; + + /// If this tap is in a series of taps, then this value represents + /// the number in the series this tap is. + final int consecutiveTapCount; + + /// The keys that were pressed when the most recent [PointerDownEvent] occurred. + final Set keysPressedOnDown; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('globalPosition', globalPosition)); + properties.add(DiagnosticsProperty('localPosition', localPosition)); + properties.add(DiagnosticsProperty('kind', kind)); + properties.add(DiagnosticsProperty('consecutiveTapCount', consecutiveTapCount)); + properties.add(DiagnosticsProperty>('keysPressedOnDown', keysPressedOnDown)); + } +} + +/// {@macro flutter.gestures.dragdetails.GestureDragStartCallback} +/// +/// The consecutive tap count at the time the pointer contacted the +/// screen is given by [TapDragStartDetails.consecutiveTapCount]. +/// +/// Used by [TapAndDragGestureRecognizer.onDragStart]. +typedef GestureTapDragStartCallback = void Function(TapDragStartDetails details); + +/// Details for [GestureTapDragStartCallback], such as the number of +/// consecutive taps. +/// +/// See also: +/// +/// * [TapAndDragGestureRecognizer], which passes this information to its +/// [TapAndDragGestureRecognizer.onDragStart] callback. +/// * [TapDragDownDetails], the details for [GestureTapDragDownCallback]. +/// * [TapDragUpDetails], the details for [GestureTapDragUpCallback]. +/// * [TapDragUpdateDetails], the details for [GestureTapDragUpdateCallback]. +/// * [TapDragEndDetails], the details for [GestureTapDragEndCallback]. +class TapDragStartDetails with Diagnosticable { + /// Creates details for a [GestureTapDragStartCallback]. + /// + /// The [globalPosition], [localPosition], [consecutiveTapCount], and + /// [keysPressedOnDown] arguments must be provided and must not be null. + TapDragStartDetails({ + this.sourceTimeStamp, + required this.globalPosition, + required this.localPosition, + this.kind, + required this.consecutiveTapCount, + required this.keysPressedOnDown, + }); + + /// Recorded timestamp of the source pointer event that triggered the drag + /// event. + /// + /// Could be null if triggered from proxied events such as accessibility. + final Duration? sourceTimeStamp; + + /// The global position at which the pointer contacted the screen. + /// + /// See also: + /// + /// * [localPosition], which is the [globalPosition] transformed to the + /// coordinate space of the event receiver. + final Offset globalPosition; + + /// The local position in the coordinate system of the event receiver at + /// which the pointer contacted the screen. + final Offset localPosition; + + /// The kind of the device that initiated the event. + final PointerDeviceKind? kind; + + /// If this tap is in a series of taps, then this value represents + /// the number in the series this tap is. + final int consecutiveTapCount; + + /// The keys that were pressed when the most recent [PointerDownEvent] occurred. + final Set keysPressedOnDown; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('sourceTimeStamp', sourceTimeStamp)); + properties.add(DiagnosticsProperty('globalPosition', globalPosition)); + properties.add(DiagnosticsProperty('localPosition', localPosition)); + properties.add(DiagnosticsProperty('kind', kind)); + properties.add(DiagnosticsProperty('consecutiveTapCount', consecutiveTapCount)); + properties.add(DiagnosticsProperty>('keysPressedOnDown', keysPressedOnDown)); + } +} + +/// {@macro flutter.gestures.dragdetails.GestureDragUpdateCallback} +/// +/// The consecutive tap count at the time the pointer contacted the +/// screen is given by [TapDragUpdateDetails.consecutiveTapCount]. +/// +/// Used by [TapAndDragGestureRecognizer.onDragUpdate]. +typedef GestureTapDragUpdateCallback = void Function(TapDragUpdateDetails details); + +/// Details for [GestureTapDragUpdateCallback], such as the number of +/// consecutive taps. +/// +/// See also: +/// +/// * [TapAndDragGestureRecognizer], which passes this information to its +/// [TapAndDragGestureRecognizer.onDragUpdate] callback. +/// * [TapDragDownDetails], the details for [GestureTapDragDownCallback]. +/// * [TapDragUpDetails], the details for [GestureTapDragUpCallback]. +/// * [TapDragStartDetails], the details for [GestureTapDragStartCallback]. +/// * [TapDragEndDetails], the details for [GestureTapDragEndCallback]. +class TapDragUpdateDetails with Diagnosticable { + /// Creates details for a [GestureTapDragUpdateCallback]. + /// + /// The [delta] argument must not be null. + /// + /// If [primaryDelta] is non-null, then its value must match one of the + /// coordinates of [delta] and the other coordinate must be zero. + /// + /// The [globalPosition], [localPosition], [offsetFromOrigin], [localOffsetFromOrigin], + /// [consecutiveTapCount], and [keysPressedOnDown] arguments must be provided and must + /// not be null. + TapDragUpdateDetails({ + this.sourceTimeStamp, + this.delta = Offset.zero, + this.primaryDelta, + required this.globalPosition, + this.kind, + required this.localPosition, + required this.offsetFromOrigin, + required this.localOffsetFromOrigin, + required this.consecutiveTapCount, + required this.keysPressedOnDown, + }) : assert(delta != null), + assert( + primaryDelta == null + || (primaryDelta == delta.dx && delta.dy == 0.0) + || (primaryDelta == delta.dy && delta.dx == 0.0), + ); + + /// Recorded timestamp of the source pointer event that triggered the drag + /// event. + /// + /// Could be null if triggered from proxied events such as accessibility. + final Duration? sourceTimeStamp; + + /// The amount the pointer has moved in the coordinate space of the event + /// receiver since the previous update. + /// + /// If the [GestureTapDragUpdateCallback] is for a one-dimensional drag (e.g., + /// a horizontal or vertical drag), then this offset contains only the delta + /// in that direction (i.e., the coordinate in the other direction is zero). + /// + /// Defaults to zero if not specified in the constructor. + final Offset delta; + + /// The amount the pointer has moved along the primary axis in the coordinate + /// space of the event receiver since the previous + /// update. + /// + /// If the [GestureTapDragUpdateCallback] is for a one-dimensional drag (e.g., + /// a horizontal or vertical drag), then this value contains the component of + /// [delta] along the primary axis (e.g., horizontal or vertical, + /// respectively). Otherwise, if the [GestureTapDragUpdateCallback] is for a + /// two-dimensional drag (e.g., a pan), then this value is null. + /// + /// Defaults to null if not specified in the constructor. + final double? primaryDelta; + + /// The pointer's global position when it triggered this update. + /// + /// See also: + /// + /// * [localPosition], which is the [globalPosition] transformed to the + /// coordinate space of the event receiver. + final Offset globalPosition; + + /// The local position in the coordinate system of the event receiver at + /// which the pointer contacted the screen. + /// + /// Defaults to [globalPosition] if not specified in the constructor. + final Offset localPosition; + + /// The kind of the device that initiated the event. + final PointerDeviceKind? kind; + + /// A delta offset from the point where the drag initially contacted + /// the screen to the point where the pointer is currently located in global + /// coordinates (the present [globalPosition]) when this callback is triggered. + /// + /// When considering a [GestureRecognizer] that tracks the number of consecutive taps, + /// this offset is associated with the most recent [PointerDownEvent] that occured. + final Offset offsetFromOrigin; + + /// A local delta offset from the point where the drag initially contacted + /// the screen to the point where the pointer is currently located in local + /// coordinates (the present [localPosition]) when this callback is triggered. + /// + /// When considering a [GestureRecognizer] that tracks the number of consecutive taps, + /// this offset is associated with the most recent [PointerDownEvent] that occured. + final Offset localOffsetFromOrigin; + + /// If this tap is in a series of taps, then this value represents + /// the number in the series this tap is. + final int consecutiveTapCount; + + /// The keys that were pressed when the most recent [PointerDownEvent] occurred. + final Set keysPressedOnDown; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('sourceTimeStamp', sourceTimeStamp)); + properties.add(DiagnosticsProperty('delta', delta)); + properties.add(DiagnosticsProperty('primaryDelta', primaryDelta)); + properties.add(DiagnosticsProperty('globalPosition', globalPosition)); + properties.add(DiagnosticsProperty('localPosition', localPosition)); + properties.add(DiagnosticsProperty('kind', kind)); + properties.add(DiagnosticsProperty('offsetFromOrigin', offsetFromOrigin)); + properties.add(DiagnosticsProperty('localOffsetFromOrigin', localOffsetFromOrigin)); + properties.add(DiagnosticsProperty('consecutiveTapCount', consecutiveTapCount)); + properties.add(DiagnosticsProperty>('keysPressedOnDown', keysPressedOnDown)); + } +} + +/// {@macro flutter.gestures.monodrag.GestureDragEndCallback} +/// +/// The consecutive tap count at the time the pointer contacted the +/// screen is given by [TapDragEndDetails.consecutiveTapCount]. +/// +/// Used by [TapAndDragGestureRecognizer.onDragEnd]. +typedef GestureTapDragEndCallback = void Function(TapDragEndDetails endDetails); + +/// Details for [GestureTapDragEndCallback], such as the number of +/// consecutive taps. +/// +/// See also: +/// +/// * [TapAndDragGestureRecognizer], which passes this information to its +/// [TapAndDragGestureRecognizer.onDragEnd] callback. +/// * [TapDragDownDetails], the details for [GestureTapDragDownCallback]. +/// * [TapDragUpDetails], the details for [GestureTapDragUpCallback]. +/// * [TapDragStartDetails], the details for [GestureTapDragStartCallback]. +/// * [TapDragUpdateDetails], the details for [GestureTapDragUpdateCallback]. +class TapDragEndDetails with Diagnosticable { + /// Creates details for a [GestureTapDragEndCallback]. + /// + /// The [velocity] argument must not be null. + /// + /// The [consecutiveTapCount], and [keysPressedOnDown] arguments must + /// be provided and must not be null. + TapDragEndDetails({ + this.velocity = Velocity.zero, + this.primaryVelocity, + required this.consecutiveTapCount, + required this.keysPressedOnDown, + }) : assert(velocity != null), + assert( + primaryVelocity == null + || primaryVelocity == velocity.pixelsPerSecond.dx + || primaryVelocity == velocity.pixelsPerSecond.dy, + ); + + /// The velocity the pointer was moving when it stopped contacting the screen. + /// + /// Defaults to zero if not specified in the constructor. + final Velocity velocity; + + /// The velocity the pointer was moving along the primary axis when it stopped + /// contacting the screen, in logical pixels per second. + /// + /// If the [GestureTapDragEndCallback] is for a one-dimensional drag (e.g., a + /// horizontal or vertical drag), then this value contains the component of + /// [velocity] along the primary axis (e.g., horizontal or vertical, + /// respectively). Otherwise, if the [GestureTapDragEndCallback] is for a + /// two-dimensional drag (e.g., a pan), then this value is null. + /// + /// Defaults to null if not specified in the constructor. + final double? primaryVelocity; + + /// If this tap is in a series of taps, then this value represents + /// the number in the series this tap is. + final int consecutiveTapCount; + + /// The keys that were pressed when the most recent [PointerDownEvent] occurred. + final Set keysPressedOnDown; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('velocity', velocity)); + properties.add(DiagnosticsProperty('primaryVelocity', primaryVelocity)); + properties.add(DiagnosticsProperty('consecutiveTapCount', consecutiveTapCount)); + properties.add(DiagnosticsProperty>('keysPressedOnDown', keysPressedOnDown)); + } +} + +/// Signature for when the pointer that previously triggered a +/// [GestureTapDragDownCallback] did not complete. +/// +/// Used by [TapAndDragGestureRecognizer.onCancel]. +typedef GestureCancelCallback = void Function(); + +// A mixin for [OneSequenceGestureRecognizer] that tracks the number of taps +// that occur in a series of [PointerEvent]s and the most recent set of +// [LogicalKeyboardKey]s pressed on the most recent tap down. +// +// A tap is tracked as part of a series of taps if: +// +// 1. The elapsed time between when a [PointerUpEvent] and the subsequent +// [PointerDownEvent] does not exceed [kDoubleTapTimeout]. +// 2. The delta between the position tapped in the global coordinate system +// and the position that was tapped previously must be less than or equal +// to [kDoubleTapSlop]. +// +// This mixin's state, i.e. the series of taps being tracked is reset when +// a tap is tracked that does not meet any of the specifications stated above. +mixin _TapStatusTrackerMixin on OneSequenceGestureRecognizer { + // Public state available to [OneSequenceGestureRecognizer]. + + // The [PointerDownEvent] that was most recently tracked in [addAllowedPointer]. + // + // This value will be null if a [PointerDownEvent] has not been tracked yet in + // [addAllowedPointer] or the timer between two taps has elapsed. + // + // This value is only reset when the timer between a [PointerUpEvent] and the + // [PointerDownEvent] times out or when a new [PointerDownEvent] is tracked in + // [addAllowedPointer]. + PointerDownEvent? get currentDown => _down; + + // The [PointerUpEvent] that was most recently tracked in [handleEvent]. + // + // This value will be null if a [PointerUpEvent] has not been tracked yet in + // [handleEvent] or the timer between two taps has elapsed. + // + // This value is only reset when the timer between a [PointerUpEvent] and the + // [PointerDownEvent] times out or when a new [PointerDownEvent] is tracked in + // [addAllowedPointer]. + PointerUpEvent? get currentUp => _up; + + // The number of consecutive taps that the most recently tracked [PointerDownEvent] + // in [currentDown] represents. + // + // This value defaults to zero, meaning a tap series is not currently being tracked. + // + // When this value is greater than zero it means [addAllowedPointer] has run + // and at least one [PointerDownEvent] belongs to the current series of taps + // being tracked. + // + // [addAllowedPointer] will either increment this value by `1` or set the value to `1` + // depending if the new [PointerDownEvent] is determined to be in the same series as the + // tap that preceded it. If too much time has elapsed between two taps, the recognizer has lost + // in the arena, the gesture has been cancelled, or the recognizer is being disposed then + // this value will be set to `0`, and a new series will begin. + int get consecutiveTapCount => _consecutiveTapCount; + + // The set of [LogicalKeyboardKey]s pressed when the most recent [PointerDownEvent] + // was tracked in [addAllowedPointer]. + // + // This value defaults to an empty set. + // + // When the timer between two taps elapses, the recognizer loses the arena, the gesture is cancelled + // or the recognizer is disposed of then this value is reset. + Set get keysPressedOnDown => _keysPressedOnDown ?? {}; + + // The upper limit for the [consecutiveTapCount]. When this limit is reached + // all tap related state is reset and a new tap series is tracked. + // + // If this value is null, [consecutiveTapCount] can grow infinitely large. + int? get maxConsecutiveTap; + + // The maximum distance in logical pixels the gesture is allowed to drift + // from the initial touch down position before the [consecutiveTapCount] + // and [keysPressedOnDown] are frozen and the remaining tracker state is + // reset. These values remain frozen until the next [PointerDownEvent] is + // tracked in [addAllowedPointer]. + double? get slopTolerance; + + // Private tap state tracked. + PointerDownEvent? _down; + PointerUpEvent? _up; + int _consecutiveTapCount = 0; + Set? _keysPressedOnDown; + + OffsetPair? _originPosition; + int? _previousButtons; + + // For timing taps. + Timer? _consecutiveTapTimer; + Offset? _lastTapOffset; + + // When tracking a tap, the [consecutiveTapCount] is incremented if the given tap + // falls under the tolerance specifications and reset to 1 if not. + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + if (maxConsecutiveTap == _consecutiveTapCount) { + _tapTrackerReset(); + } + _up = null; + if (_down != null && !_representsSameSeries(event)) { + // The given tap does not match the specifications of the series of taps being tracked, + // reset the tap count and related state. + _consecutiveTapCount = 1; + } else { + _consecutiveTapCount += 1; + } + _consecutiveTapTimerStop(); + // `_down` must be assigned in this method instead of [handleEvent], + // because [acceptGesture] might be called before [handleEvent], + // which may rely on `_down` to initiate a callback. + _trackTap(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + final bool isSlopPastTolerance = slopTolerance != null && _getGlobalDistance(event, _originPosition) > slopTolerance!; + + if (isSlopPastTolerance) { + _consecutiveTapTimerStop(); + _previousButtons = null; + _lastTapOffset = null; + } + } else if (event is PointerUpEvent) { + _up = event; + if (_down != null) { + _consecutiveTapTimerStop(); + _consecutiveTapTimerStart(); + } + } else if (event is PointerCancelEvent) { + _tapTrackerReset(); + } + } + + @override + void rejectGesture(int pointer) { + _tapTrackerReset(); + } + + @override + void dispose() { + _tapTrackerReset(); + super.dispose(); + } + + void _trackTap(PointerDownEvent event) { + _down = event; + _keysPressedOnDown = HardwareKeyboard.instance.logicalKeysPressed; + _previousButtons = event.buttons; + _lastTapOffset = event.position; + _originPosition = OffsetPair(local: event.localPosition, global: event.position); + } + + bool _hasSameButton(int buttons) { + assert(_previousButtons != null); + if (buttons == _previousButtons!) { + return true; + } else { + return false; + } + } + + bool _isWithinConsecutiveTapTolerance(Offset secondTapOffset) { + assert(secondTapOffset != null); + if (_lastTapOffset == null) { + return false; + } + + final Offset difference = secondTapOffset - _lastTapOffset!; + return difference.distance <= kDoubleTapSlop; + } + + bool _representsSameSeries(PointerDownEvent event) { + return _consecutiveTapTimer != null + && _isWithinConsecutiveTapTolerance(event.position) + && _hasSameButton(event.buttons); + } + + void _consecutiveTapTimerStart() { + _consecutiveTapTimer ??= Timer(kDoubleTapTimeout, _tapTrackerReset); + } + + void _consecutiveTapTimerStop() { + if (_consecutiveTapTimer != null) { + _consecutiveTapTimer!.cancel(); + _consecutiveTapTimer = null; + } + } + + void _tapTrackerReset() { + // The timer has timed out, i.e. the time between a [PointerUpEvent] and the subsequent + // [PointerDownEvent] exceeded the duration of [kDoubleTapTimeout], so the tap belonging + // to the [PointerDownEvent] cannot be considered part of the same tap series as the + // previous [PointerUpEvent]. + _consecutiveTapTimerStop(); + _previousButtons = null; + _originPosition = null; + _lastTapOffset = null; + _consecutiveTapCount = 0; + _keysPressedOnDown = null; + _down = null; + _up = null; + } +} + +/// Recognizes taps and movements. +/// +/// Takes on the responsibilities of [TapGestureRecognizer] and +/// [DragGestureRecognizer] in one [GestureRecognizer]. +/// +/// ### Gesture arena behavior +/// +/// [TapAndDragGestureRecognizer] competes on the pointer events of +/// [kPrimaryButton] only when it has at least one non-null `onTap*` +/// or `onDrag*` callback. +/// +/// It will declare defeat if it determines that a gesture is not a +/// tap (e.g. if the pointer is dragged too far while it's contacting the +/// screen) or a drag (e.g. if the pointer was not dragged far enough to +/// be considered a drag. +/// +/// This recognizer will not immediately declare victory for every tap or drag that it +/// recognizes. +/// +/// The recognizer will declare victory when all other recognizer's in +/// the arena have lost, if the timer of [kPressTimeout] elapses and a tap +/// series greater than 1 is being tracked. +/// +/// If this recognizer loses the arena (either by declaring defeat or by +/// another recognizer declaring victory) while the pointer is contacting the +/// screen, it will fire [onCancel] instead of [onTapUp] or [onDragEnd]. +/// +/// ### When competing with `TapGestureRecognizer` and `DragGestureRecognizer` +/// +/// Similar to [TapGestureRecognizer] and [DragGestureRecognizer], +/// [TapAndDragGestureRecognizer] will not aggresively declare victory when it detects +/// a tap, so when it is competing with those gesture recognizers and others it has a chance +/// of losing. +/// +/// When competing against [TapGestureRecognizer], if the pointer does not move past the tap +/// tolerance, then the recognizer that entered the arena first will win. In this case the +/// gesture detected is a tap. If the pointer does travel past the tap tolerance then this +/// recognizer will be declared winner by default. The gesture detected in this case is a drag. +/// +/// When competing against [DragGestureRecognizer], if the pointer does not move a sufficient +/// global distance to be considered a drag, the recognizers will tie in the arena. If the +/// pointer does travel enough distance then the [TapAndDragGestureRecognizer] will lose because +/// the [DragGestureRecognizer] will declare self-victory when the drag threshold is met. +class TapAndDragGestureRecognizer extends OneSequenceGestureRecognizer with _TapStatusTrackerMixin { + /// Creates a tap and drag gesture recognizer. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + TapAndDragGestureRecognizer({ + super.debugOwner, + super.kind, + super.supportedDevices, + }) : _deadline = kPressTimeout, + dragStartBehavior = DragStartBehavior.start, + slopTolerance = kTouchSlop; + + /// Configure the behavior of offsets passed to [onDragStart]. + /// + /// If set to [DragStartBehavior.start], the [onDragStart] callback will be called + /// with the position of the pointer at the time this gesture recognizer won + /// the arena. If [DragStartBehavior.down], [onDragStart] will be called with + /// the position of the first detected down event for the pointer. When there + /// are no other gestures competing with this gesture in the arena, there's + /// no difference in behavior between the two settings. + /// + /// For more information about the gesture arena: + /// https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which includes more details and an example. + DragStartBehavior dragStartBehavior; + + /// The frequency at which the [onDragUpdate] callback is called. + /// + /// The value defaults to null, meaning there is no delay for [onDragUpdate] callback. + /// + /// See also: + /// * [TextSelectionGestureDetector], which uses this parameter to avoid excessive updates + /// text layouts in text fields. + Duration? dragUpdateThrottleFrequency; + + /// An upper bound for the amount of taps that can belong to one tap series. + /// + /// When this limit is reached the series of taps being tracked by this + /// recognizer will be reset. + @override + int? maxConsecutiveTap; + + // The maximum distance in logical pixels the gesture is allowed to drift + // to still be considered a tap. + // + // Drifting past the allowed slop amount causes the recognizer to reset + // the tap series it is currently tracking, stopping the consecutive tap + // count from increasing. The consecutive tap count and the set of hardware + // keys that were pressed on tap down will retain their pre-past slop + // tolerance values until the next [PointerDownEvent] is tracked. + // + // If the gesture exceeds this value, then it can only be accepted as a drag + // gesture. + // + // Can be null to indicate that the gesture can drift for any distance. + // Defaults to 18 logical pixels. + @override + final double? slopTolerance; + + /// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapDown} + /// + /// This triggers after the down event, once a short timeout ([kPressTimeout]) has + /// elapsed, or once the gestures has won the arena, whichever comes first. + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragDownDetails] object. + /// + /// {@template flutter.gestures.selectionrecognizers.TapAndDragGestureRecognizer.tapStatusTrackerData} + /// The number of consecutive taps, and the keys that were pressed on tap down + /// are also provided in the callback's `details` argument. + /// {@endtemplate} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragDownDetails], which is passed as an argument to this callback. + GestureTapDragDownCallback? onTapDown; + + /// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapUp} + /// + /// This triggers on the up event, if the recognizer wins the arena with it + /// or has previously won. + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragUpDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.TapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragUpDetails], which is passed as an argument to this callback. + GestureTapDragUpCallback? onTapUp; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onStart} + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragStartDetails] object. The [dragStartBehavior] + /// determines this position. + /// + /// {@macro flutter.gestures.selectionrecognizers.TapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragStartDetails], which is passed as an argument to this callback. + GestureTapDragStartCallback? onDragStart; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onUpdate} + /// + /// The distance traveled by the pointer since the last update is provided in + /// the callback's `details` argument, which is a [TapDragUpdateDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.TapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragUpdateDetails], which is passed as an argument to this callback. + GestureTapDragUpdateCallback? onDragUpdate; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onEnd} + /// + /// The velocity is provided in the callback's `details` argument, which is a + /// [TapDragEndDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.TapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragEndDetails], which is passed as an argument to this callback. + GestureTapDragEndCallback? onDragEnd; + + /// The pointer that previously triggered [onTapDown] did not complete. + /// + /// This is called when a [PointerCancelEvent] is tracked when the [onTapDown] callback + /// was previously called. + /// + /// It may also be called if a [PointerUpEvent] is tracked after the pointer has moved + /// past the tap tolerance but not past the drag tolerance, and the recognizer has not + /// yet won the arena. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + GestureCancelCallback? onCancel; + + // Tap related state. + bool _pastSlopTolerance = false; + bool _sentTapDown = false; + + // Primary pointer being tracked by this recognizer. + int? _primaryPointer; + Timer? _deadlineTimer; + // The recognizer will call [onTapDown] after this amount of time has elapsed + // since starting to track the primary pointer. + // + // [onTapDown] will not be called if the primary pointer is + // accepted, rejected, or all pointers are up or canceled before [_deadline]. + final Duration _deadline; + + // Drag related state. + _DragState _dragState = _DragState.ready; + PointerEvent? _start; + late OffsetPair _initialPosition; + late double _globalDistanceMoved; + OffsetPair? _correctedPosition; + + // For drag update throttle. + TapDragUpdateDetails? _lastDragUpdateDetails; + Timer? _dragUpdateThrottleTimer; + + final Set _acceptedActivePointers = {}; + + bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) { + return _globalDistanceMoved.abs() > computePanSlop(pointerDeviceKind, gestureSettings); + } + + // Drag updates may require throttling to avoid excessive updating, such as for text layouts in text + // fields. The frequency of invocations is controlled by the [dragUpdateThrottleFrequency]. + // + // Once the drag gesture ends, any pending drag update will be fired + // immediately. See [_checkDragEnd]. + void _handleDragUpdateThrottled() { + assert(_lastDragUpdateDetails != null); + if (onDragUpdate != null) { + invokeCallback('onDragUpdate', () => onDragUpdate!(_lastDragUpdateDetails!)); + } + _dragUpdateThrottleTimer = null; + _lastDragUpdateDetails = null; + } + + @override + bool isPointerAllowed(PointerEvent event) { + if (_primaryPointer == null) { + switch (event.buttons) { + case kPrimaryButton: + if (onTapDown == null && + onDragStart == null && + onDragUpdate == null && + onDragEnd == null && + onTapUp == null && + onCancel == null) { + return false; + } + break; + default: + return false; + } + } else { + if (event.pointer != _primaryPointer) { + return false; + } + } + + return super.isPointerAllowed(event as PointerDownEvent); + } + + @override + void addAllowedPointer(PointerDownEvent event) { + if (_dragState == _DragState.ready) { + super.addAllowedPointer(event); + _primaryPointer = event.pointer; + _globalDistanceMoved = 0.0; + _dragState = _DragState.possible; + _initialPosition = OffsetPair(global: event.position, local: event.localPosition); + _deadlineTimer = Timer(_deadline, () => _didExceedDeadlineWithEvent(event)); + } + } + + @override + void handleNonAllowedPointer(PointerDownEvent event) { + // There can be multiple drags simultaneously. Their effects are combined. + if (event.buttons != kPrimaryButton) { + if (!_sentTapDown) { + super.handleNonAllowedPointer(event); + } + } + } + + @override + void acceptGesture(int pointer) { + if (pointer != _primaryPointer) { + return; + } + + _stopDeadlineTimer(); + + assert(!_acceptedActivePointers.contains(pointer)); + _acceptedActivePointers.add(pointer); + + // Called when this recognizer is accepted by the [GestureArena]. + if (currentDown != null) { + _checkTapDown(currentDown!); + } + + if (_start != null) { + _acceptDrag(_start!); + } + + if (currentUp != null) { + _checkTapUp(currentUp!); + } + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_dragState) { + case _DragState.ready: + _checkCancel(); + resolve(GestureDisposition.rejected); + break; + + case _DragState.possible: + if (_pastSlopTolerance) { + // This means the pointer was not accepted as a tap. + if (_sentTapDown) { + // If the recognizer has already won the arena for the primary pointer being tracked + // but the pointer has exceeded the tap tolerance, then the pointer is accepted as a + // drag gesture. + if (currentDown != null) { + _acceptDrag(currentDown!); + _checkDragEnd(); + } + } else { + _checkCancel(); + resolve(GestureDisposition.rejected); + } + } else { + // The pointer is accepted as a tap. + if (currentUp != null) { + _checkTapUp(currentUp!); + } + } + break; + + case _DragState.accepted: + // For the case when the pointer has been accepted as a drag. + // Meaning [_checkTapDown] and [_checkDragStart] have already ran. + _checkDragEnd(); + break; + } + + _stopDeadlineTimer(); + _dragState = _DragState.ready; + _pastSlopTolerance = false; + } + + @override + void handleEvent(PointerEvent event) { + if (event.pointer != _primaryPointer) { + return; + } + super.handleEvent(event); + if (event is PointerMoveEvent) { + // Receiving a [PointerMoveEvent], does not automatically mean the pointer + // being tracked is doing a drag gesture. There is some drift that can happen + // between the initial [PointerDownEvent] and subsequent [PointerMoveEvent]s. + // Accessing [_pastSlopTolerance] lets us know if our tap has moved past the + // acceptable tolerance. If the pointer does not move past this tolerance than + // it is not considered a drag. + // + // To be recognized as a drag, the [PointerMoveEvent] must also have moved + // a sufficient global distance from the initial [PointerDownEvent] to be + // accepted as a drag. This logic is handled in [_hasSufficientGlobalDistanceToAccept]. + // + // The recognizer will also detect the gesture as a drag when the pointer + // has been accepted and it has moved past the [slopTolerance] but has not moved + // a sufficient global distance from the initial position to be considered a drag. + // In this case since the gesture cannot be a tap, it defaults to a drag. + + _pastSlopTolerance = _pastSlopTolerance || slopTolerance != null && _getGlobalDistance(event, _initialPosition) > slopTolerance!; + + if (_dragState == _DragState.accepted) { + _checkDragUpdate(event); + } else if (_dragState == _DragState.possible) { + if (_start == null) { + // Only check for a drag if the start of a drag was not already identified. + _checkDrag(event); + } + + // This can occur when the recognizer is accepted before a [PointerMoveEvent] has been + // received that moves the pointer a sufficient global distance to be considered a drag. + if (_start != null && _sentTapDown) { + _acceptDrag(_start!); + } + } + } else if (event is PointerUpEvent) { + if (_dragState == _DragState.possible) { + // The drag has not been accepted before a [PointerUpEvent], therefore the recognizer + // attempts to recognize a tap. + stopTrackingIfPointerNoLongerDown(event); + } else if (_dragState == _DragState.accepted) { + _giveUpPointer(event.pointer); + } + } else if (event is PointerCancelEvent) { + _dragState = _DragState.ready; + _giveUpPointer(event.pointer); + } + } + + @override + void rejectGesture(int pointer) { + if (pointer != _primaryPointer) { + return; + } + super.rejectGesture(pointer); + + _stopDeadlineTimer(); + _giveUpPointer(pointer); + _resetTaps(); + _resetDragUpdateThrottle(); + } + + @override + void dispose() { + _stopDeadlineTimer(); + _resetDragUpdateThrottle(); + super.dispose(); + } + + @override + String get debugDescription => 'tap_and_drag'; + + void _acceptDrag(PointerEvent event) { + _dragState = _DragState.accepted; + if (dragStartBehavior == DragStartBehavior.start) { + _initialPosition = _initialPosition + OffsetPair(global: event.delta, local: event.localDelta); + } + _checkDragStart(event); + if (event.localDelta != Offset.zero) { + final Matrix4? localToGlobal = event.transform != null ? Matrix4.tryInvert(event.transform!) : null; + final Offset correctedLocalPosition = _initialPosition.local + event.localDelta; + final Offset globalUpdateDelta = PointerEvent.transformDeltaViaPositions( + untransformedEndPosition: correctedLocalPosition, + untransformedDelta: event.localDelta, + transform: localToGlobal, + ); + final OffsetPair updateDelta = OffsetPair(local: event.localDelta, global: globalUpdateDelta); + _correctedPosition = _initialPosition + updateDelta; // Only adds delta for down behaviour + _checkDragUpdate(event); + _correctedPosition = null; + } + } + + void _checkDrag(PointerMoveEvent event) { + final Matrix4? localToGlobalTransform = event.transform == null ? null : Matrix4.tryInvert(event.transform!); + _globalDistanceMoved += PointerEvent.transformDeltaViaPositions( + transform: localToGlobalTransform, + untransformedDelta: event.localDelta, + untransformedEndPosition: event.localPosition + ).distance * 1.sign; + if (_hasSufficientGlobalDistanceToAccept(event.kind, gestureSettings?.touchSlop)) { + _start = event; + } + } + + void _checkTapDown(PointerDownEvent event) { + if (_sentTapDown) { + return; + } + + final TapDragDownDetails details = TapDragDownDetails( + globalPosition: event.position, + localPosition: event.localPosition, + kind: getKindForPointer(event.pointer), + consecutiveTapCount: consecutiveTapCount, + keysPressedOnDown: keysPressedOnDown, + ); + + if (onTapDown != null) { + invokeCallback('onTapDown', () => onTapDown!(details)); + } + + _sentTapDown = true; + } + + void _checkTapUp(PointerUpEvent event) { + if (!_sentTapDown) { + return; + } + + final TapDragUpDetails upDetails = TapDragUpDetails( + kind: event.kind, + globalPosition: event.position, + localPosition: event.localPosition, + consecutiveTapCount: consecutiveTapCount, + keysPressedOnDown: keysPressedOnDown, + ); + + if (onTapUp != null) { + invokeCallback('onTapUp', () => onTapUp!(upDetails)); + } + + _resetTaps(); + if (!_acceptedActivePointers.remove(event.pointer)) { + resolvePointer(event.pointer, GestureDisposition.rejected); + } + } + + void _checkDragStart(PointerEvent event) { + if (onDragStart != null) { + final TapDragStartDetails details = TapDragStartDetails( + sourceTimeStamp: event.timeStamp, + globalPosition: _initialPosition.global, + localPosition: _initialPosition.local, + kind: getKindForPointer(event.pointer), + consecutiveTapCount: consecutiveTapCount, + keysPressedOnDown: keysPressedOnDown, + ); + + invokeCallback('onDragStart', () => onDragStart!(details)); + } + + _start = null; + } + + void _checkDragUpdate(PointerEvent event) { + final Offset globalPosition = _correctedPosition != null ? _correctedPosition!.global : event.position; + final Offset localPosition = _correctedPosition != null ? _correctedPosition!.local : event.localPosition; + + final TapDragUpdateDetails details = TapDragUpdateDetails( + sourceTimeStamp: event.timeStamp, + delta: event.localDelta, + globalPosition: globalPosition, + kind: getKindForPointer(event.pointer), + localPosition: localPosition, + offsetFromOrigin: globalPosition - _initialPosition.global, + localOffsetFromOrigin: localPosition - _initialPosition.local, + consecutiveTapCount: consecutiveTapCount, + keysPressedOnDown: keysPressedOnDown, + ); + + if (dragUpdateThrottleFrequency != null) { + _lastDragUpdateDetails = details; + // Only schedule a new timer if there's not one pending. + _dragUpdateThrottleTimer ??= Timer(dragUpdateThrottleFrequency!, _handleDragUpdateThrottled); + } else { + if (onDragUpdate != null) { + invokeCallback('onDragUpdate', () => onDragUpdate!(details)); + } + } + } + + void _checkDragEnd() { + if (_dragUpdateThrottleTimer != null) { + // If there's already an update scheduled, trigger it immediately and + // cancel the timer. + _dragUpdateThrottleTimer!.cancel(); + _handleDragUpdateThrottled(); + } + + final TapDragEndDetails endDetails = + TapDragEndDetails( + primaryVelocity: 0.0, + consecutiveTapCount: consecutiveTapCount, + keysPressedOnDown: keysPressedOnDown, + ); + + invokeCallback('onDragEnd', () => onDragEnd!(endDetails)); + + _resetTaps(); + _resetDragUpdateThrottle(); + } + + void _checkCancel() { + if (!_sentTapDown) { + // Do not fire tap cancel if [onTapDown] was never called. + return; + } + if (onCancel != null) { + invokeCallback('onCancel', onCancel!); + } + _resetDragUpdateThrottle(); + _resetTaps(); + } + + void _didExceedDeadlineWithEvent(PointerDownEvent event) { + _didExceedDeadline(); + } + + void _didExceedDeadline() { + if (currentDown != null) { + _checkTapDown(currentDown!); + + if (consecutiveTapCount > 1) { + // If our consecutive tap count is greater than 1, i.e. is a double tap or greater, + // then this recognizer declares victory to prevent the [LongPressGestureRecognizer] + // from declaring itself the winner if a double tap is held for too long. + resolve(GestureDisposition.accepted); + } + } + } + + void _giveUpPointer(int pointer) { + stopTrackingPointer(pointer); + // If the pointer was never accepted, then it is rejected since this recognizer is no longer + // interested in winning the gesture arena for it. + if (!_acceptedActivePointers.remove(pointer)) { + resolvePointer(pointer, GestureDisposition.rejected); + } + } + + void _resetTaps() { + _sentTapDown = false; + _primaryPointer = null; + } + + void _resetDragUpdateThrottle() { + if (dragUpdateThrottleFrequency == null) { + return; + } + _lastDragUpdateDetails = null; + if (_dragUpdateThrottleTimer != null) { + _dragUpdateThrottleTimer!.cancel(); + _dragUpdateThrottleTimer = null; + } + } + + void _stopDeadlineTimer() { + if (_deadlineTimer != null) { + _deadlineTimer!.cancel(); + _deadlineTimer = null; + } + } +} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 8812ec5b5fb0..cbfc61b3c126 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -24,6 +24,7 @@ import 'gesture_detector.dart'; import 'magnifier.dart'; import 'overlay.dart'; import 'scrollable.dart'; +import 'tap_and_drag_gestures.dart'; import 'tap_region.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; @@ -35,19 +36,6 @@ export 'package:flutter/services.dart' show TextSelectionDelegate; /// called. const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); -/// 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); - /// The type for a Function that builds a toolbar's container with the given /// child. /// @@ -1907,6 +1895,23 @@ class TextSelectionGestureDetectorBuilder { && selection.end >= textPosition.offset; } + /// Returns true if position was on selection. + bool _positionOnSelection(Offset position, TextSelection? targetSelection) { + if (targetSelection == null) { + return false; + } + + final TextPosition textPosition = renderEditable.getPositionForPoint(position); + + return targetSelection.start <= textPosition.offset + && targetSelection.end >= textPosition.offset; + } + + /// Returns true if shift left or right is contained in the given set. + static bool _containsShift(Set keysPressed) { + return keysPressed.any({ LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight }.contains); + } + // Expand the selection to the given global position. // // Either base or extent will be moved to the last tapped position, whichever @@ -1994,15 +1999,6 @@ class TextSelectionGestureDetectorBuilder { /// The viewport offset pixels of the [RenderEditable] at the last drag start. double _dragStartViewportOffset = 0.0; - // Returns true iff either shift key is currently down. - bool get _isShiftPressed { - return HardwareKeyboard.instance.logicalKeysPressed - .any({ - LogicalKeyboardKey.shiftLeft, - LogicalKeyboardKey.shiftRight, - }.contains); - } - double get _scrollPosition { final ScrollableState? scrollableState = delegate.editableTextKey.currentContext == null @@ -2013,13 +2009,19 @@ class TextSelectionGestureDetectorBuilder { : scrollableState.position.pixels; } - // True iff a tap + shift has been detected but the tap has not yet come up. - bool _isShiftTapping = false; - // For a shift + tap + drag gesture, the TextSelection at the point of the // tap. Mac uses this value to reset to the original selection when an // inversion of the base and offset happens. - TextSelection? _shiftTapDragSelection; + TextSelection? _dragStartSelection; + + // For tap + drag gesture on iOS, whether the position where the drag started + // was on the previous TextSelection. iOS uses this value to determine if + // the cursor should move on drag update. + // + // If the drag started on the previous selection then the cursor will move on + // drag update. If the drag did not start on the previous selection then the + // cursor will not move on drag update. + bool? _dragBeganOnPreviousSelection; /// Handler for [TextSelectionGestureDetector.onTapDown]. /// @@ -2030,11 +2032,17 @@ class TextSelectionGestureDetectorBuilder { /// /// * [TextSelectionGestureDetector.onTapDown], which triggers this callback. @protected - void onTapDown(TapDownDetails details) { + void onTapDown(TapDragDownDetails details) { if (!delegate.selectionEnabled) { return; } - renderEditable.handleTapDown(details); + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable.handleTapDown(TapDownDetails(globalPosition: details.globalPosition)); // The selection overlay should only be shown when the user is interacting // through a touch screen (via either a finger or a stylus). A mouse shouldn't // trigger the selection overlay. @@ -2048,21 +2056,20 @@ class TextSelectionGestureDetectorBuilder { || kind == PointerDeviceKind.stylus; // Handle shift + click selection if needed. - final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null; + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = isShiftPressed && renderEditable.selection?.baseOffset != null; switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.iOS: // On mobile platforms the selection is set on tap up. - if (_isShiftTapping) { - _isShiftTapping = false; - } break; case TargetPlatform.macOS: // On macOS, a shift-tapped unfocused field expands from 0, not from the // previous selection. if (isShiftPressedValid) { - _isShiftTapping = true; final TextSelection? fromSelection = renderEditable.hasFocus ? null : const TextSelection.collapsed(offset: 0); @@ -2082,7 +2089,6 @@ class TextSelectionGestureDetectorBuilder { case TargetPlatform.linux: case TargetPlatform.windows: if (isShiftPressedValid) { - _isShiftTapping = true; _extendSelection(details.globalPosition, SelectionChangedCause.tap); return; } @@ -2146,25 +2152,24 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onSingleTapUp], which triggers /// this callback. @protected - void onSingleTapUp(TapUpDetails details) { + void onSingleTapUp(TapDragUpDetails details) { if (delegate.selectionEnabled) { // Handle shift + click selection if needed. - final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null; + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = isShiftPressed && renderEditable.selection?.baseOffset != null; switch (defaultTargetPlatform) { case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: editableText.hideToolbar(); // On desktop platforms the selection is set on tap down. - if (_isShiftTapping) { - _isShiftTapping = false; - } break; case TargetPlatform.android: editableText.hideToolbar(); editableText.showSpellCheckSuggestionsToolbar(); if (isShiftPressedValid) { - _isShiftTapping = true; _extendSelection(details.globalPosition, SelectionChangedCause.tap); return; } @@ -2173,7 +2178,6 @@ class TextSelectionGestureDetectorBuilder { case TargetPlatform.fuchsia: editableText.hideToolbar(); if (isShiftPressedValid) { - _isShiftTapping = true; _extendSelection(details.globalPosition, SelectionChangedCause.tap); return; } @@ -2183,7 +2187,6 @@ class TextSelectionGestureDetectorBuilder { if (isShiftPressedValid) { // On iOS, a shift-tapped unfocused field expands from 0, not from // the previous selection. - _isShiftTapping = true; final TextSelection? fromSelection = renderEditable.hasFocus ? null : const TextSelection.collapsed(offset: 0); @@ -2246,7 +2249,7 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onSingleTapCancel], which triggers /// this callback. @protected - void onSingleTapCancel() {/* Subclass should override this method if needed. */} + void onSingleTapCancel() { /* Subclass should override this method if needed. */ } /// Handler for [TextSelectionGestureDetector.onSingleLongTapStart]. /// @@ -2416,7 +2419,13 @@ class TextSelectionGestureDetectorBuilder { /// * [onSecondaryTap], which is typically called after this. @protected void onSecondaryTapDown(TapDownDetails details) { - renderEditable.handleSecondaryTapDown(details); + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable.handleSecondaryTapDown(TapDownDetails(globalPosition: details.globalPosition)); _shouldShowSelectionToolbar = true; } @@ -2430,7 +2439,7 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this /// callback. @protected - void onDoubleTapDown(TapDownDetails details) { + void onDoubleTapDown(TapDragDownDetails details) { if (delegate.selectionEnabled) { renderEditable.selectWord(cause: SelectionChangedCause.tap); if (shouldShowSelectionToolbar) { @@ -2448,7 +2457,7 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onDragSelectionStart], which triggers /// this callback. @protected - void onDragSelectionStart(DragStartDetails details) { + void onDragSelectionStart(TapDragStartDetails details) { if (!delegate.selectionEnabled) { return; } @@ -2457,8 +2466,18 @@ class TextSelectionGestureDetectorBuilder { || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus; - if (_isShiftPressed && renderEditable.selection != null && renderEditable.selection!.isValid) { - _isShiftTapping = true; + _dragStartSelection = renderEditable.selection; + _dragStartScrollOffset = _scrollPosition; + _dragStartViewportOffset = renderEditable.offset.pixels; + + if (details.consecutiveTapCount > 1) { + // Do not set the selection on a consecutive tap and drag. + return; + } + + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + + if (isShiftPressed && renderEditable.selection != null && renderEditable.selection!.isValid) { switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: @@ -2471,16 +2490,46 @@ class TextSelectionGestureDetectorBuilder { _extendSelection(details.globalPosition, SelectionChangedCause.drag); break; } - _shiftTapDragSelection = renderEditable.selection; } else { - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.drag, - ); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // For Android, Fucshia, and iOS platforms, a touch drag + // does not initiate unless the editable has focus. + if (renderEditable.hasFocus) { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + break; + } } - - _dragStartScrollOffset = _scrollPosition; - _dragStartViewportOffset = renderEditable.offset.pixels; } /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate]. @@ -2493,12 +2542,14 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers /// this callback./lib/src/material/text_field.dart @protected - void onDragSelectionUpdate(DragStartDetails startDetails, DragUpdateDetails updateDetails) { + void onDragSelectionUpdate(TapDragUpdateDetails details) { if (!delegate.selectionEnabled) { return; } - if (!_isShiftTapping) { + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + + if (!isShiftPressed) { // Adjust the drag start offset for possible viewport offset changes. final Offset editableOffset = renderEditable.maxLines == 1 ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) @@ -2507,52 +2558,131 @@ class TextSelectionGestureDetectorBuilder { 0.0, _scrollPosition - _dragStartScrollOffset, ); - return renderEditable.selectPositionAt( - from: startDetails.globalPosition - editableOffset - scrollableOffset, - to: updateDetails.globalPosition, - cause: SelectionChangedCause.drag, - ); + final Offset dragStartGlobalPosition = details.globalPosition - details.offsetFromOrigin; + + // Select word by word. + if (details.consecutiveTapCount == 2) { + return renderEditable.selectWordsInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + // With a touch device, nothing should happen, unless there was a double tap, or + // there was a collapsed selection, and the tap/drag position is at the collapsed selection. + // In that case the caret should move with the drag position. + // + // With a mouse device, a drag should select the range from the origin of the drag + // to the current position of the drag. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return renderEditable.selectPositionAt( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + _dragBeganOnPreviousSelection ??= _positionOnSelection(dragStartGlobalPosition, _dragStartSelection); + assert(_dragBeganOnPreviousSelection != null); + if (renderEditable.hasFocus + && _dragStartSelection!.isCollapsed + && _dragBeganOnPreviousSelection! + ) { + return renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // With a precise pointer device, such as a mouse, trackpad, or stylus, + // the drag will select the text spanning the origin of the drag to the end of the drag. + // With a touch device, the cursor should move with the drag. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + return renderEditable.selectPositionAt( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + if (renderEditable.hasFocus) { + return renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + break; + case null: + break; + } + return; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return renderEditable.selectPositionAt( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } } - if (_shiftTapDragSelection!.isCollapsed + if (_dragStartSelection!.isCollapsed || (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.macOS)) { - return _extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag); + return _extendSelection(details.globalPosition, SelectionChangedCause.drag); } // If the drag inverts the selection, Mac and iOS revert to the initial // selection. final TextSelection selection = editableText.textEditingValue.selection; - final TextPosition nextExtent = renderEditable.getPositionForPoint(updateDetails.globalPosition); + final TextPosition nextExtent = renderEditable.getPositionForPoint(details.globalPosition); final bool isShiftTapDragSelectionForward = - _shiftTapDragSelection!.baseOffset < _shiftTapDragSelection!.extentOffset; + _dragStartSelection!.baseOffset < _dragStartSelection!.extentOffset; final bool isInverted = isShiftTapDragSelectionForward - ? nextExtent.offset < _shiftTapDragSelection!.baseOffset - : nextExtent.offset > _shiftTapDragSelection!.baseOffset; - if (isInverted && selection.baseOffset == _shiftTapDragSelection!.baseOffset) { + ? nextExtent.offset < _dragStartSelection!.baseOffset + : nextExtent.offset > _dragStartSelection!.baseOffset; + if (isInverted && selection.baseOffset == _dragStartSelection!.baseOffset) { editableText.userUpdateTextEditingValue( editableText.textEditingValue.copyWith( selection: TextSelection( - baseOffset: _shiftTapDragSelection!.extentOffset, + baseOffset: _dragStartSelection!.extentOffset, extentOffset: nextExtent.offset, ), ), SelectionChangedCause.drag, ); } else if (!isInverted - && nextExtent.offset != _shiftTapDragSelection!.baseOffset - && selection.baseOffset != _shiftTapDragSelection!.baseOffset) { + && nextExtent.offset != _dragStartSelection!.baseOffset + && selection.baseOffset != _dragStartSelection!.baseOffset) { editableText.userUpdateTextEditingValue( editableText.textEditingValue.copyWith( selection: TextSelection( - baseOffset: _shiftTapDragSelection!.baseOffset, + baseOffset: _dragStartSelection!.baseOffset, extentOffset: nextExtent.offset, ), ), SelectionChangedCause.drag, ); } else { - _extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag); + _extendSelection(details.globalPosition, SelectionChangedCause.drag); } } @@ -2566,10 +2696,12 @@ class TextSelectionGestureDetectorBuilder { /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this /// callback. @protected - void onDragSelectionEnd(DragEndDetails details) { - if (_isShiftTapping) { - _isShiftTapping = false; - _shiftTapDragSelection = null; + void onDragSelectionEnd(TapDragEndDetails details) { + final bool isShiftPressed = _containsShift(details.keysPressedOnDown); + _dragBeganOnPreviousSelection = null; + + if (isShiftPressed) { + _dragStartSelection = null; } } @@ -2608,8 +2740,8 @@ class TextSelectionGestureDetectorBuilder { /// /// An ordinary [GestureDetector] configured to handle events like tap and /// double tap will only recognize one or the other. This widget detects both: -/// first the tap and then, if another tap down occurs within a time limit, the -/// double tap. +/// the first tap and then any subsequent taps that occurs within a time limit +/// after the first. /// /// See also: /// @@ -2644,7 +2776,7 @@ class TextSelectionGestureDetector extends StatefulWidget { /// Called for every tap down including every tap down that's part of a /// double click or a long press, except touches that include enough movement /// to not qualify as taps (e.g. pans and flings). - final GestureTapDownCallback? onTapDown; + final GestureTapDragDownCallback? onTapDown; /// Called when a pointer has tapped down and the force of the pointer has /// just become greater than [ForcePressGestureRecognizer.startPressure]. @@ -2660,16 +2792,18 @@ class TextSelectionGestureDetector extends StatefulWidget { /// Called for a tap down event with the secondary mouse button. final GestureTapDownCallback? onSecondaryTapDown; - /// Called for each distinct tap except for every second tap of a double tap. + /// Called for the first tap in a series of taps, consecutive taps do not call + /// this method. + /// /// For example, if the detector was configured with [onTapDown] and /// [onDoubleTapDown], three quick taps would be recognized as a single tap - /// down, followed by a double tap down, followed by a single tap down. - final GestureTapUpCallback? onSingleTapUp; + /// down, followed by a tap up, then a double tap down, followed by a single tap down. + final GestureTapDragUpCallback? onSingleTapUp; /// Called for each touch that becomes recognized as a gesture that is not a /// short tap, such as a long tap or drag. It is called at the moment when /// another gesture from the touch is recognized. - final GestureTapCancelCallback? onSingleTapCancel; + final GestureCancelCallback? onSingleTapCancel; /// Called for a single long tap that's sustained for longer than /// [kLongPressTimeout] but not necessarily lifted. Not called for a @@ -2684,20 +2818,20 @@ class TextSelectionGestureDetector extends StatefulWidget { /// Called after a momentary hold or a short tap that is close in space and /// time (within [kDoubleTapTimeout]) to a previous short tap. - final GestureTapDownCallback? onDoubleTapDown; + final GestureTapDragDownCallback? onDoubleTapDown; /// Called when a mouse starts dragging to select text. - final GestureDragStartCallback? onDragSelectionStart; + final GestureTapDragStartCallback? 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; + final GestureTapDragUpdateCallback? onDragSelectionUpdate; /// Called when a mouse that was previously dragging is released. - final GestureDragEndCallback? onDragSelectionEnd; + final GestureTapDragEndCallback? onDragSelectionEnd; /// How this gesture detector should behave during hit testing. /// @@ -2712,100 +2846,50 @@ class TextSelectionGestureDetector extends StatefulWidget { } class _TextSelectionGestureDetectorState extends State { - // Counts down for a short duration after a previous tap. Null otherwise. - Timer? _doubleTapTimer; - Offset? _lastTapOffset; - // True if a second tap down of a double tap is detected. Used to discard - // subsequent tap up / tap hold of the same tap. - bool _isDoubleTap = false; + static int? _getDefaultMaxConsecutiveTap() => 2; @override void dispose() { - _doubleTapTimer?.cancel(); - _dragUpdateThrottleTimer?.cancel(); super.dispose(); } // The down handler is force-run on success of a single tap and optimistically // run before a long press success. - void _handleTapDown(TapDownDetails details) { + void _handleTapDown(TapDragDownDetails details) { widget.onTapDown?.call(details); // This isn't detected as a double tap gesture in the gesture recognizer // because it's 2 single taps, each of which may do different things depending // on whether it's a single tap, the first tap of a double tap, the second // tap held down, a clean double tap etc. - if (_doubleTapTimer != null && _isWithinDoubleTapTolerance(details.globalPosition)) { - // If there was already a previous tap, the second down hold/tap is a - // double tap down. - widget.onDoubleTapDown?.call(details); - _doubleTapTimer!.cancel(); - _doubleTapTimeout(); - _isDoubleTap = true; + if (details.consecutiveTapCount == 2) { + widget.onDoubleTapDown?.call(details); } } - void _handleTapUp(TapUpDetails details) { - if (!_isDoubleTap) { + void _handleTapUp(TapDragUpDetails details) { + if (details.consecutiveTapCount == 1) { widget.onSingleTapUp?.call(details); - _lastTapOffset = details.globalPosition; - _doubleTapTimer?.cancel(); - _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); } - _isDoubleTap = false; } void _handleTapCancel() { widget.onSingleTapCancel?.call(); } - DragStartDetails? _lastDragStartDetails; - DragUpdateDetails? _lastDragUpdateDetails; - Timer? _dragUpdateThrottleTimer; - - void _handleDragStart(DragStartDetails details) { - assert(_lastDragStartDetails == null); - _lastDragStartDetails = details; + void _handleDragStart(TapDragStartDetails details) { widget.onDragSelectionStart?.call(details); } - void _handleDragUpdate(DragUpdateDetails details) { - _lastDragUpdateDetails = details; - // Only schedule a new timer if there's no one pending. - _dragUpdateThrottleTimer ??= Timer(_kDragSelectionUpdateThrottle, _handleDragUpdateThrottled); + void _handleDragUpdate(TapDragUpdateDetails details) { + widget.onDragSelectionUpdate?.call(details); } - /// Drag updates are being throttled to avoid excessive text layouts in text - /// fields. The frequency of invocations is controlled by the constant - /// [_kDragSelectionUpdateThrottle]. - /// - /// Once the drag gesture ends, any pending drag update will be fired - /// immediately. See [_handleDragEnd]. - void _handleDragUpdateThrottled() { - assert(_lastDragStartDetails != null); - assert(_lastDragUpdateDetails != null); - widget.onDragSelectionUpdate?.call(_lastDragStartDetails!, _lastDragUpdateDetails!); - _dragUpdateThrottleTimer = null; - _lastDragUpdateDetails = null; - } - - void _handleDragEnd(DragEndDetails details) { - assert(_lastDragStartDetails != null); - if (_dragUpdateThrottleTimer != null) { - // If there's already an update scheduled, trigger it immediately and - // cancel the timer. - _dragUpdateThrottleTimer!.cancel(); - _handleDragUpdateThrottled(); - } + void _handleDragEnd(TapDragEndDetails details) { widget.onDragSelectionEnd?.call(details); - _dragUpdateThrottleTimer = null; - _lastDragStartDetails = null; - _lastDragUpdateDetails = null; } void _forcePressStarted(ForcePressDetails details) { - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; widget.onForcePressStart?.call(details); } @@ -2814,37 +2898,21 @@ class _TextSelectionGestureDetectorState extends State( - () => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch), + () => LongPressGestureRecognizer(debugOwner: this, supportedDevices: { PointerDeviceKind.touch }), (LongPressGestureRecognizer instance) { instance ..onLongPressStart = _handleLongPressStart @@ -2880,16 +2945,21 @@ class _TextSelectionGestureDetectorState extends State( - () => PanGestureRecognizer(debugOwner: this, supportedDevices: { PointerDeviceKind.mouse }), - (PanGestureRecognizer instance) { + gestures[TapAndDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapAndDragGestureRecognizer(debugOwner: this, supportedDevices: { PointerDeviceKind.mouse, PointerDeviceKind.touch }), + (TapAndDragGestureRecognizer instance) { instance // Text selection should start from the position of the first pointer // down event. ..dragStartBehavior = DragStartBehavior.down - ..onStart = _handleDragStart - ..onUpdate = _handleDragUpdate - ..onEnd = _handleDragEnd; + ..dragUpdateThrottleFrequency = _kDragSelectionUpdateThrottle + ..maxConsecutiveTap = _getDefaultMaxConsecutiveTap() + ..onTapDown = _handleTapDown + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = _handleDragEnd + ..onTapUp = _handleTapUp + ..onCancel = _handleTapCancel; }, ); } diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index fbacdec14a82..3eee850c7bec 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -135,6 +135,7 @@ export 'src/widgets/spacer.dart'; export 'src/widgets/spell_check.dart'; export 'src/widgets/status_transitions.dart'; export 'src/widgets/table.dart'; +export 'src/widgets/tap_and_drag_gestures.dart'; export 'src/widgets/tap_region.dart'; export 'src/widgets/text.dart'; export 'src/widgets/text_editing_intents.dart'; diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index b068ba236c53..efc01e2271bf 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -2125,6 +2125,105 @@ void main() { }, ); + testWidgets( + 'Can double click + drag with a mouse to select word by word', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }, + ); + + testWidgets( + 'Can double tap + drag to select word by word', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }, + ); + testWidgets('Readonly text field does not have tap action', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -3451,7 +3550,7 @@ void main() { // The selection doesn't move beyond the left handle. There's always at // least 1 char selected. expect(controller.selection.extentOffset, 5); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); testWidgets('Dragging between multiple lines keeps the contact point at the same place on the handle on Android', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -3602,7 +3701,9 @@ void main() { renderEditable, ); handlePos = endpoints[0].point + startHandleAdjustment; - newHandlePos = handlePos - toNextLine; + // Move handle a sufficient global distance so it can be considered a drag + // by the selection handle's [PanGestureRecognizer]. + newHandlePos = handlePos - (toNextLine * 2); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); @@ -3626,6 +3727,12 @@ void main() { handlePos = endpoints[0].point + startHandleAdjustment; newHandlePos = handlePos + toNextLine; gesture = await tester.startGesture(handlePos, pointer: 7); + // Move handle up a small amount before dragging it down so the total global + // distance travelled can be accepted by the selection handle's [PanGestureRecognizer] as a drag. + // This way it can declare itself the winner before the [TapAndDragGestureRecognizer] that + // is on the selection overlay. + await tester.pump(); + await gesture.moveTo(handlePos - toNextLine); await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); @@ -3967,6 +4074,97 @@ void main() { expect(controller.selection.extentOffset, testValue.indexOf('g')); }); + testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|g'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for [kDoubleTapTimeout] after the up event, so our next down + // event does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + ); + + testWidgets('Can move cursor when dragging (Android)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + + // Tap on text field to gain focus, and set selection to '|e'. + // We await for [kDoubleTapTimeout] after the up event, so our + // next down event does not register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|d', and move to '|g'. + await gesture.down(textOffsetToPosition(tester, testValue.indexOf('d'))); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia }), + ); + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { int selectionChangedCount = 0; const String testValue = 'abc def ghi'; @@ -4400,7 +4598,7 @@ void main() { ); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + await tester.pump(const Duration(milliseconds: 300)); // skip past the frame where the opacity is zero // Verify the selection toolbar position Offset toolbarTopLeft = tester.getTopLeft(find.text('Paste')); @@ -6299,16 +6497,19 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } - await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 8); expect(controller.selection.extentOffset, 23); // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 28)); await tester.pumpAndSettle(); @@ -6403,16 +6604,19 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } - await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 8); expect(controller.selection.extentOffset, 23); // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 24)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 28)); await tester.pumpAndSettle(); @@ -6506,8 +6710,11 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 23); @@ -6516,6 +6723,7 @@ void main() { // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 5)); await tester.pumpAndSettle(); @@ -6610,16 +6818,19 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } - await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 23); expect(controller.selection.extentOffset, 8); // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 5)); await tester.pumpAndSettle(); diff --git a/packages/flutter/test/gestures/tap_test.dart b/packages/flutter/test/gestures/tap_test.dart index 7001509f8b00..e54dc8ed685b 100644 --- a/packages/flutter/test/gestures/tap_test.dart +++ b/packages/flutter/test/gestures/tap_test.dart @@ -313,7 +313,6 @@ void main() { tester.route(down1); expect(tapsRecognized, 0); - tester.route(up2); expect(tapsRecognized, 0); GestureBinding.instance.gestureArena.sweep(2); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index ea3ba4063214..307f7f1dce32 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2135,6 +2135,97 @@ void main() { expect(controller.selection.extentOffset, testValue.indexOf('g')); }); + testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS)', (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 iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|g'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for 300ms after the up event, so our next down event does not + // register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 7); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(textOffsetToPosition(tester, 7)); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + ); + + testWidgets('Can move cursor when dragging (Android)', (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')); + + // Tap on text field to gain focus, and set selection to '|e'. + // We await for 300ms after the up event, so our next down event does not + // register as a double tap. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|d', and move to '|g'. + await gesture.down(textOffsetToPosition(tester, testValue.indexOf('d'))); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + }, + variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia }), + ); + testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async { int selectionChangedCount = 0; const String testValue = 'abc def ghi'; @@ -2979,7 +3070,9 @@ void main() { renderEditable, ); handlePos = endpoints[0].point + startHandleAdjustment; - newHandlePos = handlePos - toNextLine; + // Move handle a sufficient global distance so it can be considered a drag + // by the selection handle's [PanGestureRecognizer]. + newHandlePos = handlePos - (toNextLine * 2); gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); await gesture.moveTo(newHandlePos); @@ -3004,6 +3097,12 @@ void main() { newHandlePos = handlePos + toNextLine; gesture = await tester.startGesture(handlePos, pointer: 7); await tester.pump(); + // Move handle up a small amount before dragging it down so the total global + // distance travelled can be accepted by the selection handle's [PanGestureRecognizer] as a drag. + // This way it can declare itself the winner before the [TapAndDragGestureRecognizer] that + // is on the selection overlay. + await gesture.moveTo(handlePos - toNextLine); + await tester.pump(); await gesture.moveTo(newHandlePos); await tester.pump(); await gesture.up(); @@ -8711,6 +8810,105 @@ void main() { expect(widget.selectionControls, equals(materialTextSelectionControls)); }); + testWidgets( + 'Can double click + drag with a mouse to select word by word', + (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 hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }, + ); + + testWidgets( + 'Can double tap + drag to select word by word', + (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 hPos = textOffsetToPosition(tester, testValue.indexOf('h')); + + // Tap on text field to gain focus, and set selection to '|e'. + final TestGesture gesture = await tester.startGesture(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('e')); + + // Here we tap on '|e' again, to register a double tap. This will select + // the word at the tapped position. + await gesture.down(ePos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + + // Drag, right after the double tap, to select word by word. + // Moving to the position of 'h', will extend the selection to 'ghi'. + await gesture.moveTo(hPos); + await tester.pumpAndSettle(); + + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); + }, + ); + testWidgets( 'double tap on top of cursor also selects word', (WidgetTester tester) async { @@ -9983,7 +10181,7 @@ void main() { ); await tester.pump(); await gesture.up(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(kDoubleTapTimeout); expect( controller.selection, const TextSelection.collapsed(offset: 3), @@ -10370,7 +10568,7 @@ void main() { expect(controller.value.selection.extentOffset, 1); }, variant: const TargetPlatformVariant({ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux })); - testWidgets('force press does not select a word', (WidgetTester tester) async { + testWidgets('Force press does not set selection on Android or Fuchsia touch devices', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', ); @@ -10405,13 +10603,56 @@ void main() { pressureMin: 0, )); + await gesture.up(); + await tester.pump(); + // We don't want this gesture to select any word on Android. expect(controller.selection, const TextSelection.collapsed(offset: -1)); + expect(find.byType(TextButton), findsNothing); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia })); + + testWidgets('Force press sets selection on desktop platforms that do not support it', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TextField( + controller: controller, + ), + ), + ), + ); + + final Offset offset = tester.getTopLeft(find.byType(TextField)) + const Offset(150.0, 9.0); + + final int pointerValue = tester.nextPointer; + final TestGesture gesture = await tester.createGesture(); + await gesture.downWithCustomEvent( + offset, + PointerDownEvent( + pointer: pointerValue, + position: offset, + pressure: 0.0, + pressureMax: 6.0, + pressureMin: 0.0, + ), + ); + await gesture.updateWithCustomEvent(PointerMoveEvent( + pointer: pointerValue, + position: offset + const Offset(150.0, 9.0), + pressure: 0.5, + pressureMin: 0, + )); await gesture.up(); await tester.pump(); + + // We don't want this gesture to select any word on Android. + expect(controller.selection, const TextSelection.collapsed(offset: 9)); expect(find.byType(TextButton), findsNothing); - }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); + }, variant: const TargetPlatformVariant({ TargetPlatform.linux, TargetPlatform.windows })); testWidgets('force press selects word', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -12697,16 +12938,19 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } - await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 8); expect(controller.selection.extentOffset, 23); // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 28)); await tester.pumpAndSettle(); @@ -12904,16 +13148,19 @@ void main() { pointer: 7, kind: PointerDeviceKind.mouse, ); + await tester.pumpAndSettle(); if (isTargetPlatformMobile) { await gesture.up(); + // Not a double tap + drag. + await tester.pumpAndSettle(kDoubleTapTimeout); } - await tester.pumpAndSettle(); expect(controller.selection.baseOffset, 23); expect(controller.selection.extentOffset, 8); // Expand the selection a bit. if (isTargetPlatformMobile) { await gesture.down(textOffsetToPosition(tester, 8)); + await tester.pumpAndSettle(); } await gesture.moveTo(textOffsetToPosition(tester, 5)); await tester.pumpAndSettle(); diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 2a1a992a9662..ce3bfde3c87e 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -196,7 +196,7 @@ void main() { final TestGesture gesture = await tester.startGesture(textFieldStart, kind: PointerDeviceKind.mouse); await tester.pump(const Duration(seconds: 2)); await gesture.up(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(kDoubleTapTimeout); final FlutterError error = tester.takeException() as FlutterError; expect( @@ -3907,6 +3907,7 @@ void main() { expect(find.byType(CupertinoButton), findsNWidgets(1)); // Double tap selecting the same word somewhere else is fine. + await tester.pumpAndSettle(kDoubleTapTimeout); await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0)); await tester.pump(const Duration(milliseconds: 50)); // First tap moved the cursor. @@ -3928,6 +3929,7 @@ void main() { editableTextState.hideToolbar(); await tester.pumpAndSettle(); + await tester.pumpAndSettle(kDoubleTapTimeout); await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0)); await tester.pump(const Duration(milliseconds: 50)); // First tap moved the cursor. diff --git a/packages/flutter/test/widgets/tap_and_drag_gestures_test.dart b/packages/flutter/test/widgets/tap_and_drag_gestures_test.dart new file mode 100644 index 000000000000..32e50e3aeb21 --- /dev/null +++ b/packages/flutter/test/widgets/tap_and_drag_gestures_test.dart @@ -0,0 +1,552 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../gestures/gesture_tester.dart'; + +// Anything longer than [kDoubleTapTimeout] will reset the consecutive tap count. +final Duration kConsecutiveTapDelay = kDoubleTapTimeout ~/ 2; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List events; + late TapAndDragGestureRecognizer tapAndDrag; + + setUp(() { + events = []; + tapAndDrag = TapAndDragGestureRecognizer() + ..dragStartBehavior = DragStartBehavior.down + ..maxConsecutiveTap = 3 + ..onTapDown = (TapDragDownDetails details) { + events.add('down#${details.consecutiveTapCount}'); + } + ..onTapUp = (TapDragUpDetails details) { + events.add('up#${details.consecutiveTapCount}'); + } + ..onDragStart = (TapDragStartDetails details) { + events.add('dragstart#${details.consecutiveTapCount}'); + } + ..onDragUpdate = (TapDragUpdateDetails details) { + events.add('dragupdate#${details.consecutiveTapCount}'); + } + ..onDragEnd = (TapDragEndDetails details) { + events.add('dragend#${details.consecutiveTapCount}'); + } + ..onCancel = () { + events.add('cancel'); + }; + }); + + // Down/up pair 1: normal tap sequence + const PointerDownEvent down1 = PointerDownEvent( + pointer: 1, + position: Offset(10.0, 10.0), + ); + + const PointerUpEvent up1 = PointerUpEvent( + pointer: 1, + position: Offset(11.0, 9.0), + ); + + const PointerCancelEvent cancel1 = PointerCancelEvent( + pointer: 1, + ); + + // Down/up pair 2: normal tap sequence close to pair 1 + const PointerDownEvent down2 = PointerDownEvent( + pointer: 2, + position: Offset(12.0, 12.0), + ); + + const PointerUpEvent up2 = PointerUpEvent( + pointer: 2, + position: Offset(13.0, 11.0), + ); + + // Down/up pair 3: normal tap sequence close to pair 1 + const PointerDownEvent down3 = PointerDownEvent( + pointer: 3, + position: Offset(12.0, 12.0), + ); + + const PointerUpEvent up3 = PointerUpEvent( + pointer: 3, + position: Offset(13.0, 11.0), + ); + + // Down/up pair 4: normal tap sequence far away from pair 1 + const PointerDownEvent down4 = PointerDownEvent( + pointer: 4, + position: Offset(130.0, 130.0), + ); + + const PointerUpEvent up4 = PointerUpEvent( + pointer: 4, + position: Offset(131.0, 129.0), + ); + + // Down/move/up sequence 5: intervening motion + const PointerDownEvent down5 = PointerDownEvent( + pointer: 5, + position: Offset(10.0, 10.0), + ); + + const PointerMoveEvent move5 = PointerMoveEvent( + pointer: 5, + position: Offset(25.0, 25.0), + ); + + const PointerUpEvent up5 = PointerUpEvent( + pointer: 5, + position: Offset(25.0, 25.0), + ); + + testGesture('Recognizes consecutive taps', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + + events.clear(); + tester.async.elapse(kConsecutiveTapDelay); + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(events, ['down#2', 'up#2']); + + events.clear(); + tester.async.elapse(kConsecutiveTapDelay); + tapAndDrag.addPointer(down3); + tester.closeArena(3); + tester.route(down3); + tester.route(up3); + GestureBinding.instance.gestureArena.sweep(3); + expect(events, ['down#3', 'up#3']); + }); + + testGesture('Resets if times out in between taps', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + + events.clear(); + tester.async.elapse(const Duration(milliseconds: 1000)); + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Resets if taps are far apart', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + + events.clear(); + tester.async.elapse(const Duration(milliseconds: 100)); + tapAndDrag.addPointer(down4); + tester.closeArena(4); + tester.route(down4); + tester.route(up4); + GestureBinding.instance.gestureArena.sweep(4); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Resets if consecutiveTapCount reaches maxConsecutiveTap', (GestureTester tester) { + // First tap. + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + + // Second tap. + events.clear(); + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(events, ['down#2', 'up#2']); + + // Third tap. + events.clear(); + tapAndDrag.addPointer(down3); + tester.closeArena(3); + tester.route(down3); + tester.route(up3); + GestureBinding.instance.gestureArena.sweep(3); + expect(events, ['down#3', 'up#3']); + + // Fourth tap. Here we arrived at the `maxConsecutiveTap` for `consecutiveTapCount` + // so our count should reset and our new count should be `1`. + events.clear(); + tapAndDrag.addPointer(down3); + tester.closeArena(3); + tester.route(down3); + tester.route(up3); + GestureBinding.instance.gestureArena.sweep(3); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Should recognize drag', (GestureTester tester) { + final TestPointer pointer = TestPointer(5); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(down); + tester.closeArena(5); + tester.route(down); + tester.route(pointer.move(const Offset(40.0, 45.0))); + tester.route(pointer.up()); + GestureBinding.instance.gestureArena.sweep(5); + expect(events, ['down#1', 'dragstart#1', 'dragupdate#1', 'dragend#1']); + }); + + testGesture('Recognizes consecutive taps + drag', (GestureTester tester) { + final TestPointer pointer = TestPointer(5); + final PointerDownEvent downA = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(downA); + tester.closeArena(5); + tester.route(downA); + tester.route(pointer.up()); + GestureBinding.instance.gestureArena.sweep(5); + + tester.async.elapse(kConsecutiveTapDelay); + + final PointerDownEvent downB = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(downB); + tester.closeArena(5); + tester.route(downB); + tester.route(pointer.up()); + GestureBinding.instance.gestureArena.sweep(5); + + tester.async.elapse(kConsecutiveTapDelay); + + final PointerDownEvent downC = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(downC); + tester.closeArena(5); + tester.route(downC); + tester.route(pointer.move(const Offset(40.0, 45.0))); + tester.route(pointer.up()); + expect(events, [ + 'down#1', + 'up#1', + 'down#2', + 'up#2', + 'down#3', + 'dragstart#3', + 'dragupdate#3', + 'dragend#3']); + }); + + testGesture('Recognizer rejects pointer that is not the primary one (FIFO) - before acceptance', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tapAndDrag.addPointer(down2); + tester.closeArena(1); + tester.route(down1); + + tester.closeArena(2); + tester.route(down2); + + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Calls tap up when the recognizer accepts before handleEvent is called', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + GestureBinding.instance.gestureArena.sweep(1); + tester.route(down1); + tester.route(up1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Recognizer rejects pointer that is not the primary one (FILO) - before acceptance', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tapAndDrag.addPointer(down2); + tester.closeArena(1); + tester.route(down1); + + tester.closeArena(2); + tester.route(down2); + + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Recognizer rejects pointer that is not the primary one (FIFO) - after acceptance', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Recognizer rejects pointer that is not the primary one (FILO) - after acceptance', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Recognizer detects tap gesture when pointer does not move past tap tolerance', (GestureTester tester) { + // In this test the tap has not travelled past the tap tolerance defined by + // [kDoubleTapTouchSlop]. It is expected for the recognizer to detect a tap + // and fire drag cancel. + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Recognizer detects drag gesture when pointer moves past tap tolerance but not the drag minimum', (GestureTester tester) { + // In this test, the pointer has moved past the tap tolerance but it has + // not reached the distance travelled to be considered a drag gesture. In + // this case it is expected for the recognizer to detect a drag and fire tap cancel. + tapAndDrag.addPointer(down5); + tester.closeArena(5); + tester.route(down5); + tester.route(move5); + tester.route(up5); + GestureBinding.instance.gestureArena.sweep(5); + expect(events, ['down#1', 'dragstart#1', 'dragend#1']); + }); + + testGesture('Recognizer loses when competing against a DragGestureRecognizer when the pointer travels minimum distance to be considered a drag', (GestureTester tester) { + final PanGestureRecognizer pans = PanGestureRecognizer() + ..onStart = (DragStartDetails details) { + events.add('panstart'); + } + ..onUpdate = (DragUpdateDetails details) { + events.add('panupdate'); + } + ..onEnd = (DragEndDetails details) { + events.add('panend'); + } + ..onCancel = () { + events.add('pancancel'); + }; + + final TestPointer pointer = TestPointer(5); + final PointerDownEvent downB = pointer.down(const Offset(10.0, 10.0)); + // When competing against another [DragGestureRecognizer], the [TapAndDragGestureRecognizer] + // will only win when it is the last recognizer in the arena. + tapAndDrag.addPointer(downB); + pans.addPointer(downB); + tester.closeArena(5); + tester.route(downB); + tester.route(pointer.move(const Offset(40.0, 45.0))); + tester.route(pointer.up()); + expect(events, [ + 'panstart', + 'panend']); + }); + + testGesture('Beats LongPressGestureRecognizer on a consecutive tap greater than one', (GestureTester tester) { + final LongPressGestureRecognizer longpress = LongPressGestureRecognizer() + ..onLongPressStart = (LongPressStartDetails details) { + events.add('longpressstart'); + } + ..onLongPressMoveUpdate = (LongPressMoveUpdateDetails details) { + events.add('longpressmoveupdate'); + } + ..onLongPressEnd = (LongPressEndDetails details) { + events.add('longpressend'); + } + ..onLongPressCancel = () { + events.add('longpresscancel'); + }; + + final TestPointer pointer = TestPointer(5); + final PointerDownEvent downA = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(downA); + longpress.addPointer(downA); + tester.closeArena(5); + tester.route(downA); + tester.route(pointer.up()); + GestureBinding.instance.gestureArena.sweep(5); + + tester.async.elapse(kConsecutiveTapDelay); + + final PointerDownEvent downB = pointer.down(const Offset(10.0, 10.0)); + tapAndDrag.addPointer(downB); + longpress.addPointer(downB); + tester.closeArena(5); + tester.route(downB); + + tester.async.elapse(const Duration(milliseconds: 500)); + + tester.route(pointer.move(const Offset(40.0, 45.0))); + tester.route(pointer.up()); + expect(events, [ + 'longpresscancel', + 'down#1', + 'up#1', + 'down#2', + 'dragstart#2', + 'dragupdate#2', + 'dragend#2']); + }); + + testGesture('Beats TapGestureRecognizer when the pointer has not moved and this recognizer is the first in the arena', (GestureTester tester) { + final TapGestureRecognizer taps = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + events.add('tapdown'); + } + ..onTapUp = (TapUpDetails details) { + events.add('tapup'); + } + ..onTapCancel = () { + events.add('tapscancel'); + }; + + tapAndDrag.addPointer(down1); + taps.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Beats TapGestureRecognizer when the pointer has exceeded the slop tolerance', (GestureTester tester) { + final TapGestureRecognizer taps = TapGestureRecognizer() + ..onTapDown = (TapDownDetails details) { + events.add('tapdown'); + } + ..onTapUp = (TapUpDetails details) { + events.add('tapup'); + } + ..onTapCancel = () { + events.add('tapscancel'); + }; + + tapAndDrag.addPointer(down5); + taps.addPointer(down5); + tester.closeArena(5); + tester.route(down5); + tester.route(move5); + tester.route(up5); + GestureBinding.instance.gestureArena.sweep(5); + expect(events, ['down#1', 'dragstart#1', 'dragend#1']); + + events.clear(); + tester.async.elapse(const Duration(milliseconds: 1000)); + taps.addPointer(down1); + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['tapdown', 'tapup']); + }); + + testGesture('Ties with PanGestureRecognizer when pointer has not met sufficient global distance to be a drag', (GestureTester tester) { + final PanGestureRecognizer pans = PanGestureRecognizer() + ..onStart = (DragStartDetails details) { + events.add('panstart'); + } + ..onUpdate = (DragUpdateDetails details) { + events.add('panupdate'); + } + ..onEnd = (DragEndDetails details) { + events.add('panend'); + } + ..onCancel = () { + events.add('pancancel'); + }; + + tapAndDrag.addPointer(down5); + pans.addPointer(down5); + tester.closeArena(5); + tester.route(down5); + tester.route(move5); + tester.route(up5); + GestureBinding.instance.gestureArena.sweep(5); + expect(events, ['pancancel']); + }); + + testGesture('Defaults to drag when pointer dragged past slop tolerance', (GestureTester tester) { + tapAndDrag.addPointer(down5); + tester.closeArena(5); + tester.route(down5); + tester.route(move5); + tester.route(up5); + GestureBinding.instance.gestureArena.sweep(5); + expect(events, ['down#1', 'dragstart#1', 'dragend#1']); + + events.clear(); + tester.async.elapse(const Duration(milliseconds: 1000)); + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(up1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'up#1']); + }); + + testGesture('Fires cancel and resets for PointerCancelEvent', (GestureTester tester) { + tapAndDrag.addPointer(down1); + tester.closeArena(1); + tester.route(down1); + tester.route(cancel1); + GestureBinding.instance.gestureArena.sweep(1); + expect(events, ['down#1', 'cancel']); + + events.clear(); + tester.async.elapse(const Duration(milliseconds: 100)); + tapAndDrag.addPointer(down2); + tester.closeArena(2); + tester.route(down2); + tester.route(up2); + GestureBinding.instance.gestureArena.sweep(2); + expect(events, ['down#1', 'up#1']); + }); +} diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index f8508114870e..034b3418854d 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -25,16 +25,16 @@ void main() { late int dragEndCount; const Offset forcePressOffset = Offset(400.0, 50.0); - void handleTapDown(TapDownDetails details) { tapCount++; } - void handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; } + void handleTapDown(TapDragDownDetails details) { tapCount++; } + void handleSingleTapUp(TapDragUpDetails details) { singleTapUpCount++; } void handleSingleTapCancel() { singleTapCancelCount++; } void handleSingleLongTapStart(LongPressStartDetails details) { singleLongTapStartCount++; } - void handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; } + void handleDoubleTapDown(TapDragDownDetails 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++; } + void handleDragSelectionStart(TapDragStartDetails details) { dragStartCount++; } + void handleDragSelectionUpdate(TapDragUpdateDetails details) { dragUpdateCount++; } + void handleDragSelectionEnd(TapDragEndDetails details) { dragEndCount++; } setUp(() { tapCount = 0; @@ -173,7 +173,12 @@ void main() { await gesture.moveBy(const Offset(100, 100)); await tester.pump(); expect(singleTapUpCount, 0); - expect(tapCount, 0); + // Before the move to TapAndDragGestureRecognizer the tapCount was 0 because the + // TapGestureRecognizer rejected itself when the initial pointer moved past a certain + // threshold. With TapAndDragGestureRecognizer, we have two thresholds, a normal tap + // threshold, and a drag threshold, so it is possible for the tap count to increase + // even though the original pointer has moved beyond the tap threshold. + expect(tapCount, 1); expect(singleTapCancelCount, 0); expect(doubleTapDownCount, 0); expect(singleLongTapStartCount, 0); @@ -181,7 +186,7 @@ void main() { await gesture.up(); // Nothing else happens on up. expect(singleTapUpCount, 0); - expect(tapCount, 0); + expect(tapCount, 1); expect(singleTapCancelCount, 0); expect(doubleTapDownCount, 0); expect(singleLongTapStartCount, 0); @@ -195,7 +200,7 @@ void main() { await tester.pump(); expect(singleTapUpCount, 0); expect(tapCount, 1); - expect(singleTapCancelCount, 1); + expect(singleTapCancelCount, 0); expect(doubleTapDownCount, 0); expect(singleLongTapStartCount, 0); }); @@ -370,7 +375,7 @@ void main() { expect(singleLongTapStartCount, 0); }); - testWidgets('a touch drag is not recognized for text selection', (WidgetTester tester) async { + testWidgets('a touch drag is recognized for text selection', (WidgetTester tester) async { await pumpGestureDetector(tester); final int pointerValue = tester.nextPointer; @@ -384,11 +389,12 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - expect(tapCount, 0); + expect(tapCount, 1); expect(singleTapUpCount, 0); - expect(dragStartCount, 0); - expect(dragUpdateCount, 0); - expect(dragEndCount, 0); + expect(singleTapCancelCount, 0); + expect(dragStartCount, 1); + expect(dragUpdateCount, 1); + expect(dragEndCount, 1); }); testWidgets('a mouse drag is recognized for text selection', (WidgetTester tester) async { @@ -406,8 +412,11 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - expect(tapCount, 0); + // The tap and drag gesture recognizer will detect the tap down, but not the tap up. + expect(tapCount, 1); + expect(singleTapCancelCount, 0); expect(singleTapUpCount, 0); + expect(dragStartCount, 1); expect(dragUpdateCount, 1); expect(dragEndCount, 1); @@ -428,6 +437,11 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); + // The tap and drag gesture recognizer will detect the tap down, but not the tap up. + expect(tapCount, 1); + expect(singleTapCancelCount, 0); + expect(singleTapUpCount, 0); + expect(dragStartCount, 1); expect(dragUpdateCount, 1); expect(dragEndCount, 1); @@ -746,12 +760,18 @@ void main() { final Offset position = textOffsetToPosition(tester, 4); await tester.tapAt(position); - await tester.pump(); + // Don't do a double tap drag. + await tester.pump(const Duration(milliseconds: 300)); expect(controller.selection.isCollapsed, isTrue); expect(controller.selection.baseOffset, 4); final TestGesture gesture = await tester.startGesture(position, kind: PointerDeviceKind.mouse); + + // Checking that double-tap was not registered. + expect(controller.selection.isCollapsed, isTrue); + expect(controller.selection.baseOffset, 4); + addTearDown(gesture.removePointer); await tester.pump(); await gesture.moveTo(textOffsetToPosition(tester, 7));