Unverified Commit ec00e974 authored by xster's avatar xster Committed by GitHub

Add long-press-move support for text fields 2 (#28242)

parent 47724f97
......@@ -482,8 +482,21 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> 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<CupertinoTextField> 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<CupertinoTextField> 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<CupertinoTextField> 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),
......
......@@ -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<void>('onLongPress', onLongPress);
}
if (onLongPressStart != null) {
invokeCallback<void>('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<void>('onLongPressUp', onLongPressUp);
}
if (onLongPressEnd != null) {
invokeCallback<void>('onLongPressEnd', () {
onLongPressEnd(LongPressEndDetails(
globalPosition: event.position,
));
});
}
_longPressAccepted = false;
invokeCallback<void>('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<void>('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';
}
......@@ -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) {
......
......@@ -568,6 +568,21 @@ class _TextFieldState extends State<TextField> 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<TextField> 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<TextField> 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<TextField> 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<TextField> 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,
......
......@@ -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(
......
......@@ -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>(
() => LongPressGestureRecognizer(debugOwner: this),
(LongPressGestureRecognizer instance) {
instance
..onLongPress = onLongPress
..onLongPressStart = onLongPressStart
..onLongPressMoveUpdate = onLongPressMoveUpdate
..onLongPressEnd =onLongPressEnd
..onLongPressUp = onLongPressUp;
},
);
......
......@@ -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<TextSelectionGestureDetec
widget.onForcePressEnd(details);
}
void _handleLongPress() {
if (!_isDoubleTap && widget.onSingleLongTapDown != null) {
widget.onSingleLongTapDown();
void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart(details);
}
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate(details);
}
}
void _handleLongPressUp(LongPressEndDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd(details);
}
_isDoubleTap = false;
}
......@@ -764,7 +784,9 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
onForcePressStart: widget.onForcePressStart != null ? _forcePressStarted : null,
onForcePressEnd: widget.onForcePressEnd != null ? _forcePressEnded : null,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
onLongPressStart: _handleLongPressStart,
onLongPressMoveUpdate: _handleLongPressMoveUpdate,
onLongPressEnd: _handleLongPressUp,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
......
......@@ -1272,7 +1272,7 @@ void main() {
);
testWidgets(
'long press tap is not a double tap',
'long press tap cannot initiate a double tap',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
......@@ -1302,11 +1302,163 @@ void main() {
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
);
// Collapsed toolbar shows 2 buttons.
// The toolbar from the long press is now dismissed by the second tap.
expect(find.byType(CupertinoButton), findsNothing);
},
);
testWidgets(
'long press drag moves the cursor under the drag and shows toolbar on lift',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
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.upstream),
);
// Toolbar only shows up on long press up.
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.upstream),
);
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.upstream),
);
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.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), findsNWidgets(2));
},
);
testWidgets('long press drag can edge scroll', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
maxLines: 1,
),
),
),
);
final RenderEditable renderEditable = tester.renderObject<RenderEditable>(
find.byElementPredicate((Element element) => element.renderObject is RenderEditable)
);
List<TextSelectionPoint> 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<TextSelectionPoint> 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 {
......
......@@ -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<TextSelectionPoint> 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<TextSelectionPoint> 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<TextSelectionPoint> 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 {
......
......@@ -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);
});
}
......@@ -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 {
......
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