Enable SelectionArea double tap/triple tap gesture support for mobile platforms (#149295)

This change enables double tap / triple tap support in SelectionArea for mobile platforms:
Android / Fuchsia: 
- On native, these platforms allow for double tap / double tap + drag to select word-by-word.
- On web using touch, these platforms only support double tap to select word.
- On web and native using a mouse, these platforms support double click / double click + drag to select word-by-word, and triple click / triple click + drag to select paragraph-by-paragraph.

iOS:
- On native, these platforms allow for double tap / double tap + drag to select word-by-word.
- On web using touch, these platforms do not support double tap/triple tap gestures.
- On web using touch, these platforms allow support double tap + drag gestures.
- On web and native using a mouse, these platforms support double click / double click + drag to select word-by-word, and triple click / triple click + drag to select paragraph-by-paragraph.

Part of: https://github.com/flutter/flutter/issues/129583
This commit is contained in:
Renzo Olivares 2024-06-21 11:47:20 -07:00 committed by GitHub
parent 9056c0b192
commit 20459dda0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 742 additions and 56 deletions

View file

@ -354,9 +354,6 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
Orientation? _lastOrientation;
SelectedContent? _lastSelectedContent;
/// {@macro flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
Offset? lastSecondaryTapDownPosition;
/// The [SelectionOverlay] that is currently visible on the screen.
///
/// Can be null if there is no visible [SelectionOverlay].
@ -375,25 +372,10 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
widget.focusNode.addListener(_handleFocusChanged);
_initMouseGestureRecognizer();
_initTouchGestureRecognizer();
// Taps and right clicks.
// Right clicks.
_gestureRecognizers[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance.onTapUp = (TapUpDetails details) {
if (defaultTargetPlatform == TargetPlatform.iOS && _positionIsOnActiveSelection(globalPosition: details.globalPosition)) {
// On iOS when the tap occurs on the previous selection, instead of
// moving the selection, the context menu will be toggled.
final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false;
if (toolbarIsVisible) {
hideToolbar(false);
} else {
_showToolbar(location: details.globalPosition);
}
} else {
hideToolbar();
_collapseSelectionAt(offset: details.globalPosition);
}
};
instance.onSecondaryTapDown = _handleRightClickDown;
},
);
@ -487,6 +469,27 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
// gestures.
// The position of the most recent secondary tap down event on this
// SelectableRegion.
Offset? _lastSecondaryTapDownPosition;
// The device kind for the pointer of the most recent tap down event on this
// SelectableRegion.
PointerDeviceKind? _lastPointerDeviceKind;
static bool _isPrecisePointerDevice(PointerDeviceKind pointerDeviceKind) {
switch (pointerDeviceKind) {
case PointerDeviceKind.mouse:
return true;
case PointerDeviceKind.trackpad:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
return false;
}
}
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
// which can grow to be infinitely large, to a value between 1 and the supported
// max consecutive tap count. The value that the raw count is converted to is
@ -494,11 +497,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
//
// This method should be used in all instances when details.consecutiveTapCount
// would be used.
static int _getEffectiveConsecutiveTapCount(int rawCount) {
const int maxConsecutiveTap = 3;
int _getEffectiveConsecutiveTapCount(int rawCount) {
int maxConsecutiveTap = 3;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
if (_lastPointerDeviceKind != null && _lastPointerDeviceKind != PointerDeviceKind.mouse) {
// When the pointer device kind is not precise like a mouse, native
// Android resets the tap count at 2. For example, this is so the
// selection can collapse on the third tap.
maxConsecutiveTap = 2;
}
// From observation, these platforms reset their tap count to 0 when
// the number of consecutive taps exceeds the max consecutive tap supported.
// For example on native Android, when going past a triple click,
// on the fourth click the selection is moved to the precise click
// position, on the fifth click the word at the position is selected, and
// on the sixth click the paragraph at the position is selected.
return rawCount <= maxConsecutiveTap ? rawCount : (rawCount % maxConsecutiveTap == 0 ? maxConsecutiveTap : rawCount % maxConsecutiveTap);
case TargetPlatform.linux:
// From observation, these platforms reset their tap count to 0 when
// the number of consecutive taps exceeds the max consecutive tap supported.
@ -510,7 +526,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.windows:
// From observation, these platforms either hold their tap count at the max
// From observation, these platforms hold their tap count at the max
// consecutive tap supported. For example on macOS, when going past a triple
// click, the selection should be retained at the paragraph that was first
// selected on triple click.
@ -519,19 +535,27 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
}
void _initMouseGestureRecognizer() {
_gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(TapAndPanGestureRecognizer instance) {
instance
..onTapDown = _startNewMouseSelectionGesture
..onTapUp = _handleMouseTapUp
..onDragStart = _handleMouseDragStart
..onDragUpdate = _handleMouseDragUpdate
..onDragEnd = _handleMouseDragEnd
..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down;
},
);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
_gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner:this),
(TapAndPanGestureRecognizer instance) {
instance
..onTapDown = _startNewMouseSelectionGesture
..onTapUp = _handleMouseTapUp
..onDragStart = _handleMouseDragStart
..onDragUpdate = _handleMouseDragUpdate
..onDragEnd = _handleMouseDragEnd
..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down;
},
);
}
}
void _initTouchGestureRecognizer() {
@ -546,26 +570,59 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
);
}
Offset? _doubleTapOffset;
void _startNewMouseSelectionGesture(TapDragDownDetails details) {
_lastPointerDeviceKind = details.kind;
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
widget.focusNode.requestFocus();
hideToolbar();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
// On mobile platforms the selection is set on tap up.
// On mobile platforms the selection is set on tap up for the first
// tap.
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
hideToolbar();
_collapseSelectionAt(offset: details.globalPosition);
}
case 2:
_selectWordAt(offset: details.globalPosition);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
if (kIsWeb && details.kind != null && !_isPrecisePointerDevice(details.kind!)) {
// Double tap on iOS web triggers when a drag begins after the double tap.
_doubleTapOffset = details.globalPosition;
break;
}
_selectWordAt(offset: details.globalPosition);
if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) {
_showHandles();
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
_selectWordAt(offset: details.globalPosition);
}
case 3:
_selectParagraphAt(offset: details.globalPosition);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
if (details.kind != null && _isPrecisePointerDevice(details.kind!)) {
// Triple tap on static text is only supported on mobile
// platforms using a precise pointer device.
_selectParagraphAt(offset: details.globalPosition);
}
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
_selectParagraphAt(offset: details.globalPosition);
}
}
_updateSelectedContentIfNeeded();
}
@ -573,6 +630,10 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
void _handleMouseDragStart(TapDragStartDetails details) {
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) {
// Drag to select is only enabled with a precise pointer device.
return;
}
_selectStartTo(offset: details.globalPosition);
}
_updateSelectedContentIfNeeded();
@ -581,27 +642,101 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
void _handleMouseDragUpdate(TapDragUpdateDetails details) {
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) {
// Drag to select is only enabled with a precise pointer device.
return;
}
_selectEndTo(offset: details.globalPosition, continuous: true);
case 2:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// Double tap + drag is only supported on Android when using a precise
// pointer device or when not on the web.
if (!kIsWeb || details.kind != null && _isPrecisePointerDevice(details.kind!)) {
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
}
case TargetPlatform.iOS:
if (kIsWeb && details.kind != null && !_isPrecisePointerDevice(details.kind!) && _doubleTapOffset != null) {
// On iOS web a double tap does not select the word at the position,
// until the drag has begun.
_selectWordAt(offset: _doubleTapOffset!);
_doubleTapOffset = null;
}
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) {
_showHandles();
}
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
}
case 3:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
// Triple tap + drag is only supported on mobile devices when using
// a precise pointer device.
if (details.kind != null && _isPrecisePointerDevice(details.kind!)) {
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
}
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
}
}
_updateSelectedContentIfNeeded();
}
void _handleMouseDragEnd(TapDragEndDetails details) {
final bool isPointerPrecise = _lastPointerDeviceKind != null && _lastPointerDeviceKind == PointerDeviceKind.mouse;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
if (!isPointerPrecise) {
// On Android, a drag gesture will only show the selection overlay when
// the drag has finished and the pointer device kind is not precise.
_showHandles();
_showToolbar();
}
case TargetPlatform.iOS:
if (!isPointerPrecise) {
// On iOS, a drag gesture will only show the selection toolbar when
// the drag has finished and the pointer device kind is not precise.
_showToolbar();
}
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
// The selection overlay is not shown on desktop platforms after a drag.
break;
}
_finalizeSelection();
_updateSelectedContentIfNeeded();
}
void _handleMouseTapUp(TapDragUpDetails details) {
if (defaultTargetPlatform == TargetPlatform.iOS && _positionIsOnActiveSelection(globalPosition: details.globalPosition)) {
// On iOS when the tap occurs on the previous selection, instead of
// moving the selection, the context menu will be toggled.
final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false;
if (toolbarIsVisible) {
hideToolbar(false);
} else {
_showToolbar();
}
return;
}
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
case 1:
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
hideToolbar();
_collapseSelectionAt(offset: details.globalPosition);
case TargetPlatform.macOS:
case TargetPlatform.linux:
@ -609,6 +744,30 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
// On desktop platforms the selection is set on tap down.
break;
}
case 2:
final bool isPointerPrecise = _isPrecisePointerDevice(details.kind);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
if (!isPointerPrecise) {
// On Android, a double tap will only show the selection overlay after
// the following tap up when the pointer device kind is not precise.
_showHandles();
_showToolbar();
}
case TargetPlatform.iOS:
if (!isPointerPrecise) {
// On iOS, a double tap will only show the selection toolbar after
// the following tap up when the pointer device kind is not precise.
_showToolbar();
}
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
// The selection overlay is not shown on desktop platforms
// on a double click.
break;
}
}
_updateSelectedContentIfNeeded();
}
@ -659,47 +818,47 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
}
void _handleRightClickDown(TapDownDetails details) {
final Offset? previousSecondaryTapDownPosition = lastSecondaryTapDownPosition;
final Offset? previousSecondaryTapDownPosition = _lastSecondaryTapDownPosition;
final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false;
lastSecondaryTapDownPosition = details.globalPosition;
_lastSecondaryTapDownPosition = details.globalPosition;
widget.focusNode.requestFocus();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
// If lastSecondaryTapDownPosition is within the current selection then
// If _lastSecondaryTapDownPosition is within the current selection then
// keep the current selection, if not then collapse it.
final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition);
if (!lastSecondaryTapDownPositionWasOnActiveSelection) {
_collapseSelectionAt(offset: lastSecondaryTapDownPosition!);
_collapseSelectionAt(offset: _lastSecondaryTapDownPosition!);
}
_showHandles();
_showToolbar(location: lastSecondaryTapDownPosition);
_showToolbar(location: _lastSecondaryTapDownPosition);
case TargetPlatform.iOS:
_selectWordAt(offset: lastSecondaryTapDownPosition!);
_selectWordAt(offset: _lastSecondaryTapDownPosition!);
_showHandles();
_showToolbar(location: lastSecondaryTapDownPosition);
_showToolbar(location: _lastSecondaryTapDownPosition);
case TargetPlatform.macOS:
if (previousSecondaryTapDownPosition == lastSecondaryTapDownPosition && toolbarIsVisible) {
if (previousSecondaryTapDownPosition == _lastSecondaryTapDownPosition && toolbarIsVisible) {
hideToolbar();
return;
}
_selectWordAt(offset: lastSecondaryTapDownPosition!);
_selectWordAt(offset: _lastSecondaryTapDownPosition!);
_showHandles();
_showToolbar(location: lastSecondaryTapDownPosition);
_showToolbar(location: _lastSecondaryTapDownPosition);
case TargetPlatform.linux:
if (toolbarIsVisible) {
hideToolbar();
return;
}
// If lastSecondaryTapDownPosition is within the current selection then
// If _lastSecondaryTapDownPosition is within the current selection then
// keep the current selection, if not then collapse it.
final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition);
if (!lastSecondaryTapDownPositionWasOnActiveSelection) {
_collapseSelectionAt(offset: lastSecondaryTapDownPosition!);
_collapseSelectionAt(offset: _lastSecondaryTapDownPosition!);
}
_showHandles();
_showToolbar(location: lastSecondaryTapDownPosition);
_showToolbar(location: _lastSecondaryTapDownPosition);
}
_updateSelectedContentIfNeeded();
}
@ -1177,9 +1336,9 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
/// for the default context menu buttons.
TextSelectionToolbarAnchors get contextMenuAnchors {
if (lastSecondaryTapDownPosition != null) {
if (_lastSecondaryTapDownPosition != null) {
return TextSelectionToolbarAnchors(
primaryAnchor: lastSecondaryTapDownPosition!,
primaryAnchor: _lastSecondaryTapDownPosition!,
);
}
final RenderBox renderBox = context.findRenderObject()! as RenderBox;

View file

@ -110,6 +110,37 @@ void main() {
expect(selectionEvent.globalPosition, const Offset(200.0, 200.0));
});
testWidgets('touch double click sends select-word event', (WidgetTester tester) async {
final UniqueKey spy = UniqueKey();
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
),
)
);
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
renderSelectionSpy.events.clear();
await gesture.down(const Offset(200.0, 200.0));
await tester.pump();
await gesture.up();
expect(renderSelectionSpy.events.length, 1);
expect(renderSelectionSpy.events[0], isA<SelectWordSelectionEvent>());
final SelectWordSelectionEvent selectionEvent = renderSelectionSpy.events[0] as SelectWordSelectionEvent;
expect(selectionEvent.globalPosition, const Offset(200.0, 200.0));
});
testWidgets('Does not crash when using Navigator pages', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/119776
final FocusNode focusNode = FocusNode();
@ -596,6 +627,502 @@ void main() {
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
}, variant: TargetPlatformVariant.desktop());
testWidgets('touch can select word-by-word on double tap drag on mobile platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
await gesture.moveTo(textOffsetToPosition(paragraph, 7));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 8));
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
// Start a new double-click drag.
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pump();
await gesture.up();
expect(paragraph.selections.isEmpty, isFalse);
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 5));
await tester.pump(kDoubleTapTimeout);
// Double-click.
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 5));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
// Selecting across line should select to the end.
await gesture.moveTo(textOffsetToPosition(paragraph, 5) + const Offset(0.0, 200.0));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 11));
await gesture.up();
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web does not support double tap + drag gestures on all of the tested platforms.
);
testWidgets('touch can select multiple widgets on double tap drag on mobile platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
await gesture.up();
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web does not support double tap + drag gestures on all of the tested platforms.
);
testWidgets('touch can select multiple widgets on double tap drag and return to origin word on mobile platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select the rest of paragraph 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should clear the selection on paragraph 3.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
// Should clear the selection on paragraph 2.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
expect(paragraph2.selections.isEmpty, isTrue);
expect(paragraph3.selections.isEmpty, isTrue);
await gesture.up();
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web does not support double tap + drag gestures on all of the tested platforms.
);
testWidgets('touch can reverse selection across multiple widgets on double tap drag on mobile platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?'),
Text('Good, and you?'),
Text('Fine, thank you.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 10));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph3, 10));
await tester.pumpAndSettle();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11));
await gesture.moveTo(textOffsetToPosition(paragraph3, 4));
await tester.pump();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 4));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 5));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 0));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 12, extentOffset: 4));
await gesture.up();
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web does not support double tap + drag gestures on all of the tested platforms.
);
testWidgets('touch cannot triple tap or triple tap drag on Android and iOS', (WidgetTester tester) async {
const String longText = 'Hello world this is some long piece of text '
'that will represent a long paragraph, when triple clicking this block '
'of text all of it will be selected.\n'
'This will be the start of a new line. When triple clicking this block '
'of text all of it should be selected.';
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text(longText),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text(longText), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 150));
await gesture.moveTo(textOffsetToPosition(paragraph, 155));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
await gesture.moveTo(textOffsetToPosition(paragraph, 170));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 150));
// Start a new triple-click drag.
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pumpAndSettle();
await gesture.up();
expect(paragraph.selections.isNotEmpty, isTrue);
expect(paragraph.selections.length, 1);
expect(paragraph.selections.first, const TextSelection.collapsed(offset: 151));
await tester.pump(kDoubleTapTimeout);
// Triple-click.
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 150, extentOffset: 257));
await gesture.up();
await tester.pumpAndSettle();
// Reset selection.
await tester.tapAt(textOffsetToPosition(paragraph, 0));
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 0));
// Trying to triple-click with a touch gesture should not work.
final TestGesture touchGesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
addTearDown(touchGesture.removePointer);
await tester.pump();
await touchGesture.up();
await tester.pump();
await touchGesture.down(textOffsetToPosition(paragraph, 2));
await tester.pump();
await touchGesture.up();
await tester.pump();
await touchGesture.down(textOffsetToPosition(paragraph, 2));
await tester.pump();
await touchGesture.up();
await tester.pumpAndSettle();
// The selection is collapsed on Android because the max consecutive tap count
// on native Android is 2 when the pointer device kind is not precise like
// for a touch.
//
// On iOS the selection is maintained because the tap occured on the active
// selection.
expect(paragraph.selections[0], defaultTargetPlatform == TargetPlatform.iOS ? const TextSelection(baseOffset: 0, extentOffset: 5) : const TextSelection.collapsed(offset: 2));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
skip: kIsWeb, // [intended] Web does not support double tap + drag gestures on all of the tested platforms.
);
testWidgets('touch cannot select word-by-word on double tap drag when on Android web', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
// Dragging should not change the selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph, 7));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.up();
await tester.pumpAndSettle();
},
skip: !kIsWeb, // [intended] This test verifies web behavior.
);
testWidgets('touch can double tap + drag on iOS web', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2));
// A double tap should not change the selection.
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2));
// Dragging should change the selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 3));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
await gesture.moveTo(textOffsetToPosition(paragraph, 7));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 8));
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.up();
await tester.pumpAndSettle();
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
skip: true, // https://github.com/flutter/flutter/issues/125582.
);
testWidgets('touch cannot double tap on iOS web', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2));
// A double tap should not change the selection.
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 2));
await gesture.up();
await tester.pumpAndSettle();
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
skip: !kIsWeb, // [intended] This test verifies web behavior.
);
testWidgets('mouse can select single text on desktop platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);