Reland "Single tap on the previous selection should toggle the toolbar on iOS #108913" (#111995)
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index a639739..cef464d 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -102,7 +102,6 @@
@override
void onSingleTapUp(TapUpDetails details) {
- editableText.hideToolbar();
// Because TextSelectionGestureDetector listens to taps that happen on
// widgets in front of it, tapping the clear button will also trigger
// this handler. If the clear button widget recognizes the up event,
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 992dccd..f477f6e 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -103,7 +103,6 @@
@override
void onSingleTapUp(TapUpDetails details) {
- editableText.hideToolbar();
super.onSingleTapUp(details);
_state._requestKeyboard();
_state.widget.onTap?.call();
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index c287ac5..bad52f3 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -2724,8 +2724,8 @@
_scribbleCacheKey = null;
}
- void _createSelectionOverlay() {
- _selectionOverlay = TextSelectionOverlay(
+ TextSelectionOverlay _createSelectionOverlay() {
+ final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
clipboardStatus: _clipboardStatus,
context: context,
value: _value,
@@ -2740,6 +2740,8 @@
onSelectionHandleTapped: widget.onSelectionHandleTapped,
magnifierConfiguration: widget.magnifierConfiguration,
);
+
+ return selectionOverlay;
}
@pragma('vm:notify-debugger-on-exception')
@@ -2780,7 +2782,7 @@
_selectionOverlay = null;
} else {
if (_selectionOverlay == null) {
- _createSelectionOverlay();
+ _selectionOverlay = _createSelectionOverlay();
} else {
_selectionOverlay!.update(_value);
}
@@ -3266,7 +3268,7 @@
if (value == textEditingValue) {
if (!widget.focusNode.hasFocus) {
widget.focusNode.requestFocus();
- _createSelectionOverlay();
+ _selectionOverlay = _createSelectionOverlay();
}
return;
}
@@ -3317,10 +3319,11 @@
}
/// Toggles the visibility of the toolbar.
- void toggleToolbar() {
- assert(_selectionOverlay != null);
- if (_selectionOverlay!.toolbarIsVisible) {
- hideToolbar();
+ void toggleToolbar([bool hideHandles = true]) {
+ final TextSelectionOverlay selectionOverlay = _selectionOverlay ??= _createSelectionOverlay();
+
+ if (selectionOverlay.toolbarIsVisible) {
+ hideToolbar(hideHandles);
} else {
showToolbar();
}
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index df987b4..9ec54da 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -1635,6 +1635,26 @@
&& renderEditable.selection!.end >= textPosition.offset;
}
+ bool _positionWasOnSelectionExclusive(TextPosition textPosition) {
+ final TextSelection? selection = renderEditable.selection;
+ if (selection == null) {
+ return false;
+ }
+
+ return selection.start < textPosition.offset
+ && selection.end > textPosition.offset;
+ }
+
+ bool _positionWasOnSelectionInclusive(TextPosition textPosition) {
+ final TextSelection? selection = renderEditable.selection;
+ if (selection == null) {
+ return false;
+ }
+
+ return selection.start <= textPosition.offset
+ && selection.end >= textPosition.offset;
+ }
+
// Expand the selection to the given global position.
//
// Either base or extent will be moved to the last tapped position, whichever
@@ -1879,6 +1899,7 @@
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
+ editableText.hideToolbar();
// On desktop platforms the selection is set on tap down.
if (_isShiftTapping) {
_isShiftTapping = false;
@@ -1886,6 +1907,7 @@
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
+ editableText.hideToolbar();
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
@@ -1918,8 +1940,32 @@
break;
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
- // On iOS/iPadOS a touch tap places the cursor at the edge of the word.
- renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
+ // Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the
+ // TextAffinity remains the same, and the editable is focused. The TextAffinity is important when the
+ // cursor is on the boundary of a line wrap, if the affinity is different (i.e. it is downstream), the
+ // selection should move to the following line and not toggle the toolbar.
+ //
+ // Toggle the toolbar when the tap is exclusively within the bounds of a non-collapsed `previousSelection`,
+ // and the editable is focused.
+ //
+ // Selects the word edge closest to the tap when the editable is not focused, or if the tap was neither exclusively
+ // or inclusively on `previousSelection`. If the selection remains the same after selecting the word edge, then we
+ // toggle the toolbar. If the selection changes then we hide the toolbar.
+ final TextSelection previousSelection = renderEditable.selection ?? editableText.textEditingValue.selection;
+ final TextPosition textPosition = renderEditable.getPositionForPoint(details.globalPosition);
+ final bool isAffinityTheSame = textPosition.affinity == previousSelection.affinity;
+ if (((_positionWasOnSelectionExclusive(textPosition) && !previousSelection.isCollapsed)
+ || (_positionWasOnSelectionInclusive(textPosition) && previousSelection.isCollapsed && isAffinityTheSame))
+ && renderEditable.hasFocus) {
+ editableText.toggleToolbar(false);
+ } else {
+ renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
+ if (previousSelection == editableText.textEditingValue.selection && renderEditable.hasFocus) {
+ editableText.toggleToolbar(false);
+ } else {
+ editableText.hideToolbar(false);
+ }
+ }
break;
}
break;
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index d719758..9b6d3d3 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -1753,11 +1753,170 @@
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6);
- // No toolbar.
- expect(find.byType(CupertinoButton), findsNothing);
+ // Toolbar shows on mobile.
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : isTargetPlatformMobile ? findsNWidgets(2) : findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets(
+ 'Tapping on a collapsed selection toggles the toolbar',
+ (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(
+ text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
+ );
+ // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoTextField(
+ controller: controller,
+ maxLines: 2,
+ ),
+ ),
+ ),
+ );
+
+ final double lineHeight = findRenderEditable(tester).preferredLineHeight;
+ final Offset begPos = textOffsetToPosition(tester, 0);
+ final Offset endPos = textOffsetToPosition(tester, 35) + const Offset(200.0, 0.0); // Index of 'Bonaventure|' + Offset(200.0,0), which is at the end of the first line.
+ final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'.
+ final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'.
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(wPos);
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(vPos);
+ await tester.pump(const Duration(milliseconds: 500));
+ // First tap moved the cursor. Here we tap the position where 'v' is located.
+ // On iOS this will select the closest word edge, in this case the cursor is placed
+ // at the end of the word 'Bonaventure|'.
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+ // Second tap toggles the toolbar. Here we tap on 'v' again, and select the word edge. Since
+ // the selection has not changed we toggle the toolbar.
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+
+ // Tap the 'v' position again to hide the toolbar.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Long press at the end of the first line to move the cursor to the end of the first line
+ // where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR,
+ // the TextAffinity will be upstream and against the natural direction. The toolbar is also
+ // shown after a long press.
+ await tester.longPressAt(endPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.upstream);
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+
+ // Tap at the same position to toggle the toolbar.
+ await tester.tapAt(endPos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.upstream);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Tap at the beginning of the second line to move the cursor to the front of the first word on the
+ // second line, where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR,
+ // the TextAffinity will be downstream and following the natural direction. The toolbar will be hidden after this tap.
+ await tester.tapAt(begPos + Offset(0.0, lineHeight));
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.downstream);
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
+ );
+
+ testWidgets(
+ 'Tapping on a non-collapsed selection toggles the toolbar and retains the selection',
+ (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(
+ text: 'Atwater Peel Sherbrooke Bonaventure',
+ );
+ // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoTextField(
+ controller: controller,
+ ),
+ ),
+ ),
+ );
+
+ final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'.
+ final Offset ePos = textOffsetToPosition(tester, 35) + const Offset(7.0, 0.0); // Index of 'Bonaventure|' + Offset(7.0,0), which taps slightly to the right of the end of the text.
+ final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'.
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(wPos);
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(vPos);
+ await tester.pump(const Duration(milliseconds: 50));
+ // First tap moved the cursor.
+ expect(controller.selection.isCollapsed, true);
+ expect(
+ controller.selection.baseOffset,
+ 35,
+ );
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+
+ // Second tap selects the word around the cursor.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+
+ // Selected text shows 3 toolbar buttons.
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+
+ // Tap the selected word to hide the toolbar and retain the selection.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Tap the selected word to show the toolbar and retain the selection.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+
+ // Tap past the selected word to move the cursor and hide the toolbar.
+ await tester.tapAt(ePos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
+ );
+
+ testWidgets(
'double tap selects word and first tap of double tap moves cursor',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
@@ -2402,9 +2561,6 @@
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
- // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
- // On macOS, we select the precise position of the tap.
- final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
await tester.pumpWidget(
CupertinoApp(
home: Center(
@@ -2423,10 +2579,9 @@
await tester.tapAt(ePos);
await tester.pump();
- // We ended up moving the cursor to the edge of the same word and dismissed
- // the toolbar.
+ // The cursor does not move and the toolbar is toggled.
expect(controller.selection.isCollapsed, isTrue);
- expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6);
+ expect(controller.selection.baseOffset, 6);
// The toolbar from the long press is now dismissed by the second tap.
expect(find.byType(CupertinoButton), findsNothing);
@@ -2703,11 +2858,13 @@
// Double tap selecting the same word somewhere else is fine.
await tester.tapAt(textFieldStart + const Offset(100.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
- // First tap moved the cursor.
+ // First tap hides the toolbar, and retains the selection.
expect(
controller.selection,
- const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ const TextSelection(baseOffset: 0, extentOffset: 7),
);
+ expect(find.byType(CupertinoButton), findsNothing);
+ // Second tap shows the toolbar, and retains the selection.
await tester.tapAt(textFieldStart + const Offset(100.0, 5.0));
await tester.pumpAndSettle();
expect(
@@ -2718,11 +2875,12 @@
await tester.tapAt(textFieldStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
- // First tap moved the cursor.
+ // First tap moved the cursor and hides the toolbar.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8),
);
+ expect(find.byType(CupertinoButton), findsNothing);
await tester.tapAt(textFieldStart + const Offset(150.0, 5.0));
await tester.pumpAndSettle();
expect(
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 97242ac..170e8d3 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -2953,6 +2953,55 @@
expect(find.text('Cut'), findsNothing);
}, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu.
+ testWidgets('create selection overlay if none exists when toggleToolbar is called', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/111660
+ final Widget testWidget = MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ title: const Text('Test'),
+ actions: <Widget>[
+ PopupMenuButton<String>(
+ itemBuilder: (BuildContext context) {
+ return <String>{'About'}.map((String value) {
+ return PopupMenuItem<String>(
+ value: value,
+ child: Text(value),
+ );
+ }).toList();
+ },
+ ),
+ ],
+ ),
+ body: const TextField(),
+ ),
+ );
+
+ await tester.pumpWidget(testWidget);
+
+ // Tap on TextField.
+ final Offset textFieldStart = tester.getTopLeft(find.byType(TextField));
+ final TestGesture gesture = await tester.startGesture(textFieldStart);
+ await tester.pump(const Duration(milliseconds: 300));
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ // Tap on 3 dot menu.
+ await tester.tap(find.byType(PopupMenuButton<String>));
+ await tester.pumpAndSettle();
+
+ // Tap on TextField.
+ await gesture.down(textFieldStart);
+ await tester.pump(const Duration(milliseconds: 300));
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ // Tap on TextField again.
+ await tester.tapAt(textFieldStart);
+ await tester.pumpAndSettle();
+
+ expect(tester.takeException(), isNull);
+ }, variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}));
+
testWidgets('TextField height with minLines unset', (WidgetTester tester) async {
await tester.pumpWidget(textFieldBuilder());
@@ -7550,13 +7599,176 @@
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6);
- // No toolbar.
- expect(find.byType(CupertinoButton), findsNothing);
+ // Toolbar shows on iOS.
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : isTargetPlatformMobile ? findsNWidgets(2) : findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
+ 'Tapping on a collapsed selection toggles the toolbar',
+ (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(
+ text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
+ );
+ // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Center(
+ child: TextField(
+ controller: controller,
+ maxLines: 2,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final double lineHeight = findRenderEditable(tester).preferredLineHeight;
+ final Offset begPos = textOffsetToPosition(tester, 0);
+ final Offset endPos = textOffsetToPosition(tester, 35) + const Offset(200.0, 0.0); // Index of 'Bonaventure|' + Offset(200.0,0), which is at the end of the first line.
+ final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'.
+ final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'.
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(wPos);
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(vPos);
+ await tester.pump(const Duration(milliseconds: 500));
+ // First tap moved the cursor. Here we tap the position where 'v' is located.
+ // On iOS this will select the closest word edge, in this case the cursor is placed
+ // at the end of the word 'Bonaventure|'.
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+ // Second tap toggles the toolbar. Here we tap on 'v' again, and select the word edge. Since
+ // the selection has not changed we toggle the toolbar.
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+
+ // Tap the 'v' position again to hide the toolbar.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Long press at the end of the first line to move the cursor to the end of the first line
+ // where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR,
+ // the TextAffinity will be upstream and against the natural direction. The toolbar is also
+ // shown after a long press.
+ await tester.longPressAt(endPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.upstream);
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+
+ // Tap at the same position to toggle the toolbar.
+ await tester.tapAt(endPos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.upstream);
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Tap at the beginning of the second line to move the cursor to the front of the first word on the
+ // second line, where the word wrap is. Since there is a word wrap here, and the direction of the text is LTR,
+ // the TextAffinity will be downstream and following the natural direction. The toolbar will be hidden after this tap.
+ await tester.tapAt(begPos + Offset(0.0, lineHeight));
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 46);
+ expect(controller.selection.affinity, TextAffinity.downstream);
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
+ );
+
+ testWidgets(
+ 'Tapping on a non-collapsed selection toggles the toolbar and retains the selection',
+ (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(
+ text: 'Atwater Peel Sherbrooke Bonaventure',
+ );
+ // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Center(
+ child: TextField(
+ controller: controller,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final Offset vPos = textOffsetToPosition(tester, 29); // Index of 'Bonav|enture'.
+ final Offset ePos = textOffsetToPosition(tester, 35) + const Offset(7.0, 0.0); // Index of 'Bonaventure|' + Offset(7.0,0), which taps slightly to the right of the end of the text.
+ final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'.
+
+ // This tap just puts the cursor somewhere different than where the double
+ // tap will occur to test that the double tap moves the existing cursor first.
+ await tester.tapAt(wPos);
+ await tester.pump(const Duration(milliseconds: 500));
+
+ await tester.tapAt(vPos);
+ await tester.pump(const Duration(milliseconds: 50));
+ // First tap moved the cursor.
+ expect(controller.selection.isCollapsed, true);
+ expect(
+ controller.selection.baseOffset,
+ 35,
+ );
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle(const Duration(milliseconds: 500));
+
+ // Second tap selects the word around the cursor.
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+
+ // Selected text shows 3 toolbar buttons.
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+
+ // Tap the selected word to hide the toolbar and retain the selection.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+ expect(find.byType(CupertinoButton), findsNothing);
+
+ // Tap the selected word to show the toolbar and retain the selection.
+ await tester.tapAt(vPos);
+ await tester.pumpAndSettle();
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 24, extentOffset: 35),
+ );
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+
+ // Tap past the selected word to move the cursor and hide the toolbar.
+ await tester.tapAt(ePos);
+ await tester.pumpAndSettle();
+ expect(controller.selection.isCollapsed, true);
+ expect(controller.selection.baseOffset, 35);
+ expect(find.byType(CupertinoButton), findsNothing);
+ },
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
+ );
+
+ testWidgets(
'double tap selects word and first tap of double tap moves cursor',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
@@ -8191,9 +8403,6 @@
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
- // On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
- // On macOS, we select the precise position of the tap.
- final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
await tester.pumpWidget(
MaterialApp(
home: Material(
@@ -8210,16 +8419,14 @@
await tester.longPressAt(ePos);
await tester.pump(const Duration(milliseconds: 50));
-
await tester.tapAt(ePos);
await tester.pump();
- // We ended up moving the cursor to the edge of the same word and dismissed
- // the toolbar.
+ // The cursor does not move and the toolbar is toggled.
expect(controller.selection.isCollapsed, isTrue);
- expect(controller.selection.baseOffset, isTargetPlatformMobile ? 7 : 6);
+ expect(controller.selection.baseOffset, 6);
- // Collapsed toolbar shows 2 buttons.
+ // The toolbar from the long press is now dismissed by the second tap.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
@@ -8944,11 +9151,13 @@
// Double tap selecting the same word somewhere else is fine.
await tester.tapAt(textfieldStart + const Offset(100.0, 9.0));
await tester.pump(const Duration(milliseconds: 50));
- // First tap moved the cursor.
+ // First tap hides the toolbar and retains the selection.
expect(
controller.selection,
- const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
+ const TextSelection(baseOffset: 0, extentOffset: 7),
);
+ expect(find.byType(CupertinoButton), findsNothing);
+ // Second tap shows the toolbar and retains the selection.
await tester.tapAt(textfieldStart + const Offset(100.0, 9.0));
await tester.pumpAndSettle();
expect(
@@ -8959,11 +9168,12 @@
await tester.tapAt(textfieldStart + const Offset(150.0, 9.0));
await tester.pump(const Duration(milliseconds: 50));
- // First tap moved the cursor.
+ // First tap moved the cursor and hid the toolbar.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8),
);
+ expect(find.byType(CupertinoButton), findsNothing);
await tester.tapAt(textfieldStart + const Offset(150.0, 9.0));
await tester.pumpAndSettle();
expect(
diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart
index 72fd86a..e83c245 100644
--- a/packages/flutter/test/widgets/text_selection_test.dart
+++ b/packages/flutter/test/widgets/text_selection_test.dart
@@ -570,6 +570,38 @@
}
}, variant: TargetPlatformVariant.all());
+ testWidgets('test TextSelectionGestureDetectorBuilder toggles toolbar on single tap on previous selection iOS', (WidgetTester tester) async {
+ await pumpTextSelectionGestureDetectorBuilder(tester);
+
+ final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
+ final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
+ expect(state.showToolbarCalled, isFalse);
+ expect(state.toggleToolbarCalled, isFalse);
+ renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6);
+ renderEditable.hasFocus = true;
+
+ final TestGesture gesture = await tester.startGesture(
+ const Offset(25.0, 200.0),
+ pointer: 0,
+ );
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.iOS:
+ expect(renderEditable.selectWordEdgeCalled, isFalse);
+ expect(state.toggleToolbarCalled, isTrue);
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(renderEditable.selectPositionAtCalled, isTrue);
+ break;
+ }
+ }, variant: TargetPlatformVariant.all());
+
testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final TestGesture gesture = await tester.startGesture(
@@ -1519,6 +1551,7 @@
class FakeEditableTextState extends EditableTextState {
final GlobalKey _editableKey = GlobalKey();
bool showToolbarCalled = false;
+ bool toggleToolbarCalled = false;
@override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
@@ -1530,7 +1563,8 @@
}
@override
- void toggleToolbar() {
+ void toggleToolbar([bool hideHandles = true]) {
+ toggleToolbarCalled = true;
return;
}