Unverified Commit 892c8919 authored by xster's avatar xster Committed by GitHub

Add long-press-drag cursor move support on iOS (#26001)

parent e44fc135
......@@ -478,8 +478,25 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
_requestKeyboard();
}
void _handleSingleLongTapDown() {
_renderEditable.selectPosition(cause: SelectionChangedCause.longPress);
void _handleSingleLongTapStart(GestureLongPressDragStartDetails details) {
_renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
}
void _handleSingleLongTapDragUpdate(GestureLongPressDragUpdateDetails details) {
_renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
}
void _handleSingleLongTapUp(GestureLongPressDragUpDetails details) {
_renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress
);
_editableTextKey.currentState.showToolbar();
}
......@@ -488,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;
......@@ -641,6 +664,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,
......@@ -681,7 +705,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
onForcePressStart: _handleForcePressStarted,
onForcePressEnd: _handleForcePressEnded,
onSingleTapUp: _handleSingleTapUp,
onSingleLongTapDown: _handleSingleLongTapDown,
onSingleLongTapStart: _handleSingleLongTapStart,
onSingleLongTapDragUpdate: _handleSingleLongTapDragUpdate,
onSingleLongTapUp: _handleSingleLongTapUp,
onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: _addTextDependentAttachments(paddedEditable, textStyle),
......
......@@ -11,9 +11,117 @@ import 'recognizer.dart';
/// 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.
/// Signature for when a pointer stops contacting the screen after a long press
/// gesture was detected.
typedef GestureLongPressUpCallback = void Function();
/// Signature from a [LongPressDragGestureRecognizer] when a pointer has remained
/// in contact with the screen at the same location for a long period of time.
typedef GestureLongPressDragStartCallback = void Function(GestureLongPressDragStartDetails details);
/// Signature from a [LongPressDragGestureRecognizer] when a pointer is moving
/// after being held in contact at the same location for a long period of time.
typedef GestureLongPressDragUpdateCallback = void Function(GestureLongPressDragUpdateDetails details);
/// Signature from a [LongPressDragGestureRecognizer] after a pointer stops
/// contacting the screen.
///
/// The contact stop position may be different from the contact start position.
typedef GestureLongPressDragUpCallback = void Function(GestureLongPressDragUpDetails details);
/// Details for callbacks that use [GestureLongPressDragStartCallback].
///
/// See also:
///
/// * [LongPressDragGestureRecognizer.onLongPressStart], which uses [GestureLongPressDragStartCallback].
/// * [GestureLongPressDragUpdateDetails], the details for [GestureLongPressDragUpdateCallback]
/// * [GestureLongPressDragUpDetails], the details for [GestureLongPressDragUpCallback].
class GestureLongPressDragStartDetails {
/// Creates the details for a [GestureLongPressDragStartCallback].
///
/// The [globalPosition] argument must not be null.
const GestureLongPressDragStartDetails({ this.sourceTimeStamp, this.globalPosition = Offset.zero })
: assert(globalPosition != null);
/// Recorded timestamp of the source pointer event that triggered the press
/// event.
///
/// Could be null if triggered by proxied events such as accessibility.
///
/// See also:
///
/// * [PointerEvent.synthesized] for details on synthesized pointer events.
final Duration sourceTimeStamp;
/// The global position at which the pointer contacted the screen.
final Offset globalPosition;
}
/// Details for callbacks that use [GestureLongPressDragUpdateCallback].
///
/// See also:
///
/// * [LongPressDragGestureRecognizer.onLongPressDragUpdate], which uses [GestureLongPressDragUpdateCallback].
/// * [GestureLongPressDragUpDetails], the details for [GestureLongPressDragUpCallback]
/// * [GestureLongPressDragStartDetails], the details for [GestureLongPressDragStartCallback].
class GestureLongPressDragUpdateDetails {
/// Creates the details for a [GestureLongPressDragUpdateCallback].
///
/// The [globalPosition] and [offsetFromOrigin] arguments must not be null.
const GestureLongPressDragUpdateDetails({
this.sourceTimeStamp,
this.globalPosition = Offset.zero,
this.offsetFromOrigin = Offset.zero,
}) : assert(globalPosition != null),
assert(offsetFromOrigin != null);
/// Recorded timestamp of the source pointer event that triggered the press
/// event.
///
/// Could be null if triggered by proxied events such as accessibility.
///
/// See also:
///
/// * [PointerEvent.synthesized] for details on synthesized pointer events.
final Duration sourceTimeStamp;
/// 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 when
/// this callback is triggered.
final Offset offsetFromOrigin;
}
/// Details for callbacks that use [GestureLongPressDragUpCallback].
///
/// See also:
///
/// * [LongPressDragGestureRecognizer.onLongPressUp], which uses [GestureLongPressDragUpCallback].
/// * [GestureLongPressDragUpdateDetails], the details for [GestureLongPressDragUpdateCallback]
/// * [GestureLongPressDragStartDetails], the details for [GestureLongPressDragStartCallback].
class GestureLongPressDragUpDetails {
/// Creates the details for a [GestureLongPressDragUpCallback].
///
/// The [globalPosition] argument must not be null.
const GestureLongPressDragUpDetails({ this.sourceTimeStamp, this.globalPosition = Offset.zero })
: assert(globalPosition != null);
/// Recorded timestamp of the source pointer event that triggered the press
/// event.
///
/// Could be null if triggered by proxied events such as accessibility.
///
/// See also:
///
/// * [PointerEvent.synthesized] for details on synthesized pointer events.
final Duration sourceTimeStamp;
/// 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.
class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
......@@ -28,7 +136,8 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
/// Called when a long press gesture has been recognized.
GestureLongPressCallback onLongPress;
/// Called when the pointer stops contacting the screen after the long-press gesture has been recognized.
/// Called when the pointer stops contacting the screen after the long-press
/// gesture has been recognized.
GestureLongPressUpCallback onLongPressUp;
@override
......@@ -58,3 +167,107 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer {
@override
String get debugDescription => 'long press';
}
/// Recognizes long presses that can be subsequently dragged around.
///
/// Similar to a [LongPressGestureRecognizer] where a press has to be held down
/// at the same location for a long period of time. However drag events that
/// occur after the long-press hold threshold has past will not cancel the
/// gesture. The [onLongPressDragUpdate] callback will be called until an up
/// event occurs.
///
/// See also:
///
/// * [LongPressGestureRecognizer], which cancels its gesture if a drag event
/// occurs at any point during the long-press.
class LongPressDragGestureRecognizer extends PrimaryPointerGestureRecognizer {
/// Creates a long-press-drag gesture recognizer.
///
/// Consider assigning the [onLongPressStart], [onLongPressDragUpdate] and
/// the [onLongPressUp] callbacks after creating this object.
LongPressDragGestureRecognizer({ Object debugOwner }) : super(
deadline: kLongPressTimeout,
// Since it's a drag gesture, no travel distance will cause it to get
// rejected after the long-press is accepted.
postAcceptSlopTolerance: null,
debugOwner: debugOwner,
);
bool _longPressAccepted = false;
Offset _longPressOrigin;
Duration _longPressStartTimestamp;
/// Called when a long press gesture has been recognized.
GestureLongPressDragStartCallback onLongPressStart;
/// Called as the primary pointer is dragged after the long press.
GestureLongPressDragUpdateCallback onLongPressDragUpdate;
/// Called when the pointer stops contacting the screen after the
/// long-press gesture has been recognized.
GestureLongPressDragUpCallback onLongPressUp;
@override
void didExceedDeadline() {
resolve(GestureDisposition.accepted);
_longPressAccepted = true;
super.acceptGesture(primaryPointer);
if (onLongPressStart != null) {
invokeCallback<void>('onLongPressStart', () {
onLongPressStart(GestureLongPressDragStartDetails(
sourceTimeStamp: _longPressStartTimestamp,
globalPosition: _longPressOrigin,
));
});
}
}
@override
void handlePrimaryPointer(PointerEvent event) {
if (event is PointerUpEvent) {
if (_longPressAccepted == true && onLongPressUp != null) {
_longPressAccepted = false;
invokeCallback<void>('onLongPressUp', () {
onLongPressUp(GestureLongPressDragUpDetails(
sourceTimeStamp: event.timeStamp,
globalPosition: event.position,
));
});
} else {
resolve(GestureDisposition.rejected);
}
} else if (event is PointerDownEvent) {
// The first touch.
_longPressAccepted = false;
_longPressStartTimestamp = event.timeStamp;
_longPressOrigin = event.position;
} else if (event is PointerMoveEvent && _longPressAccepted && onLongPressDragUpdate != null) {
invokeCallback<void>('onLongPressDrag', () {
onLongPressDragUpdate(GestureLongPressDragUpdateDetails(
sourceTimeStamp: event.timeStamp,
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
void didStopTrackingLastPointer(int pointer) {
_longPressAccepted = false;
_longPressOrigin = null;
_longPressStartTimestamp = null;
super.didStopTrackingLastPointer(pointer);
}
@override
String get debugDescription => 'long press drag';
}
......@@ -262,10 +262,10 @@ 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].
/// primary pointer. The recognizer moves to [accepted] when resolved to win the
/// gesture arena. It may then move to [defunct] from [accepted] or go to
/// [defunct] directly when rejected. 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,
......@@ -275,6 +275,9 @@ enum GestureRecognizerState {
/// been accepted definitively.
possible,
/// The gesture has been definitively accepted by the recognizer.
accepted,
/// Further pointer events cannot cause this recognizer to recognize the
/// gesture until the recognizer returns to the [ready] state (typically when
/// all the pointers the recognizer is tracking are removed from the screen).
......@@ -284,18 +287,43 @@ 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.
/// travels beyond [preAcceptSlopTolerance] pixels from the original contact
/// point before the gesture is accepted or beyond [postAcceptSlopTolerance]
/// from where the pointer was after the gesture was accepted.
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.
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 be rejected,
/// even after being accepted.
final double postAcceptSlopTolerance;
/// The current state of the recognizer.
///
/// See [GestureRecognizerState] for a description of the states.
......@@ -324,9 +352,17 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni
@override
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) {
if (event.pointer == primaryPointer) {
final bool isPreAcceptSlopPastTolerance =
state == GestureRecognizerState.possible &&
preAcceptSlopTolerance != null &&
_getDistance(event) > preAcceptSlopTolerance;
final bool isPostAcceptSlopPastTolerance =
state == GestureRecognizerState.accepted &&
postAcceptSlopTolerance != null &&
_getDistance(event) > postAcceptSlopTolerance;
if (event is PointerMoveEvent && (isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) {
resolve(GestureDisposition.rejected);
stopTrackingPointer(primaryPointer);
} else {
......@@ -349,8 +385,19 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni
}
@override
void rejectGesture(int pointer) {
void acceptGesture(int pointer) {
// Ignore state 'ready' here because that would happen if this recognizer
// won by a sweep.
if (pointer == primaryPointer && state == GestureRecognizerState.possible) {
state = GestureRecognizerState.accepted;
}
}
@override
void rejectGesture(int pointer) {
if (pointer == primaryPointer
&& (state == GestureRecognizerState.possible ||
state == GestureRecognizerState.accepted)) {
_stopTimer();
state = GestureRecognizerState.defunct;
}
......
......@@ -566,6 +566,15 @@ 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.
if (Theme.of(context).platform == TargetPlatform.iOS
&& cause == SelectionChangedCause.longPress) {
_editableTextKey.currentState?.bringIntoView(selection.base);
}
}
InteractiveInkFeature _createInkFeature(TapDownDetails details) {
final MaterialInkController inkController = Material.of(context);
final ThemeData themeData = Theme.of(context);
......@@ -639,11 +648,14 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
_cancelCurrentSplash();
}
void _handleSingleLongTapDown() {
void _handleSingleLongTapStart(GestureLongPressDragStartDetails 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:
......@@ -651,11 +663,35 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
Feedback.forLongPress(context);
break;
}
_editableTextKey.currentState.showToolbar();
}
_confirmCurrentSplash();
}
void _handleSingleLongTapDragUpdate(GestureLongPressDragUpdateDetails 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 _handleSingleLongTapUp(GestureLongPressDragUpDetails details) {
_editableTextKey.currentState.showToolbar();
}
void _handleDoubleTapDown(TapDownDetails details) {
if (widget.selectionEnabled) {
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
......@@ -774,6 +810,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,
......@@ -822,7 +859,9 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
onForcePressStart: forcePressEnabled ? _handleForcePressStarted : null,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
onSingleLongTapStart: _handleSingleLongTapStart,
onSingleLongTapDragUpdate: _handleSingleLongTapDragUpdate,
onSingleLongTapUp: _handleSingleLongTapUp,
onDoubleTapDown: _handleDoubleTapDown,
behavior: HitTestBehavior.translucent,
child: child,
......
......@@ -1191,7 +1191,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 - _paintOffset;
}
void _handleTapDown(TapDownDetails details) {
assert(!ignorePointer);
......@@ -1247,12 +1247,27 @@ 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);
_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,
);
}
}
......@@ -1273,10 +1288,10 @@ class RenderEditable extends RenderBox {
assert(cause != 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(
......
......@@ -19,6 +19,10 @@ export 'package:flutter/gestures.dart' show
GestureTapCallback,
GestureTapCancelCallback,
GestureLongPressCallback,
GestureLongPressUpCallback,
GestureLongPressDragStartCallback,
GestureLongPressDragUpdateCallback,
GestureLongPressDragUpCallback,
GestureDragDownCallback,
GestureDragStartCallback,
GestureDragUpdateCallback,
......@@ -31,6 +35,9 @@ export 'package:flutter/gestures.dart' show
GestureForcePressPeakCallback,
GestureForcePressEndCallback,
GestureForcePressUpdateCallback,
GestureLongPressDragStartDetails,
GestureLongPressDragUpdateDetails,
GestureLongPressDragUpDetails,
ScaleStartDetails,
ScaleUpdateDetails,
ScaleEndDetails,
......@@ -150,6 +157,10 @@ class GestureDetector extends StatelessWidget {
/// because a combination of a horizontal and vertical drag is a pan. Simply
/// use the pan callbacks instead.
///
/// Long press and long press drag callbacks cannot be used simultaneously
/// since they overlap. A long press cannot be subsequently dragged while a
/// long press drag can be dragged after a long press.
///
/// By default, gesture detectors contribute semantic information to the tree
/// that is used by assistive technology.
GestureDetector({
......@@ -162,6 +173,9 @@ class GestureDetector extends StatelessWidget {
this.onDoubleTap,
this.onLongPress,
this.onLongPressUp,
this.onLongPressDragStart,
this.onLongPressDragUpdate,
this.onLongPressDragUp,
this.onVerticalDragDown,
this.onVerticalDragStart,
this.onVerticalDragUpdate,
......@@ -194,6 +208,8 @@ class GestureDetector extends StatelessWidget {
final bool haveHorizontalDrag = onHorizontalDragStart != null || onHorizontalDragUpdate != null || onHorizontalDragEnd != null;
final bool havePan = onPanStart != null || onPanUpdate != null || onPanEnd != null;
final bool haveScale = onScaleStart != null || onScaleUpdate != null || onScaleEnd != null;
final bool haveLongPress = onLongPress != null || onLongPressUp != null;
final bool haveLongPressDrag = onLongPressDragStart != null || onLongPressDragUpdate != null || onLongPressDragUp != null;
if (havePan || haveScale) {
if (havePan && haveScale) {
throw FlutterError(
......@@ -210,6 +226,15 @@ class GestureDetector extends StatelessWidget {
);
}
}
if (haveLongPress && haveLongPressDrag) {
throw FlutterError(
'Incorrect GestureDetector arguments.\n'
'Having both a long press and a long press drag recognizer is '
'redundant as the long press drag is a superset of long press. '
'Except long press drag allows for drags after the long press is '
'triggered.'
);
}
return true;
}()),
super(key: key);
......@@ -258,11 +283,37 @@ class GestureDetector extends StatelessWidget {
/// A pointer has remained in contact with the screen at the same location for
/// a long period of time.
///
/// The long press drag callbacks [onLongPressDragStart], [onLongPressDragUpdate]
/// and [onLongPressDragUp] cannot be set while this callback is set.
final GestureLongPressCallback onLongPress;
/// A pointer that has triggered a long-press has stopped contacting the screen.
///
/// The long press drag callbacks [onLongPressDragStart], [onLongPressDragUpdate]
/// and [onLongPressDragUp] cannot be set while this callback is set.
final GestureLongPressUpCallback onLongPressUp;
/// A pointer has remained in contact with the screen at the same location for
/// a long period of time and can subsequently be dragged.
///
/// The non-drag long press callbacks [onLongPress] and [onLongPressUp] cannot
/// be set while this callback is set.
final GestureLongPressDragStartCallback onLongPressDragStart;
/// A pointer has been drag-moved after a long press.
///
/// The non-drag long press callbacks [onLongPress] and [onLongPressUp] cannot
/// be set while this callback is set.
final GestureLongPressDragUpdateCallback onLongPressDragUpdate;
/// A pointer that has triggered a long press has stopped contacting the screen
/// regardless of whether the pointer is dragged after the long press.
///
/// The non-drag long press callbacks [onLongPress] and [onLongPressUp] cannot
/// be set while this callback is set.
final GestureLongPressDragUpCallback onLongPressDragUp;
/// A pointer has contacted the screen and might begin to move vertically.
final GestureDragDownCallback onVerticalDragDown;
......@@ -422,7 +473,7 @@ class GestureDetector extends StatelessWidget {
);
}
if (onLongPress != null || onLongPressUp !=null) {
if (onLongPress != null || onLongPressUp != null) {
gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this),
(LongPressGestureRecognizer instance) {
......@@ -433,6 +484,18 @@ class GestureDetector extends StatelessWidget {
);
}
if (onLongPressDragStart != null || onLongPressDragUpdate != null || onLongPressDragUp != null) {
gestures[LongPressDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressDragGestureRecognizer>(
() => LongPressDragGestureRecognizer(debugOwner: this),
(LongPressDragGestureRecognizer instance) {
instance
..onLongPressStart = onLongPressDragStart
..onLongPressDragUpdate = onLongPressDragUpdate
..onLongPressUp = onLongPressDragUp;
},
);
}
if (onVerticalDragDown != null ||
onVerticalDragStart != null ||
onVerticalDragUpdate != null ||
......
......@@ -617,7 +617,9 @@ class TextSelectionGestureDetector extends StatefulWidget {
this.onForcePressEnd,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapDown,
this.onSingleLongTapStart,
this.onSingleLongTapDragUpdate,
this.onSingleLongTapUp,
this.onDoubleTapDown,
this.behavior,
@required this.child,
......@@ -651,7 +653,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 GestureLongPressDragStartCallback onSingleLongTapStart;
/// Called after [onSingleLongTapStart] when the pointer is dragged.
final GestureLongPressDragUpdateCallback onSingleLongTapDragUpdate;
/// Called after [onSingleLongTapStart] when the pointer is lifted.
final GestureLongPressDragUpCallback onSingleLongTapUp;
/// Called after a momentary hold or a short tap that is close in space and
/// time (within [kDoubleTapTimeout]) to a previous short tap.
......@@ -735,9 +743,21 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
widget.onForcePressEnd(details);
}
void _handleLongPress() {
if (!_isDoubleTap && widget.onSingleLongTapDown != null) {
widget.onSingleLongTapDown();
void _handleLongDragStart(GestureLongPressDragStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart(details);
}
}
void _handleLongDragUpdate(GestureLongPressDragUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapDragUpdate != null) {
widget.onSingleLongTapDragUpdate(details);
}
}
void _handleLongDragUp(GestureLongPressDragUpDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapUp != null) {
widget.onSingleLongTapUp(details);
}
_isDoubleTap = false;
}
......@@ -765,7 +785,9 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
onForcePressStart: widget.onForcePressStart != null ? _forcePressStarted : null,
onForcePressEnd: widget.onForcePressEnd != null ? _forcePressEnded : null,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
onLongPressDragStart: _handleLongDragStart,
onLongPressDragUpdate: _handleLongDragUpdate,
onLongPressDragUp: _handleLongDragUp,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
......
......@@ -1046,7 +1046,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',
......@@ -1076,11 +1076,162 @@ 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),
);
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 {
......
......@@ -4143,7 +4143,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',
......@@ -4181,6 +4181,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.
expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(797.3333129882812));
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(-258.66668701171875));
});
testWidgets(
'long tap after a double tap select is not affected (iOS)',
(WidgetTester tester) async {
......
......@@ -486,4 +486,19 @@ void main() {
expect(horizontalDragStart, 1);
expect(forcePressStart, 0);
});
testWidgets('Cannot set both a long press and a long press drag callback', (WidgetTester tester) async {
try {
GestureDetector(
onLongPress: () {},
onLongPressDragUpdate: (GestureLongPressDragUpdateDetails details) {},
child: Container(
color: const Color(0xFF00FF00),
),
);
throw 'setting long press and long press drag should throw';
} on FlutterError catch (_) {
// Should throw.
}
});
}
......@@ -9,7 +9,7 @@ void main() {
int tapCount;
int singleTapUpCount;
int singleTapCancelCount;
int singleLongTapDownCount;
int singleLongTapStartCount;
int doubleTapDownCount;
int forcePressStartCount;
int forcePressEndCount;
......@@ -17,7 +17,7 @@ void main() {
void _handleTapDown(TapDownDetails details) { tapCount++; }
void _handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; }
void _handleSingleTapCancel() { singleTapCancelCount++; }
void _handleSingleLongTapDown() { singleLongTapDownCount++; }
void _handleSingleLongTapStart(GestureLongPressDragStartDetails details) { singleLongTapStartCount++; }
void _handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; }
void _handleForcePressStart(ForcePressDetails details) { forcePressStartCount++; }
void _handleForcePressEnd(ForcePressDetails details) { forcePressEndCount++; }
......@@ -26,7 +26,7 @@ void main() {
tapCount = 0;
singleTapUpCount = 0;
singleTapCancelCount = 0;
singleLongTapDownCount = 0;
singleLongTapStartCount = 0;
doubleTapDownCount = 0;
forcePressStartCount = 0;
forcePressEndCount = 0;
......@@ -39,7 +39,7 @@ void main() {
onTapDown: _handleTapDown,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
onSingleLongTapStart: _handleSingleLongTapStart,
onDoubleTapDown: _handleDoubleTapDown,
onForcePressStart: _handleForcePressStart,
onForcePressEnd: _handleForcePressEnd,
......@@ -106,7 +106,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.
......@@ -114,7 +114,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 {
......@@ -127,7 +127,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.
......@@ -135,7 +135,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 {
......@@ -148,7 +148,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