diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index d17294c7493..bd384cecf07 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -482,8 +482,21 @@ class _CupertinoTextFieldState extends State with AutomaticK _requestKeyboard(); } - void _handleSingleLongTapDown() { - _renderEditable.selectPosition(cause: SelectionChangedCause.longPress); + void _handleSingleLongTapStart(LongPressStartDetails details) { + _renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + + void _handleSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + _renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + + void _handleSingleLongTapEnd(LongPressEndDetails details) { _editableTextKey.currentState.showToolbar(); } @@ -492,6 +505,12 @@ class _CupertinoTextFieldState extends State with AutomaticK _editableTextKey.currentState.showToolbar(); } + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { + if (cause == SelectionChangedCause.longPress) { + _editableTextKey.currentState?.bringIntoView(selection.base); + } + } + @override bool get wantKeepAlive => _controller?.text?.isNotEmpty == true; @@ -646,6 +665,7 @@ class _CupertinoTextFieldState extends State with AutomaticK selectionColor: _kSelectionHighlightColor, selectionControls: cupertinoTextSelectionControls, onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, inputFormatters: formatters, @@ -686,7 +706,9 @@ class _CupertinoTextFieldState extends State with AutomaticK onForcePressStart: _handleForcePressStarted, onForcePressEnd: _handleForcePressEnded, onSingleTapUp: _handleSingleTapUp, - onSingleLongTapDown: _handleSingleLongTapDown, + onSingleLongTapStart: _handleSingleLongTapStart, + onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate, + onSingleLongTapEnd: _handleSingleLongTapEnd, onDoubleTapDown: _handleDoubleTapDown, behavior: HitTestBehavior.translucent, child: _addTextDependentAttachments(paddedEditable, textStyle), diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index f9f723989a5..780f4223d02 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -7,54 +7,221 @@ import 'constants.dart'; import 'events.dart'; import 'recognizer.dart'; -/// Signature for when a pointer has remained in contact with the screen at the +/// Callback signature for [LongPressGestureRecognizer.onLongPress]. +/// +/// Called when a pointer has remained in contact with the screen at the /// same location for a long period of time. typedef GestureLongPressCallback = void Function(); -/// Signature for when a pointer stops contacting the screen after a long press gesture was detected. +/// Callback signature for [LongPressGestureRecognizer.onLongPressUp]. +/// +/// Called when a pointer stops contacting the screen after a long press +/// gesture was detected. typedef GestureLongPressUpCallback = void Function(); +/// Callback signature for [LongPressGestureRecognizer.onLongPressStart]. +/// +/// Called when a pointer has remained in contact with the screen at the +/// same location for a long period of time. Also reports the long press down +/// position. +typedef GestureLongPressStartCallback = void Function(LongPressStartDetails details); + +/// Callback signature for [LongPressGestureRecognizer.onLongPressMoveUpdate]. +/// +/// Called when a pointer is moving after being held in contact at the same +/// location for a long period of time. Reports the new position and its offset +/// from the original down position. +typedef GestureLongPressMoveUpdateCallback = void Function(LongPressMoveUpdateDetails details); + +/// Callback signature for [LongPressGestureRecognizer.onLongPressEnd]. +/// +/// Called when a pointer stops contacting the screen after a long press +/// gesture was detected. Also reports the position where the pointer stopped +/// contacting the screen. +typedef GestureLongPressEndCallback = void Function(LongPressEndDetails details); + +/// Details for callbacks that use [GestureLongPressStartCallback]. +/// +/// See also: +/// +/// * [LongPressGestureRecognizer.onLongPressStart], which uses [GestureLongPressStartCallback]. +/// * [LongPressMoveUpdateDetails], the details for [GestureLongPressMoveUpdateCallback] +/// * [LongPressEndDetails], the details for [GestureLongPressEndCallback]. +class LongPressStartDetails { + /// Creates the details for a [GestureLongPressStartCallback]. + /// + /// The [globalPosition] argument must not be null. + const LongPressStartDetails({ this.globalPosition = Offset.zero }) + : assert(globalPosition != null); + + /// The global position at which the pointer contacted the screen. + final Offset globalPosition; +} + +/// Details for callbacks that use [GestureLongPressMoveUpdateCallback]. +/// +/// See also: +/// +/// * [LongPressGestureRecognizer.onLongPressMoveUpdate], which uses [GestureLongPressMoveUpdateCallback]. +/// * [LongPressEndDetails], the details for [GestureLongPressEndCallback] +/// * [LongPressStartDetails], the details for [GestureLongPressStartCallback]. +class LongPressMoveUpdateDetails { + /// Creates the details for a [GestureLongPressMoveUpdateCallback]. + /// + /// The [globalPosition] and [offsetFromOrigin] arguments must not be null. + const LongPressMoveUpdateDetails({ + this.globalPosition = Offset.zero, + this.offsetFromOrigin = Offset.zero, + }) : assert(globalPosition != null), + assert(offsetFromOrigin != null); + + /// The global position of the pointer when it triggered this update. + final Offset globalPosition; + + /// A delta offset from the point where the long press drag initially contacted + /// the screen to the point where the pointer is currently located (the + /// present [globalPosition]) when this callback is triggered. + final Offset offsetFromOrigin; +} + +/// Details for callbacks that use [GestureLongPressEndCallback]. +/// +/// See also: +/// +/// * [LongPressGestureRecognizer.onLongPressEnd], which uses [GestureLongPressEndCallback]. +/// * [LongPressMoveUpdateDetails], the details for [GestureLongPressMoveUpdateCallback] +/// * [LongPressStartDetails], the details for [GestureLongPressStartCallback]. +class LongPressEndDetails { + /// Creates the details for a [GestureLongPressEndCallback]. + /// + /// The [globalPosition] argument must not be null. + const LongPressEndDetails({ this.globalPosition = Offset.zero }) + : assert(globalPosition != null); + + /// The global position at which the pointer lifted from the screen. + final Offset globalPosition; +} + /// Recognizes when the user has pressed down at the same location for a long /// period of time. +/// +/// The gesture must not deviate in position from its touch down point for 500ms +/// until it's recognized. Once the gesture is accepted, the finger can be +/// moved, triggering [onLongPressMoveUpdate] callbacks, unless the +/// [postAcceptSlopTolerance] constructor argument is specified. class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// Creates a long-press gesture recognizer. /// - /// Consider assigning the [onLongPress] callback after creating this object. - LongPressGestureRecognizer({ Object debugOwner }) - : super(deadline: kLongPressTimeout, debugOwner: debugOwner); + /// Consider assigning the [onLongPressStart] callback after creating this + /// object. + /// + /// The [postAcceptSlopTolerance] argument can be used to specify a maximum + /// allowed distance for the gesture to deviate from the starting point once + /// the long press has triggered. If the gesture deviates past that point, + /// subsequent callbacks ([onLongPressMoveUpdate], [onLongPressUp], + /// [onLongPressEnd]) will stop. Defaults to null, which means the gesture + /// can be moved without limit once the long press is accepted. + LongPressGestureRecognizer({ + double postAcceptSlopTolerance, + Object debugOwner, + }) : super( + deadline: kLongPressTimeout, + postAcceptSlopTolerance: postAcceptSlopTolerance, + debugOwner: debugOwner, + ); bool _longPressAccepted = false; + Offset _longPressOrigin; + /// Called when a long press gesture has been recognized. + /// + /// See also: + /// + /// * [onLongPressStart], which has the same timing but has data for the + /// press location. GestureLongPressCallback onLongPress; - /// Called when the pointer stops contacting the screen after the long-press gesture has been recognized. + /// Callback for long press start with gesture location. + /// + /// See also: + /// + /// * [onLongPress], which has the same timing but without the location data. + GestureLongPressStartCallback onLongPressStart; + + /// Callback for moving the gesture after the lang press is recognized. + GestureLongPressMoveUpdateCallback onLongPressMoveUpdate; + + /// Called when the pointer stops contacting the screen after the long-press. + /// + /// See also: + /// + /// * [onLongPressEnd], which has the same timing but has data for the up + /// gesture location. GestureLongPressUpCallback onLongPressUp; + /// Callback for long press end with gesture location. + /// + /// See also: + /// + /// * [onLongPressUp], which has the same timing but without the location data. + GestureLongPressEndCallback onLongPressEnd; + @override void didExceedDeadline() { resolve(GestureDisposition.accepted); _longPressAccepted = true; + super.acceptGesture(primaryPointer); if (onLongPress != null) { invokeCallback('onLongPress', onLongPress); } + if (onLongPressStart != null) { + invokeCallback('onLongPressStart', () { + onLongPressStart(LongPressStartDetails( + globalPosition: _longPressOrigin, + )); + }); + } } @override void handlePrimaryPointer(PointerEvent event) { if (event is PointerUpEvent) { - if (_longPressAccepted == true && onLongPressUp != null) { + if (_longPressAccepted == true) { + if (onLongPressUp != null) { + invokeCallback('onLongPressUp', onLongPressUp); + } + if (onLongPressEnd != null) { + invokeCallback('onLongPressEnd', () { + onLongPressEnd(LongPressEndDetails( + globalPosition: event.position, + )); + }); + } _longPressAccepted = false; - invokeCallback('onLongPressUp', onLongPressUp); } else { resolve(GestureDisposition.rejected); } } else if (event is PointerDownEvent || event is PointerCancelEvent) { - // the first touch, initialize the flag with false + // The first touch. _longPressAccepted = false; + _longPressOrigin = event.position; + } else if (event is PointerMoveEvent && _longPressAccepted && onLongPressMoveUpdate != null) { + invokeCallback('onLongPressMoveUpdate', () { + onLongPressMoveUpdate(LongPressMoveUpdateDetails( + globalPosition: event.position, + offsetFromOrigin: event.position - _longPressOrigin, + )); + }); } } + @override + void acceptGesture(int pointer) { + // Winning the arena isn't important here since it may happen from a sweep. + // Explicitly exceeding the deadline puts the gesture in accepted state. + } + @override String get debugDescription => 'long press'; } diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index d5ddbd619ee..79504841098 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -261,11 +261,11 @@ abstract class OneSequenceGestureRecognizer extends GestureRecognizer { /// The possible states of a [PrimaryPointerGestureRecognizer]. /// -/// The recognizer advances from [ready] to [possible] when starts tracking a -/// primary pointer. When the primary pointer is resolve (either accepted or -/// or rejected), the recognizers advances to [defunct]. Once the recognizer -/// has stopped tracking any remaining pointers, the recognizer returns to -/// [ready]. +/// The recognizer advances from [ready] to [possible] when it starts tracking a +/// primary pointer. When the primary pointer is resolved in the gesture +/// arena (either accepted or rejected), the recognizers advances to [defunct]. +/// Once the recognizer has stopped tracking any remaining pointers, the +/// recognizer returns to [ready]. enum GestureRecognizerState { /// The recognizer is ready to start recognizing a gesture. ready, @@ -283,19 +283,52 @@ enum GestureRecognizerState { /// A base class for gesture recognizers that track a single primary pointer. /// -/// Gestures based on this class will reject the gesture if the primary pointer -/// travels beyond [kTouchSlop] pixels from the original contact point. +/// Gestures based on this class will stop tracking the gesture if the primary +/// pointer travels beyond [preAcceptSlopTolerance] or [postAcceptSlopTolerance] +/// pixels from the original contact point of the gesture. +/// +/// If the [preAcceptSlopTolerance] was breached before the gesture was accepted +/// in the gesture arena, the gesture will be rejected. abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecognizer { /// Initializes the [deadline] field during construction of subclasses. PrimaryPointerGestureRecognizer({ this.deadline, + this.preAcceptSlopTolerance = kTouchSlop, + this.postAcceptSlopTolerance = kTouchSlop, Object debugOwner, - }) : super(debugOwner: debugOwner); + }) : assert( + preAcceptSlopTolerance == null || preAcceptSlopTolerance >= 0, + 'The preAcceptSlopTolerance must be positive or null', + ), + assert( + postAcceptSlopTolerance == null || postAcceptSlopTolerance >= 0, + 'The postAcceptSlopTolerance must be positive or null', + ), + super(debugOwner: debugOwner); /// If non-null, the recognizer will call [didExceedDeadline] after this /// amount of time has elapsed since starting to track the primary pointer. final Duration deadline; + /// The maximum distance in logical pixels the gesture is allowed to drift + /// from the initial touch down position before the gesture is accepted. + /// + /// Drifting past the allowed slop amount causes the gesture to be rejected. + /// + /// Can be null to indicate that the gesture can drift for any distance. + /// Defaults to 18 logical pixels. + final double preAcceptSlopTolerance; + + /// The maximum distance in logical pixels the gesture is allowed to drift + /// after the gesture has been accepted. + /// + /// Drifting past the allowed slop amount causes the gesture to stop tracking + /// and signaling subsequent callbacks. + /// + /// Can be null to indicate that the gesture can drift for any distance. + /// Defaults to 18 logical pixels. + final double postAcceptSlopTolerance; + /// The current state of the recognizer. /// /// See [GestureRecognizerState] for a description of the states. @@ -307,6 +340,9 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni /// The global location at which the primary pointer contacted the screen. Offset initialPosition; + // Whether this pointer is accepted by winning the arena or as defined by + // a subclass calling acceptGesture. + bool _gestureAccepted = false; Timer _timer; @override @@ -325,8 +361,16 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni void handleEvent(PointerEvent event) { assert(state != GestureRecognizerState.ready); if (state == GestureRecognizerState.possible && event.pointer == primaryPointer) { - // TODO(abarth): Maybe factor the slop handling out into a separate class? - if (event is PointerMoveEvent && _getDistance(event) > kTouchSlop) { + final bool isPreAcceptSlopPastTolerance = + !_gestureAccepted && + preAcceptSlopTolerance != null && + _getDistance(event) > preAcceptSlopTolerance; + final bool isPostAcceptSlopPastTolerance = + _gestureAccepted && + postAcceptSlopTolerance != null && + _getDistance(event) > postAcceptSlopTolerance; + + if (event is PointerMoveEvent && (isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) { resolve(GestureDisposition.rejected); stopTrackingPointer(primaryPointer); } else { @@ -348,6 +392,11 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni assert(deadline == null); } + @override + void acceptGesture(int pointer) { + _gestureAccepted = true; + } + @override void rejectGesture(int pointer) { if (pointer == primaryPointer && state == GestureRecognizerState.possible) { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 6c99f437d4c..928e664cbe2 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -568,6 +568,21 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi _editableTextKey.currentState?.requestKeyboard(); } + void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) { + // iOS cursor doesn't move via a selection handle. The scroll happens + // directly from new text selection changes. + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + if (cause == SelectionChangedCause.longPress) { + _editableTextKey.currentState?.bringIntoView(selection.base); + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // Do nothing. + } + } + InteractiveInkFeature _createInkFeature(TapDownDetails details) { final MaterialInkController inkController = Material.of(context); final ThemeData themeData = Theme.of(context); @@ -641,11 +656,14 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi _cancelCurrentSplash(); } - void _handleSingleLongTapDown() { + void _handleSingleLongTapStart(LongPressStartDetails details) { if (widget.selectionEnabled) { switch (Theme.of(context).platform) { case TargetPlatform.iOS: - _renderEditable.selectPosition(cause: SelectionChangedCause.longPress); + _renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); break; case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -653,11 +671,35 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi Feedback.forLongPress(context); break; } - _editableTextKey.currentState.showToolbar(); } _confirmCurrentSplash(); } + void _handleSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (widget.selectionEnabled) { + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + _renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + _renderEditable.selectWordsInRange( + from: details.globalPosition - details.offsetFromOrigin, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + } + } + } + + void _handleSingleLongTapEnd(LongPressEndDetails details) { + _editableTextKey.currentState.showToolbar(); + } + void _handleDoubleTapDown(TapDownDetails details) { if (widget.selectionEnabled) { _renderEditable.selectWord(cause: SelectionChangedCause.doubleTap); @@ -777,6 +819,7 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi selectionColor: themeData.textSelectionColor, selectionControls: widget.selectionEnabled ? textSelectionControls : null, onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, inputFormatters: formatters, @@ -825,7 +868,9 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi onForcePressStart: forcePressEnabled ? _handleForcePressStarted : null, onSingleTapUp: _handleSingleTapUp, onSingleTapCancel: _handleSingleTapCancel, - onSingleLongTapDown: _handleSingleLongTapDown, + onSingleLongTapStart: _handleSingleLongTapStart, + onSingleLongTapMoveUpdate: _handleSingleLongTapMoveUpdate, + onSingleLongTapEnd: _handleSingleLongTapEnd, onDoubleTapDown: _handleDoubleTapDown, behavior: HitTestBehavior.translucent, child: child, diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 1b541e3cebc..66a74bc388d 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1203,7 +1203,7 @@ class RenderEditable extends RenderBox { /// When [ignorePointer] is true, an ancestor widget must respond to tap /// down events by calling this method. void handleTapDown(TapDownDetails details) { - _lastTapDownPosition = details.globalPosition + -_paintOffset; + _lastTapDownPosition = details.globalPosition; } void _handleTapDown(TapDownDetails details) { assert(!ignorePointer); @@ -1259,12 +1259,28 @@ class RenderEditable extends RenderBox { /// programmatically manipulate its `value` or `selection` directly. /// {@endtemplate} void selectPosition({ @required SelectionChangedCause cause }) { + selectPositionAt(from: _lastTapDownPosition, cause: cause); + } + + /// Select text between the global positions [from] and [to]. + void selectPositionAt({ @required Offset from, Offset to, @required SelectionChangedCause cause }) { assert(cause != null); + assert(from != null); _layoutText(constraints.maxWidth); - assert(_lastTapDownPosition != null); if (onSelectionChanged != null) { - final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition)); - onSelectionChanged(TextSelection.fromPosition(position), this, cause); + final TextPosition fromPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); + final TextPosition toPosition = to == null + ? null + : _textPainter.getPositionForOffset(globalToLocal(to - _paintOffset)); + onSelectionChanged( + TextSelection( + baseOffset: fromPosition.offset, + extentOffset: toPosition?.offset ?? fromPosition.offset, + affinity: fromPosition.affinity, + ), + this, + cause, + ); } } @@ -1283,12 +1299,13 @@ class RenderEditable extends RenderBox { /// {@macro flutter.rendering.editable.select} void selectWordsInRange({ @required Offset from, Offset to, @required SelectionChangedCause cause }) { assert(cause != null); + assert(from != null); _layoutText(constraints.maxWidth); if (onSelectionChanged != null) { - final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from + -_paintOffset)); + final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from - _paintOffset)); final TextSelection firstWord = _selectWordAtOffset(firstPosition); final TextSelection lastWord = to == null ? - firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to + -_paintOffset))); + firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to - _paintOffset))); onSelectionChanged( TextSelection( @@ -1308,7 +1325,7 @@ class RenderEditable extends RenderBox { _layoutText(constraints.maxWidth); assert(_lastTapDownPosition != null); if (onSelectionChanged != null) { - final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition)); + final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition - _paintOffset)); final TextRange word = _textPainter.getWordBoundary(position); if (position.offset - word.start <= 1) { onSelectionChanged( diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index bfba20ff8be..3279519dec1 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -19,6 +19,10 @@ export 'package:flutter/gestures.dart' show GestureTapCallback, GestureTapCancelCallback, GestureLongPressCallback, + GestureLongPressStartCallback, + GestureLongPressMoveUpdateCallback, + GestureLongPressUpCallback, + GestureLongPressEndCallback, GestureDragDownCallback, GestureDragStartCallback, GestureDragUpdateCallback, @@ -31,6 +35,9 @@ export 'package:flutter/gestures.dart' show GestureForcePressPeakCallback, GestureForcePressEndCallback, GestureForcePressUpdateCallback, + LongPressStartDetails, + LongPressMoveUpdateDetails, + LongPressEndDetails, ScaleStartDetails, ScaleUpdateDetails, ScaleEndDetails, @@ -161,7 +168,10 @@ class GestureDetector extends StatelessWidget { this.onTapCancel, this.onDoubleTap, this.onLongPress, + this.onLongPressStart, + this.onLongPressMoveUpdate, this.onLongPressUp, + this.onLongPressEnd, this.onVerticalDragDown, this.onVerticalDragStart, this.onVerticalDragUpdate, @@ -256,13 +266,45 @@ class GestureDetector extends StatelessWidget { /// succession. final GestureTapCallback onDoubleTap; - /// A pointer has remained in contact with the screen at the same location for - /// a long period of time. + /// Called when a long press gesture has been recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// See also: + /// + /// * [onLongPressStart], which has the same timing but has data for the + /// press location. final GestureLongPressCallback onLongPress; + /// Callback for long press start with gesture location. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// See also: + /// + /// * [onLongPress], which has the same timing but without the location data. + final GestureLongPressStartCallback onLongPressStart; + + /// A pointer has been drag-moved after a long press. + final GestureLongPressMoveUpdateCallback onLongPressMoveUpdate; + /// A pointer that has triggered a long-press has stopped contacting the screen. + /// + /// See also: + /// + /// * [onLongPressEnd], which has the same timing but has data for the up + /// gesture location. final GestureLongPressUpCallback onLongPressUp; + /// A pointer that has triggered a long-press has stopped contacting the screen. + /// + /// See also: + /// + /// * [onLongPressUp], which has the same timing but without the location data. + final GestureLongPressEndCallback onLongPressEnd; + /// A pointer has contacted the screen and might begin to move vertically. final GestureDragDownCallback onVerticalDragDown; @@ -421,12 +463,19 @@ class GestureDetector extends StatelessWidget { ); } - if (onLongPress != null || onLongPressUp !=null) { + if (onLongPress != null || + onLongPressUp != null || + onLongPressStart != null || + onLongPressMoveUpdate != null || + onLongPressEnd != null) { gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer(debugOwner: this), (LongPressGestureRecognizer instance) { instance ..onLongPress = onLongPress + ..onLongPressStart = onLongPressStart + ..onLongPressMoveUpdate = onLongPressMoveUpdate + ..onLongPressEnd =onLongPressEnd ..onLongPressUp = onLongPressUp; }, ); diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index fe4606b6516..931b52e9c65 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -616,7 +616,9 @@ class TextSelectionGestureDetector extends StatefulWidget { this.onForcePressEnd, this.onSingleTapUp, this.onSingleTapCancel, - this.onSingleLongTapDown, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, this.onDoubleTapDown, this.behavior, @required this.child, @@ -650,7 +652,13 @@ class TextSelectionGestureDetector extends StatefulWidget { /// Called for a single long tap that's sustained for longer than /// [kLongPressTimeout] but not necessarily lifted. Not called for a /// double-tap-hold, which calls [onDoubleTapDown] instead. - final GestureLongPressCallback onSingleLongTapDown; + final GestureLongPressStartCallback onSingleLongTapStart; + + /// Called after [onSingleLongTapStart] when the pointer is dragged. + final GestureLongPressMoveUpdateCallback onSingleLongTapMoveUpdate; + + /// Called after [onSingleLongTapStart] when the pointer is lifted. + final GestureLongPressEndCallback onSingleLongTapEnd; /// Called after a momentary hold or a short tap that is close in space and /// time (within [kDoubleTapTimeout]) to a previous short tap. @@ -734,9 +742,21 @@ class _TextSelectionGestureDetectorState extends State( + find.byElementPredicate((Element element) => element.renderObject is RenderEditable) + ); + + List lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(1094.73486328125)); + + final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = + await tester.startGesture(textfieldStart + const Offset(300, 5)); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 18, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(600, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 54, affinity: TextAffinity.upstream), + ); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 61, affinity: TextAffinity.upstream), + ); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), findsNWidgets(2)); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(786.73486328125)); + + final List firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-308.20499999821186)); + }); + testWidgets( 'long tap after a double tap select is not affected', (WidgetTester tester) async { diff --git a/packages/flutter/test/gestures/long_press_test.dart b/packages/flutter/test/gestures/long_press_test.dart index 10c6c4eb228..39035092443 100644 --- a/packages/flutter/test/gestures/long_press_test.dart +++ b/packages/flutter/test/gestures/long_press_test.dart @@ -9,153 +9,273 @@ import 'gesture_tester.dart'; const PointerDownEvent down = PointerDownEvent( pointer: 5, - position: Offset(10.0, 10.0) + position: Offset(10, 10), ); const PointerUpEvent up = PointerUpEvent( pointer: 5, - position: Offset(11.0, 9.0) + position: Offset(11, 9), +); + +const PointerMoveEvent move = PointerMoveEvent( + pointer: 5, + position: Offset(100, 200), ); void main() { setUp(ensureGestureBinding); - testGesture('Should recognize long press', (GestureTester tester) { - final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); + group('Long press', () { + LongPressGestureRecognizer longPress; + bool longPressDown; + bool longPressUp; - bool longPressRecognized = false; - longPress.onLongPress = () { - longPressRecognized = true; - }; + setUp(() { + longPress = LongPressGestureRecognizer(); + longPressDown = false; + longPress.onLongPress = () { + longPressDown = true; + }; + longPressUp = false; + longPress.onLongPressUp = () { + longPressUp = true; + }; + }); - longPress.addPointer(down); - tester.closeArena(5); - expect(longPressRecognized, isFalse); - tester.route(down); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 300)); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 700)); - expect(longPressRecognized, isTrue); + testGesture('Should recognize long press', (GestureTester tester) { + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 700)); + expect(longPressDown, isTrue); - longPress.dispose(); + longPress.dispose(); + }); + + testGesture('Up cancels long press', (GestureTester tester) { + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isFalse); + tester.route(up); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + expect(longPressDown, isFalse); + + longPress.dispose(); + }); + + testGesture('Moving before accept cancels', (GestureTester tester) { + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isFalse); + tester.route(move); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + tester.route(up); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isFalse); + expect(longPressUp, isFalse); + + longPress.dispose(); + }); + + testGesture('Moving after accept is ok', (GestureTester tester) { + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressDown, isFalse); + tester.route(down); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + expect(longPressDown, isTrue); + tester.route(move); + tester.route(up); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressDown, isTrue); + expect(longPressUp, isTrue); + + longPress.dispose(); + }); + + testGesture('Should recognize both tap down and long press', (GestureTester tester) { + final TapGestureRecognizer tap = TapGestureRecognizer(); + + bool tapDownRecognized = false; + tap.onTapDown = (_) { + tapDownRecognized = true; + }; + + tap.addPointer(down); + longPress.addPointer(down); + tester.closeArena(5); + expect(tapDownRecognized, isFalse); + expect(longPressDown, isFalse); + tester.route(down); + expect(tapDownRecognized, isFalse); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(tapDownRecognized, isTrue); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 700)); + expect(tapDownRecognized, isTrue); + expect(longPressDown, isTrue); + + tap.dispose(); + longPress.dispose(); + }); + + testGesture('Drag start delayed by microtask', (GestureTester tester) { + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); + + bool isDangerousStack = false; + + bool dragStartRecognized = false; + drag.onStart = (DragStartDetails details) { + expect(isDangerousStack, isFalse); + dragStartRecognized = true; + }; + + drag.addPointer(down); + longPress.addPointer(down); + tester.closeArena(5); + expect(dragStartRecognized, isFalse); + expect(longPressDown, isFalse); + tester.route(down); + expect(dragStartRecognized, isFalse); + expect(longPressDown, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(dragStartRecognized, isFalse); + expect(longPressDown, isFalse); + isDangerousStack = true; + longPress.dispose(); + isDangerousStack = false; + expect(dragStartRecognized, isFalse); + expect(longPressDown, isFalse); + tester.async.flushMicrotasks(); + expect(dragStartRecognized, isTrue); + expect(longPressDown, isFalse); + drag.dispose(); + }); + + testGesture('Should recognize long press up', (GestureTester tester) { + bool longPressUpRecognized = false; + longPress.onLongPressUp = () { + longPressUpRecognized = true; + }; + + longPress.addPointer(down); + tester.closeArena(5); + expect(longPressUpRecognized, isFalse); + tester.route(down); // kLongPressTimeout = 500; + expect(longPressUpRecognized, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressUpRecognized, isFalse); + tester.async.elapse(const Duration(milliseconds: 700)); + tester.route(up); + expect(longPressUpRecognized, isTrue); + + longPress.dispose(); + }); }); - testGesture('Up cancels long press', (GestureTester tester) { - final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); + group('long press drag', () { + LongPressGestureRecognizer longPressDrag; + bool longPressStart; + bool longPressUp; + Offset longPressDragUpdate; - bool longPressRecognized = false; - longPress.onLongPress = () { - longPressRecognized = true; - }; + setUp(() { + longPressDrag = LongPressGestureRecognizer(); + longPressStart = false; + longPressDrag.onLongPressStart = (LongPressStartDetails details) { + longPressStart = true; + }; + longPressUp = false; + longPressDrag.onLongPressEnd = (LongPressEndDetails details) { + longPressUp = true; + }; + longPressDragUpdate = null; + longPressDrag.onLongPressMoveUpdate = (LongPressMoveUpdateDetails details) { + longPressDragUpdate = details.globalPosition; + }; + }); - longPress.addPointer(down); - tester.closeArena(5); - expect(longPressRecognized, isFalse); - tester.route(down); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 300)); - expect(longPressRecognized, isFalse); - tester.route(up); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(seconds: 1)); - expect(longPressRecognized, isFalse); + testGesture('Should recognize long press down', (GestureTester tester) { + longPressDrag.addPointer(down); + tester.closeArena(5); + expect(longPressStart, isFalse); + tester.route(down); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(milliseconds: 700)); + expect(longPressStart, isTrue); - longPress.dispose(); - }); + longPressDrag.dispose(); + }); - testGesture('Should recognize both tap down and long press', (GestureTester tester) { - final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); - final TapGestureRecognizer tap = TapGestureRecognizer(); + testGesture('Short up cancels long press', (GestureTester tester) { + longPressDrag.addPointer(down); + tester.closeArena(5); + expect(longPressStart, isFalse); + tester.route(down); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressStart, isFalse); + tester.route(up); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + expect(longPressStart, isFalse); - bool tapDownRecognized = false; - tap.onTapDown = (_) { - tapDownRecognized = true; - }; + longPressDrag.dispose(); + }); - bool longPressRecognized = false; - longPress.onLongPress = () { - longPressRecognized = true; - }; + testGesture('Moving before accept cancels', (GestureTester tester) { + longPressDrag.addPointer(down); + tester.closeArena(5); + expect(longPressStart, isFalse); + tester.route(down); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressStart, isFalse); + tester.route(move); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + tester.route(up); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressStart, isFalse); + expect(longPressUp, isFalse); - tap.addPointer(down); - longPress.addPointer(down); - tester.closeArena(5); - expect(tapDownRecognized, isFalse); - expect(longPressRecognized, isFalse); - tester.route(down); - expect(tapDownRecognized, isFalse); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 300)); - expect(tapDownRecognized, isTrue); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 700)); - expect(tapDownRecognized, isTrue); - expect(longPressRecognized, isTrue); + longPressDrag.dispose(); + }); - tap.dispose(); - longPress.dispose(); - }); + testGesture('Moving after accept does not cancel', (GestureTester tester) { + longPressDrag.addPointer(down); + tester.closeArena(5); + expect(longPressStart, isFalse); + tester.route(down); + expect(longPressStart, isFalse); + tester.async.elapse(const Duration(seconds: 1)); + expect(longPressStart, isTrue); + tester.route(move); + expect(longPressDragUpdate, const Offset(100, 200)); + tester.route(up); + tester.async.elapse(const Duration(milliseconds: 300)); + expect(longPressStart, isTrue); + expect(longPressUp, isTrue); - testGesture('Drag start delayed by microtask', (GestureTester tester) { - final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); - final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(); - - bool isDangerousStack = false; - - bool dragStartRecognized = false; - drag.onStart = (DragStartDetails details) { - expect(isDangerousStack, isFalse); - dragStartRecognized = true; - }; - - bool longPressRecognized = false; - longPress.onLongPress = () { - expect(isDangerousStack, isFalse); - longPressRecognized = true; - }; - - drag.addPointer(down); - longPress.addPointer(down); - tester.closeArena(5); - expect(dragStartRecognized, isFalse); - expect(longPressRecognized, isFalse); - tester.route(down); - expect(dragStartRecognized, isFalse); - expect(longPressRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 300)); - expect(dragStartRecognized, isFalse); - expect(longPressRecognized, isFalse); - isDangerousStack = true; - longPress.dispose(); - isDangerousStack = false; - expect(dragStartRecognized, isFalse); - expect(longPressRecognized, isFalse); - tester.async.flushMicrotasks(); - expect(dragStartRecognized, isTrue); - expect(longPressRecognized, isFalse); - drag.dispose(); - }); - - testGesture('Should recognize long press up', (GestureTester tester) { - final LongPressGestureRecognizer longPress = LongPressGestureRecognizer(); - - bool longPressUpRecognized = false; - longPress.onLongPressUp = () { - longPressUpRecognized = true; - }; - - longPress.addPointer(down); - tester.closeArena(5); - expect(longPressUpRecognized, isFalse); - tester.route(down); // kLongPressTimeout = 500; - expect(longPressUpRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 300)); - expect(longPressUpRecognized, isFalse); - tester.async.elapse(const Duration(milliseconds: 700)); - tester.route(up); - expect(longPressUpRecognized, isTrue); - - longPress.dispose(); + longPressDrag.dispose(); + }); }); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index c9f436b7afb..7642cfbc699 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1026,10 +1026,10 @@ void main() { expect(inputBox.hitTest(HitTestResult(), position: inputBox.globalToLocal(newFourthPos)), isTrue); // Now try scrolling by dragging the selection handle. - // Long press the 'i' in 'Fourth line' to select the word. + // Long press the middle of the word "won't" in the fourth line. final Offset selectedWordPos = textOffsetToPosition( tester, - kMoreThanFourLines.indexOf('Fourth line') + 8, + kMoreThanFourLines.indexOf('Fourth line') + 14, ); gesture = await tester.startGesture(selectedWordPos, pointer: 7); @@ -1038,8 +1038,13 @@ void main() { await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(controller.selection.base.offset, 91); - expect(controller.selection.extent.offset, 94); + expect(controller.selection.base.offset, 77); + expect(controller.selection.extent.offset, 82); + // Sanity check for the word selected is the intended one. + expect( + controller.text.substring(controller.selection.baseOffset, controller.selection.extentOffset), + "won't", + ); final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = globalize( @@ -4199,7 +4204,7 @@ void main() { ); testWidgets( - 'long press tap is not a double tap (iOS)', + 'long press tap cannot initiate a double tap (iOS)', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -4237,6 +4242,161 @@ void main() { }, ); + testWidgets( + 'long press drag moves the cursor under the drag and shows toolbar on lift (iOS)', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = + await tester.startGesture(textfieldStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on iOS shows collapsed selection cursor. + expect( + controller.selection, + const TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 6, affinity: TextAffinity.downstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.downstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 9, affinity: TextAffinity.downstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), findsNWidgets(2)); + }, + ); + + testWidgets('long press drag can edge scroll (iOS)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: Material( + child: Center( + child: TextField( + controller: controller, + maxLines: 1, + ), + ), + ), + ), + ); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 1056); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = + await tester.startGesture(textfieldStart + const Offset(300, 5)); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 19, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(600, 0)); + // To the edge of the screen basically. + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 56, affinity: TextAffinity.downstream), + ); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 62, affinity: TextAffinity.downstream), + ); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pump(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), findsNWidgets(2)); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257, epsilon: 1)); + }); + testWidgets( 'long tap after a double tap select is not affected (iOS)', (WidgetTester tester) async { diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 6840f8e4a48..da2ac53c9c3 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -254,4 +254,84 @@ void main() { ); expect(editable, paintsExactlyCountTimes(#drawRect, 1)); }); + + test('selects correct place with offsets', () { + final TextSelectionDelegate delegate = FakeEditableTextState(); + final ViewportOffset viewportOffset = ViewportOffset.zero(); + TextSelection currentSelection; + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + selectionColor: Colors.black, + textDirection: TextDirection.ltr, + cursorColor: Colors.red, + offset: viewportOffset, + // This makes the scroll axis vertical. + maxLines: 2, + textSelectionDelegate: delegate, + onSelectionChanged: (TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause) { + currentSelection = selection; + }, + text: const TextSpan( + text: 'test\ntest', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + ); + + layout(editable); + + expect( + editable, + paints..paragraph(offset: Offset.zero), + ); + + editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap); + pumpFrame(); + + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 0); + + viewportOffset.correctBy(10); + + pumpFrame(); + + expect( + editable, + paints..paragraph(offset: const Offset(0, -10)), + ); + + // Tap the same place. But because the offset is scrolled up, the second line + // gets tapped instead. + editable.selectPositionAt(from: const Offset(0, 2), cause: SelectionChangedCause.tap); + pumpFrame(); + + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 5); + + // Test the other selection methods. + // Move over by one character. + editable.handleTapDown(TapDownDetails(globalPosition: const Offset(10, 2))); + pumpFrame(); + editable.selectPosition(cause:SelectionChangedCause.tap); + pumpFrame(); + expect(currentSelection.isCollapsed, true); + expect(currentSelection.baseOffset, 6); + + editable.handleTapDown(TapDownDetails(globalPosition: const Offset(20, 2))); + pumpFrame(); + editable.selectWord(cause:SelectionChangedCause.longPress); + pumpFrame(); + expect(currentSelection.isCollapsed, false); + expect(currentSelection.baseOffset, 5); + expect(currentSelection.extentOffset, 9); + + // Select one more character down but since it's still part of the same + // word, the same word is selected. + editable.selectWordsInRange(from: const Offset(30, 2), cause:SelectionChangedCause.longPress); + pumpFrame(); + expect(currentSelection.isCollapsed, false); + expect(currentSelection.baseOffset, 5); + expect(currentSelection.extentOffset, 9); + }); } diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 0ca260f6c07..39b39fa5c98 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -9,7 +9,7 @@ void main() { int tapCount; int singleTapUpCount; int singleTapCancelCount; - int singleLongTapDownCount; + int singleLongTapStartCount; int doubleTapDownCount; int forcePressStartCount; int forcePressEndCount; @@ -19,7 +19,7 @@ void main() { void _handleTapDown(TapDownDetails details) { tapCount++; } void _handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; } void _handleSingleTapCancel() { singleTapCancelCount++; } - void _handleSingleLongTapDown() { singleLongTapDownCount++; } + void _handleSingleLongTapStart(LongPressStartDetails details) { singleLongTapStartCount++; } void _handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; } void _handleForcePressStart(ForcePressDetails details) { forcePressStartCount++; } void _handleForcePressEnd(ForcePressDetails details) { forcePressEndCount++; } @@ -28,7 +28,7 @@ void main() { tapCount = 0; singleTapUpCount = 0; singleTapCancelCount = 0; - singleLongTapDownCount = 0; + singleLongTapStartCount = 0; doubleTapDownCount = 0; forcePressStartCount = 0; forcePressEndCount = 0; @@ -41,7 +41,7 @@ void main() { onTapDown: _handleTapDown, onSingleTapUp: _handleSingleTapUp, onSingleTapCancel: _handleSingleTapCancel, - onSingleLongTapDown: _handleSingleLongTapDown, + onSingleLongTapStart: _handleSingleLongTapStart, onDoubleTapDown: _handleDoubleTapDown, onForcePressStart: _handleForcePressStart, onForcePressEnd: _handleForcePressEnd, @@ -108,7 +108,7 @@ void main() { expect(singleTapCancelCount, 0); expect(doubleTapDownCount, 1); // The double tap down hold supersedes the single tap down. - expect(singleLongTapDownCount, 0); + expect(singleLongTapStartCount, 0); await gesture.up(); // Nothing else happens on up. @@ -116,7 +116,7 @@ void main() { expect(tapCount, 2); expect(singleTapCancelCount, 0); expect(doubleTapDownCount, 1); - expect(singleLongTapDownCount, 0); + expect(singleLongTapStartCount, 0); }); testWidgets('a very quick swipe is just a canceled tap', (WidgetTester tester) async { @@ -129,7 +129,7 @@ void main() { expect(tapCount, 0); expect(singleTapCancelCount, 1); expect(doubleTapDownCount, 0); - expect(singleLongTapDownCount, 0); + expect(singleLongTapStartCount, 0); await gesture.up(); // Nothing else happens on up. @@ -137,7 +137,7 @@ void main() { expect(tapCount, 0); expect(singleTapCancelCount, 1); expect(doubleTapDownCount, 0); - expect(singleLongTapDownCount, 0); + expect(singleLongTapStartCount, 0); }); testWidgets('a slower swipe has a tap down and a canceled tap', (WidgetTester tester) async { @@ -150,7 +150,7 @@ void main() { expect(tapCount, 1); expect(singleTapCancelCount, 1); expect(doubleTapDownCount, 0); - expect(singleLongTapDownCount, 0); + expect(singleLongTapStartCount, 0); }); testWidgets('a force press intiates a force press', (WidgetTester tester) async {