Unverified Commit 9080d1ac authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

Reland "Add support for double tap and drag for text selection #109573" (#117502)

* Revert "Revert "Add support for double tap and drag for text selection (#109573)" (#117497)"

This reverts commit 39fa0117.

* Allow TapAndDragGestureRecognizer to accept pointer events from any devices -- the TapGestureRecognizer it replaces was previously doing this
Co-authored-by: 's avatarRenzo Olivares <roliv@google.com>
parent dbd36fb1
......@@ -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();
}
}
......
......@@ -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);
......
......@@ -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.
......
......@@ -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.
......
......@@ -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) {
......
......@@ -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();
......
// 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<LogicalKeyboardKey> keysPressedOnDown;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('globalPosition', globalPosition));
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
properties.add(DiagnosticsProperty<PointerDeviceKind?>('kind', kind));
properties.add(DiagnosticsProperty<int>('consecutiveTapCount', consecutiveTapCount));
properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('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<LogicalKeyboardKey> keysPressedOnDown;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('globalPosition', globalPosition));
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
properties.add(DiagnosticsProperty<PointerDeviceKind?>('kind', kind));
properties.add(DiagnosticsProperty<int>('consecutiveTapCount', consecutiveTapCount));
properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('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<LogicalKeyboardKey> keysPressedOnDown;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Duration?>('sourceTimeStamp', sourceTimeStamp));
properties.add(DiagnosticsProperty<Offset>('globalPosition', globalPosition));
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
properties.add(DiagnosticsProperty<PointerDeviceKind?>('kind', kind));
properties.add(DiagnosticsProperty<int>('consecutiveTapCount', consecutiveTapCount));
properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('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<LogicalKeyboardKey> keysPressedOnDown;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Duration?>('sourceTimeStamp', sourceTimeStamp));
properties.add(DiagnosticsProperty<Offset>('delta', delta));
properties.add(DiagnosticsProperty<double?>('primaryDelta', primaryDelta));
properties.add(DiagnosticsProperty<Offset>('globalPosition', globalPosition));
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
properties.add(DiagnosticsProperty<PointerDeviceKind?>('kind', kind));
properties.add(DiagnosticsProperty<Offset>('offsetFromOrigin', offsetFromOrigin));
properties.add(DiagnosticsProperty<Offset>('localOffsetFromOrigin', localOffsetFromOrigin));
properties.add(DiagnosticsProperty<int>('consecutiveTapCount', consecutiveTapCount));
properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('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<LogicalKeyboardKey> keysPressedOnDown;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Velocity>('velocity', velocity));
properties.add(DiagnosticsProperty<double?>('primaryVelocity', primaryVelocity));
properties.add(DiagnosticsProperty<int>('consecutiveTapCount', consecutiveTapCount));
properties.add(DiagnosticsProperty<Set<LogicalKeyboardKey>>('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<LogicalKeyboardKey> get keysPressedOnDown => _keysPressedOnDown ?? <LogicalKeyboardKey>{};
// 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<LogicalKeyboardKey>? _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<int> _acceptedActivePointers = <int>{};
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<void>('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<void>('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<void>('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<void>('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;
}
}
}
......@@ -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<LogicalKeyboardKey> keysPressed) {
return keysPressed.any(<LogicalKeyboardKey>{ 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>{
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 {
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,
);
}
_dragStartScrollOffset = _scrollPosition;
_dragStartViewportOffset = renderEditable.offset.pixels;
break;
case null:
break;
}
break;
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
break;
}
}
}
/// 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,
);
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: startDetails.globalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
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<TextSelectionGestureDetector> {
// 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<TextSelectionGestureDetec
}
void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
if (widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart!(details);
}
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
if (widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate!(details);
}
}
void _handleLongPressEnd(LongPressEndDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) {
if (widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd!(details);
}
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
assert(secondTapOffset != null);
if (_lastTapOffset == null) {
return false;
}
final Offset difference = secondTapOffset - _lastTapOffset!;
return difference.distance <= kDoubleTapSlop;
}
@override
......@@ -2856,10 +2924,7 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
(TapGestureRecognizer instance) {
instance
..onSecondaryTap = widget.onSecondaryTap
..onSecondaryTapDown = widget.onSecondaryTapDown
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
..onSecondaryTapDown = widget.onSecondaryTapDown;
},
);
......@@ -2867,7 +2932,7 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
widget.onSingleLongTapMoveUpdate != null ||
widget.onSingleLongTapEnd != null) {
gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch),
() => LongPressGestureRecognizer(debugOwner: this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.touch }),
(LongPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleLongPressStart
......@@ -2880,16 +2945,21 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
if (widget.onDragSelectionStart != null ||
widget.onDragSelectionUpdate != null ||
widget.onDragSelectionEnd != null) {
gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(debugOwner: this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(PanGestureRecognizer instance) {
gestures[TapAndDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndDragGestureRecognizer>(
() => TapAndDragGestureRecognizer(debugOwner: this),
(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;
},
);
}
......
......@@ -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';
......
......@@ -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>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ 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>{ 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>{ 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();
......
......@@ -313,7 +313,6 @@ void main() {
tester.route(down1);
expect(tapsRecognized, 0);
tester.route(up2);
expect(tapsRecognized, 0);
GestureBinding.instance.gestureArena.sweep(2);
......
......@@ -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>{ 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>{ 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>{ 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>{ 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>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ 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();
......
......@@ -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.
......
// 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<String> events;
late TapAndDragGestureRecognizer tapAndDrag;
setUp(() {
events = <String>[];
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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>[
'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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>[
'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, <String>[
'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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['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, <String>['down#1', 'up#1']);
});
}
......@@ -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));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment