-
Notifications
You must be signed in to change notification settings - Fork 28.6k
Add support for double tap and drag for text selection #109573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for double tap and drag for text selection #109573
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I only take a quick glance at the code. I like the direction this is going, thanks for refactoring it.
@@ -191,6 +195,16 @@ class DragUpdateDetails { | |||
/// Defaults to [globalPosition] if not specified in the constructor. | |||
final Offset localPosition; | |||
|
|||
/// A delta offset from the point where the drag initially contacted |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The receiver should be able to calculate this from the sequence of the update events? Does it worth it to add a new API that may be breaking change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this be break since offsetFromOrigin
has a default value, and localOffsetFromOrigin
is nullable? One of the reasons i'm in favor of adding this, is that LongPressMoveUpdateDetails
provides a similar API
const LongPressMoveUpdateDetails({ |
DragStartDetails
in our current text selection implementation, which is something I can't do with the text gestures project since the gesture recognizer mapping is defined in the classes initializer.
Edit: I just realized since I'm using SelectionEvents
and updating the SelectionEdgeStart
and SelectionEdgeEnd
I don't necessarily need this. Though I still think it would be good for the current implementation.
|
||
void consecutiveTapTimeout() { | ||
print('consecutive tap timeout'); | ||
consecutiveTapTimer?.cancel(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can this be null? if not, we should assert it
consecutiveTapCount += 1; | ||
lastTapOffset = details.globalPosition; | ||
} else { | ||
if (consecutiveTapTimer != null && isWithinConsecutiveTapTolerance(details.globalPosition)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels to me this logic should be in the mixin instead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does this differentiate between right click and left click
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, I think that logic can go in the mixin.
It does not currently differentiate between left and right click, but that is a good point. I don't think there are any gestures related to consecutive right clicks so it should be fine to not count the tap counts on right click, do you know of any?
typedef GestureDragEndWithConsecutiveTapCountCallback = void Function(DragEndDetails endDetails, int consecutiveTapCount); | ||
typedef GestureTapAndDragCancelCallback = void Function(); | ||
|
||
mixin ConsecutiveTapMixin { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably be private class for now
} | ||
} | ||
|
||
_consecutiveTapCountWhileDragging = consecutiveTapCount; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why in tap down? we can save this in move instead ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we save it in drag start or drag update, the count timer could timeout before we have begun the drag. In native I can hold the double tap and drag N seconds later and still get double tap to drag.
invokeCallback<void>('onEnd', () => onEnd!(endDetails, consecutiveTapCount)); | ||
_consecutiveTapCountWhileDragging = null; | ||
consecutiveTapTimer ??= Timer(kDoubleTapTimeout, consecutiveTapTimeout); | ||
} else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
may need to handle pointer cancel event
|
||
void _checkLongPressStart() { | ||
print('from recognizer check long press start'); | ||
if (_isDoubleTap) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does this work for double tap and drag?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean if we can do this case for double tap and drag?, or if this case breaks double tap to drag?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I'm super excited about having a built-in gesture recognizer for tap and drag. Thanks for cleaning all of this up. Most comments are smaller things.
|
||
/// {@macro flutter.gestures.tap.GestureTapDownCallback} | ||
/// | ||
/// The consecutive tap count at the time the pointer contacted the screen, is given by `consecutiveTapCount`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: No comma needed here and below I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also maybe link consecutiveTapCount like [TapStatus.consecutiveTapCount]?
// If last tap offset is false then we have restarted our consecutive tap count, | ||
// so the consecutiveTapTimer should be null.ß | ||
assert(consecutiveTapTimer == null); | ||
consecutiveTapCount += 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment above says that you have "restarted our consecutive tap count". Does that mean that consecutiveTapCount should be 0 here before the increment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Corrected this
this.dragStartBehavior = DragStartBehavior.start, | ||
super.kind, | ||
super.supportedDevices, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Do we need an assert that dragStartBehavior isn't null here? I know with sound null safety it won't be possible to pass null anyway, but I'm not sure if the assert will do something or not otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added the assert!
switch (defaultTargetPlatform) { | ||
case TargetPlatform.android: | ||
case TargetPlatform.fuchsia: | ||
case TargetPlatform.iOS: | ||
// On mobile platforms the selection is set on tap up. | ||
if (_isShiftTapping) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's really cool to see all of this shifttapping stuff go away and be replaced by a normal gesture detector 👍
// Adjust the drag start offset for possible viewport offset changes. | ||
final Offset startOffset = renderEditable.maxLines == 1 | ||
? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) | ||
: Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); | ||
final Offset dragStartGlobalPosition = details.globalPosition - details.offsetFromOrigin; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this something that you could always do to get the start position? It seems nice that we don't need DragStartDetails any more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes! because I added offsetFromOrigin
to DragUpdateDetails
. This is similar to LongPressMoveUpdateDetails
which also has offsetFromOrigin
. A good use case where that is currently used is in TextField
from: details.globalPosition - details.offsetFromOrigin, |
// Adjust the drag start offset for possible viewport offset changes. | ||
final Offset startOffset = renderEditable.maxLines == 1 | ||
? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) | ||
: Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); | ||
final Offset dragStartGlobalPosition = details.globalPosition - details.offsetFromOrigin; | ||
|
||
// Select word by word. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't realize this but even with a mouse this is right, it selects by word for me here on Linux.
@@ -3446,7 +3446,7 @@ void main() { | |||
); | |||
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); | |||
await tester.pump(); | |||
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero | |||
await tester.pump(const Duration(milliseconds: 300)); // skip past the frame where the opacity is zero |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a constant somewhere that could be used in all of the cases where you use 300ms to make it more clear where this comes from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I can do that 300ms is the kDoubleTapTimeout from gestures/constants.dart
@justinmc @chunhtai @LongCatIsLooong This is ready for review. There's an analyzer issue due to a dependency loop that happens when I bring in My main motivation for having it in the recognizer is so that I can then define a map of GestureRecognizer subclasses to GestureRecognizerFactory, in the initialization of a class https://github.com/Renzo-Olivares/flutter/blob/3dbffa3922c656e5c2f97fb6ff8d42b4573d152c/packages/flutter/lib/src/widgets/default_selection_gestures.dart#L43. This can't be done in the initializer if I have to save state, such as saving if shift was tapped when on tap down is pressed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great, I left some comments on the selection_gesture APIs
@@ -101,7 +101,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe | |||
final _CupertinoTextFieldState _state; | |||
|
|||
@override | |||
void onSingleTapUp(TapUpDetails details) { | |||
void onSingleTapUp(TapUpDetails details, TapStatus status) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can this be SerialTapUpDetails?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That wouldn't include the isShiftTapping
field (status of shift being pressed).
/// A delta offset from the point where the 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Which origin should this be if this is a double tap and drag? may be good to document this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be the global position of the most recent tap down, i.e. the second tap down of a double tap and drag. I'll make sure to document it.
/// The local position in the coordinate system of the event receiver at | ||
/// which the pointer contacted the screen. | ||
/// | ||
/// Defaults to [globalPosition] if not specified in the constructor. | ||
final Offset localPosition; | ||
|
||
/// A delta offset from the point where the drag initially contacted | ||
/// the screen to the point where the pointer is currently located (the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// the screen to the point where the pointer is currently located (the | |
/// the screen to the point where the pointer is currently located in global coordinates (the |
/// present [globalPosition]) when this callback is triggered. | ||
final Offset offsetFromOrigin; | ||
|
||
/// A local delta offset from the point where the drag initially contacted |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe
A delta offset from the point where the drag initially contacted the screen to the point where the pointer is currently located in local coordinates relative to <TextField?>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should add the TextField specification since these Details object is used outside of TextField.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is just place holder. When it say local coordinates, it only makes sense if it mention what it is relative to.
Offset? localPosition, | ||
this.offsetFromOrigin = Offset.zero, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this necessary? In the past we let the caller to calculate this if they need.
tangential to this pr, I just realized there are states in the drag gesture reconginzer, will the internal state messed up if it is sharing between multiple textfields?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've done this to avoid having to save lastStartDetails
and passing that to the drag update method to calculate the origin of the drag.
LongPressMoveUpdateDetails
has a similar API, and we also use it in text selection. #109573 (comment)
I think it's a nice to have, to avoid saving theDragStartDetails
.
/// | ||
/// Can be null to indicate that the gesture can drift for any distance. | ||
/// Defaults to [kTouchSlop]. | ||
final double? preAcceptSlopTolerance; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not entirely sure why this was exposed in other gesture recongizer, but you can use doc template to avoid duplicated the doc string from
/// | ||
/// Can be null to indicate that the gesture can drift for any distance. | ||
/// Defaults to [kTouchSlop]. | ||
final double? postAcceptSlopTolerance; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same
/// {@macro flutter.gestures.tap.TapGestureRecognizer.onSecondaryTapUp} | ||
GestureTapUpCallback? onSecondaryTapUp; | ||
|
||
/// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onStart} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
may need to document the tap status but, here and other places
kind: getKindForPointer(event.pointer), | ||
); | ||
|
||
incrementConsecutiveTapCountOnDown(details.globalPosition); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I imagine the _ConsecutiveTapMixin to be more self contain. can the _ConsecutiveTapMixin to be mix on OneSequenceGestureRecognizer so that it can override addAllowGesture, accept gesture and the rejectGesture to update its own state? TapAndDragGestureRecognizer can just query the consecutiveTapCount when the drag starts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we have to continue to query the consecutiveTapCount in the onTapDown. The primary reason being there can be a double click hold and then drag. If we query during drag start the consecutiveTapCount may have been reset by the Timer
timeout callback.
I also think at the OneSequenceGestureRecognizer
level we do not have access to the most recent down event (unless we start tracking it) which we need for consecutiveTapCount to be updated in acceptGesture
and rejectGesture
. I'm also unsure about adding this functionality to OneSequenceGestureRecognizer
since there are some GestureRecognizers
using it as a base class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can override handleEvent to get record most recent down event?
if (widget.onSingleLongTapStart != null || | ||
widget.onSingleLongTapMoveUpdate != null || | ||
widget.onSingleLongTapEnd != null) { | ||
gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( | ||
() => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch), | ||
() => LongPressGestureRecognizer(debugOwner: this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.touch }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this also be replaced by TapAndDragGestureRecognizer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the moment I did not see a reason to expand the new recognizer's capabilities to support LongPress
. I haven't identified any selection behaviors that rely on consecutive taps for long press.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how do we handle longpress and drag?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The one thing that I want to double check is the existence of TapStatus separate from the gesture Details. It just seems strangely different from the other gesture callbacks when I see both parameters. Could it be combined into the relevant Details classes, or is this approach actually better so we don't have to make changes to so many Details classes?
/// amount of time has elapsed since starting to track the primary pointer. | ||
/// | ||
/// [onTapDown] will not be called if the primary pointer is | ||
/// accepted, rejected, or all pointers are up or canceled before [deadline]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe use a macro for these repeated docs.
/// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapUp} | ||
GestureTapUpWithTapStatusCallback? onTapUp; | ||
|
||
/// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapCancel} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Should there be an empty ///
line after this macro?
to: details.globalPosition, | ||
cause: SelectionChangedCause.drag, | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you planning to do triple tap and drag later?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes triple tap and drag, will be handled in another PR.
return; | ||
case TargetPlatform.android: | ||
case TargetPlatform.fuchsia: | ||
// With a touch device, the cursor should move with the drag. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Also state what happens with a mouse?
} | ||
|
||
void _handleTapCancel() { | ||
widget.onSingleTapCancel?.call(); | ||
} | ||
|
||
DragStartDetails? _lastDragStartDetails; | ||
// TODO(Renzo-Olivares): Can this be moved into the TapAndDragGestureRecognizer? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Heads up there's a TODO still here.
|
||
expect(controller.selection.baseOffset, testValue.indexOf('d')); | ||
expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe do it like this for consistency (if indeed most other tests are this way. At least the previous one is.):
},
);
@@ -1835,6 +1835,103 @@ void main() { | |||
}, | |||
); | |||
|
|||
testWidgets( | |||
'double tap + drag selects word by word on mouse devices', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This title seems misleading to me because it's not the device that matters, it's the gesture device kind. Same for the next test and the corresponding tests in material.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to
Can double tap + drag to select word by word
and
Can double click + drag with a mouse to select word by word
final int consecutiveTapCount; | ||
|
||
/// Whether the shift key was pressed when this tap happened. | ||
final bool isShiftPressed; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Maybe shiftWasDown
or something like that? I don't know if I like that any better... But it's slightly confusing that this means that the shift was down at the time of the tap, not necessarily when it is accessed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed this to keysPressedOnDown
in case there are other keyboard + gesture combinations that need to be handled in the future. I have so far identified one other gesture besides shift, which is ctrl. ctrl + click acts like a right click on macOS, selecting the word at the clicked position and popping up the context menu.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah good call!
I see what you mean. It feels like |
Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change). If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
Golden file changes are available for triage from new commit, Click here to view. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
This is ready for another review. Most of the changes since the last review happened in I am still unsure how to proceed with the callback signatures including a tap status vs making a new details object. My only gripe about making new details objects is that I’m not sure what direction we want to move with them since the different details objects now share some common fields. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall looks good to me, mostly small comments. I checked out your branch and tried it out on iOS and Mac and it works great. Vertical handle dragging still works perfectly as before.
final Offset localPosition; | ||
|
||
/// If this tap is in a series of taps, then this value represents | ||
/// the number in the series this tap is. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Otherwise is it just 1?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but that is not set by default by this object. It is set by the recognizer.
/// the number in the series this tap is. | ||
final int consecutiveTapCount; | ||
|
||
/// The keys that were pressed when the most recent [PointerDownEvent] occurred. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So if this is say 5th in a series of taps, then keysPressedOnDown represents the keys down during the 5th tap?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exactly. I think it's fine that the keysPressedOnDown
represents the keys down of the most recent tap. I would think a gesture would seem a bit unintuitive if it was something like on the 2nd tap of a quadruple tap if shift was down then do some action on the 4th tap.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah that sounds fine to me 👍
final PointerDeviceKind kind; | ||
|
||
/// If this tap is in a series of taps, then this value represents | ||
/// the number in the series this tap is. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading through this, I'm wondering exactly what events are fired when. Like I think this is only for tapping several times in one spot and then dragging from there. So continuing to tap again after dragging would not be part of the same consecutiveTapCount. Is that right?
Maybe you could explain this more somewhere if it's not already (and I haven't gotten to it yet). Like give an example of a user tapping a few times, then dragging, then lifting, and say what events are fired when. Maybe this belongs somewhere else besides the Details classes, just thinking out loud here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is correct, if you continue to tap again after a drag then the tap series is reset. I'll put together some more docs.
expect(controller.selection.baseOffset, testValue.indexOf('d')); | ||
expect(controller.selection.extentOffset, testValue.indexOf('i') + 1); | ||
}, | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this kind of test work on an EditableText so you wouldn't have to test both TextField and CupertinoTextField? I know handle dragging might not be worth it to do on EditableText, but maybe just this kind of tap and drag would work easily?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we have any tests for TextSelectionGestureDetector
inside of EditableText
since it is only available at the TextField
level. The only gesture related tests I see on EditableText
are ones that make use of the internal gesture recognizers RenderEditable
has. I think we should keep these tests where they are for now, but definitely explore how we can make the situation better. I always find it a bit cumbersome to have to write tests x2 sometimes x3 if we include SelectableText
.
Since we want our new Text selection gestures to follow platform behaviors versus follow design language behaviors (material vs cupertino), then I agree the best path forwards are to move all the selection tests to one place since they shouldn't differ between cupertino and material.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah that's right, sounds good, but agreed that we should find a way to consolidate these in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like the mixin still not separating the functionality well enough that the TapAndDragGestureRecognizer
and _TapStatusTrackerMixin
needs to use fragmented information to guess the status of each other's state.
Is it possible to come up with a mechanism in _TapStatusTrackerMixin to start or stop tracking the tap? something like _TapStatusTrackerMixin.startTrackingTap
or _TapStatusTrackerMixin.stopTrackingTap
. The TapAndDragGestureRecognizer
will just look for when drag is accepted.
Before drag is accepted, everything will be handled by _TapStatusTrackerMixin
. Once drag is accepted, TapAndDragGestureRecognizer
call _TapStatusTrackerMixin.stopTrackingTap
and start handling the event.
for _TapStatusTrackerMixin
it will just handle event as long as _TapStatusTrackerMixin.stopTrackingTap
is not called.
Will this make thing cleaner? If this is not possible, we may consider just merge them together since they are too inner-connected
if (_up != null && _down != null) { | ||
_consecutiveTapTimerStop(); | ||
_consecutiveTapTimerStart(); | ||
_wonArena = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does the _wonArena parameter really mean? How can a gesturereconginzer un-win itself without caliing rejectGesture?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It means the gesture recognizer has won the arena for the given pointer sometime in the past. This is used because acceptGesture
might be called before handleEvent
and in that case we will not have tracked a PointerUpEvent
yet so we cannot call tap up until we track the PointerUpEvent
in handleEvent
. So I use _wonArena
to communicate this to handleEvent
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ontapup is no longer in this class is this parameter still needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes because we want to start the timer on tap up and stop it on tap down. Better explained here: #109573 (comment)
|
||
@override | ||
void handleEvent(PointerEvent event) { | ||
if (event is PointerMoveEvent) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this need to check for move? we should only need to worry about the slop distance when the pointer is up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There can be a PointerMoveEvent
that occurs between a PointerDownEvent
and PointerUpEvent
. If that PointerMoveEvent
reaches that threshold then we should stop tracking the current tap series. See
if (event is PointerMoveEvent && (isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) { |
if (_dragState == _DragState.possible) { | ||
// If we arrive at a [PointerUpEvent], and the recognizer has not won the arena, and the tap drift | ||
// has exceeded its tolerance, then we should reject this recognizer. | ||
if (pastTapTolerance) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be handled by the mixin?
_giveUpPointer(event.pointer); | ||
return; | ||
} | ||
// The drag has not been accepted before a [PointerUpEvent], therefore the recognizer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't wrap my head around the logic.
Is it using pastTapTolerance
to infer the drag has not been accepted?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Say we have a sequence of events PointerDownEvent
-> PointerMoveEvent
-> PointerUpEvent
. There are 3 cases.
- If we arrive at that
PointerUpEvent
and thePointerMoveEvent
did not move a sufficient global distance to be accepted as a drag, but moved a sufficient global distance to move past the tap tolerance.
- TapCancel
- DragCancel
- If we arrive at that
PointerUpEvent
and thePointerMoveEvent
moved a sufficient global distance to be accepted as a drag, therefore also moving past the tap tolerance because dragTolerance > tapTolerance.
- TapCancel
- If we arrive at that
PointerUpEvent
and thePointerMoveEvent
did not move a sufficient global distance to be accepted as a drag and it did not move past the tap tolerance.
- DragCancel
It is using pastTapTolerance
to decide if the tap has been declined.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the pointer did not move a sufficient global distance to be accepted as a drag.
In this case, why does it need to send DragCancel? Does the Drag already start at this point?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because even if it doesn't meet the sufficient global distance to be accepted as a drag, the DragGestureRecognizer
will be accepted if it is the last recognizer in the arena before a PointerUpEvent
is tracked.
Here I was trying to match the behavior of PanGestureRecognizer
and TapGestureRecognizer
in an arena.
The PanGestureRecognizer
will only self-lose if we have arrived at a PointerUpEvent
and our drag has not yet been accepted. It will only declare victory when a sufficient global distance has been moved from the origin.
The TapGestureRecognizer
will only win when it is the last member of the arena. It does not declare victory on its own. It will self-lose when the tap has drifted past the tap tolerance defined by preAcceptSlopTolerance
and postAcceptSlopTolerance
, this behavior is defined by PrimaryPointerGestureRecognizer
.
When both of these recognizers are in one arena this is their behavior.
If we arrive at that PointerUpEvent and the PointerMoveEvent did not move a sufficient global distance to be accepted as a drag, but moved a sufficient global distance to move past the tap tolerance.
- Nothing unless the
PrimaryPointerGestureRecognizer.deadline
has elapsed and the tap down callback has been called in that case TapCancel will be called becausePrimaryPointerGestureRecognizer
declares self-loss because we moved past the tap tolerance.
If we arrive at that PointerUpEvent and the PointerMoveEvent moved a sufficient global distance to be accepted as a drag, therefore also moving past the tap tolerance because dragTolerance > tapTolerance.
- TapCancel because
DragGestureRecognizer
has declared self-victory, andPrimaryPointerGestureRecognizer
has also declared self-loss because we have moved past the tap tolerance.
If we arrive at that PointerUpEvent and the PointerMoveEvent did not move a sufficient global distance to be accepted as a drag and it did not move past the tap tolerance.
- DragCancel because we arrive at
DragGestureRecognizer.didStopTrackingLastPointer
when the_DragState
ispossible
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like in case #1
I am not matching the target behavior. Where individual TapGestureRecognizer
and DragGestureRecognizer
will detect the case as a drag, my single TapAndDragGestureRecognizer
rejects both tap and drag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am now mostly matching the behavior in scenario #1
.
With two recognizers TapGestureRecognizer
and PanGestureRecognizer
:
onDown-PanGestureRecognizer
-> onStart-PanGestureRecognizer
-> onUpdate-PanGestureRecognizer
-> onEnd-PanGestureRecognizer
TapGestureRecognizer
declares loss and doesn't fire any callbacks because the pointer moves past the tap tolerance.
With one TapAndDragGestureRecognizer
:
onTapDown-TapAndDragGestureRecognize
-> onTapCancel-TapAndDragGestureRecognize
-> onDragStart-TapAndDragGestureRecognize
-> onDragUpdate-TapAndDragGestureRecognize
-> onDragEnd-TapAndDragGestureRecognize
.
The difference is that the new recognizer calls onTapDown
and onTapCancel
followed by onDragStart
and onDragEnd
. This differing behavior is expected for the new recognizer since it will call onTapDown
when acceptGesture
is called, and that needs to be followed up with an onTapCancel
when a drag begins.
When using two recognizers (TapGestureRecognizer
and PanGestureRecognizer
), onTapDown
is not called because TapGestureRecognizer
only fires it on acceptGesture
and TapGestureRecognizer
self declares loss when moving past tap tolerance, therefore there is also no need for it to call onTapCancel
since tap down was never fired. In this situation PanGestureRecognizer
wins and the gesture is pan.
Our case differs because TapGestureRecognizer
declares loss, which gives acceptGesture
in TapAndDragGestureRecognizer
the chance to call onTapDown
, but because our pointer moved past the tap tolerance this gesture cannot be a tap, so we call tap cancel and the gesture defaults to a drag.
I added some tests to verify these behaviors as well as documentation. Do you have any thoughts on this cancel behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels weird that we merge two gesture recognizers to eliminate the conflicting callback and cancelling each other, but now we still simulating the canceling behavior in one merged gesture recognizer. In my opinion, once the canceling is called, this gesture recognizer should not fire any other callback in this gesture sequence. I think we should treat tap and drag as one kind of gesture so in this case
onTapDown-TapAndDragGestureRecognize -> onTapCancel-TapAndDragGestureRecognize -> onDragStart-TapAndDragGestureRecognize -> onDragUpdate-TapAndDragGestureRecognize -> onDragEnd-TapAndDragGestureRecognize.
it doesn't make sense to call onTapCancel-TapAndDragGestureRecognize
. The onTapDown
can be a down for a single tap or a down for a drag. The cusumer (i.e editabletext) should have this in mind when using this recognizer. If they have to differentiate it, they need to implement their own logic or keep some kind of state.
Does this make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes a lot of sense! I think my initial train of thought regarding the decision on cancel behavior was to keep some tests happy, but it makes a lot more sense to change the intended behavior and have the recognizer only have one cancel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated the cancel behavior to only cancel when a tap down has been fired, i.e. when a PointerDownEvent was received. Also merged onTapCancel
and onDragCancel
into onCancel
.
I agree that the onCancel
should be the callback fired in a sequence of callbacks of the recognizer. And it should only be called if the recognizer has fired a tap down before, if not then it is unclear what was "cancelled". The new changes now reflect this.
3c0c59c
to
27021c2
Compare
This change adds support for double tap + drag, to select word by word when double tapping and then dragging the mouse.
This change also adds a new gesture recognizers that will be specifically useful for text selection:
TapAndDragGestureRecognizer
This recognizer makes use of a
_TapStatusTrackerMixin
to keep track of the tap count.TextSelectionGestureDetector
no longer uses aTapGestureRecognizer
orPanGestureRecognizer
.TapUp
,TapDown
,TapCancel
,onStart
,onUpdate
,onEnd
, andonCancel
are now handled by the new recognizer.Currently our text selection system does not support double tap + drag (selects word by word) and triple tap + drag (selects line by line). This PR adds support for this behavior by bundling the handling of tap up and tap down with a drag gesture recognizer and long press gesture recognizer. This helps us avoid having to pass state around to accomplish the same behavior. For example, this behavior could also be accomplished by having the TapGestureRecognizer.onTapDown callback, save the tap count, then this tap count can be used by the DragGestureRecognizer.onUpdate callback to determine if the selection should be updated character by character, word by word, or line by line. Some disadvantages of passing state around between gesture recognizers are that you can't accomplish this behavior in a classes initializer (Trying to build a default mapping of gesture recognizers for text selection https://github.com/Renzo-Olivares/flutter/blob/3dbffa3922c656e5c2f97fb6ff8d42b4573d152c/packages/flutter/lib/src/widgets/default_selection_gestures.dart#L24).
Will be handled in separate PR:
Selection area #104552
Fixes #99071, #64550, #23973
Pre-launch Checklist
///
).