Skip to content
1 change: 0 additions & 1 deletion packages/flutter/lib/src/cupertino/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe

@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,
Expand Down
1 change: 0 additions & 1 deletion packages/flutter/lib/src/material/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete

@override
void onSingleTapUp(TapUpDetails details) {
editableText.hideToolbar();
super.onSingleTapUp(details);
_state._requestKeyboard();
_state.widget.onTap?.call();
Expand Down
4 changes: 2 additions & 2 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3186,10 +3186,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}

/// Toggles the visibility of the toolbar.
void toggleToolbar() {
void toggleToolbar([bool hideHandles = true]) {
assert(_selectionOverlay != null);
if (_selectionOverlay!.toolbarIsVisible) {
hideToolbar();
hideToolbar(hideHandles);
} else {
showToolbar();
}
Expand Down
26 changes: 25 additions & 1 deletion packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1657,6 +1657,19 @@ class TextSelectionGestureDetectorBuilder {
&& renderEditable.selection!.end >= textPosition.offset;
}

bool _tapWasOnSelection(Offset position) {
if (renderEditable.selection == null) {
return false;
}

final TextPosition textPosition = renderEditable.getPositionForPoint(
position,
);

return renderEditable.selection!.start < textPosition.offset
&& renderEditable.selection!.end > textPosition.offset;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work if the selection is collapsed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talked offline about not handling ==.

The reason I didn't handle the == case is because say we have hello world with world selected, and we want to tap at the edge of world to collapse the selection and move the cursor to the end of the field. Because we handle == we can't do that and the selection stays on world.

}

// Expand the selection to the given global position.
//
// Either base or extent will be moved to the last tapped position, whichever
Expand Down Expand Up @@ -1887,13 +1900,15 @@ class TextSelectionGestureDetectorBuilder {
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;
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
editableText.hideToolbar();
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
Expand Down Expand Up @@ -1927,7 +1942,16 @@ class TextSelectionGestureDetectorBuilder {
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);
final TextSelection previousSelection = editableText.textEditingValue.selection;
// If the tap was within the previous selection, then the selection should stay the same.
if (!_tapWasOnSelection(details.globalPosition)) {
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
}
if (previousSelection == editableText.textEditingValue.selection && renderEditable.hasFocus) {
editableText.toggleToolbar(false);
} else {
editableText.hideToolbar(false);
}
break;
}
break;
Expand Down
89 changes: 84 additions & 5 deletions packages/flutter/test/cupertino/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1752,10 +1752,86 @@ void main() {
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 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,
const TextSelection.collapsed(offset: 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 {
Expand Down Expand Up @@ -2701,11 +2777,13 @@ void main() {
// 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(
Expand All @@ -2716,11 +2794,12 @@ void main() {

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(
Expand Down
91 changes: 86 additions & 5 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7323,12 +7323,90 @@ void main() {
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 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,
const TextSelection.collapsed(offset: 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 {
Expand Down Expand Up @@ -8717,11 +8795,13 @@ void main() {
// 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(
Expand All @@ -8732,11 +8812,12 @@ void main() {

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 hides 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(
Expand Down
36 changes: 35 additions & 1 deletion packages/flutter/test/widgets/text_selection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,38 @@ void main() {
}
}, 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(
Expand Down Expand Up @@ -1333,6 +1365,7 @@ class FakeEditableText extends EditableText {
class FakeEditableTextState extends EditableTextState {
final GlobalKey _editableKey = GlobalKey();
bool showToolbarCalled = false;
bool toggleToolbarCalled = false;

@override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
Expand All @@ -1344,7 +1377,8 @@ class FakeEditableTextState extends EditableTextState {
}

@override
void toggleToolbar() {
void toggleToolbar([bool hideHandles = true]) {
toggleToolbarCalled = true;
return;
}

Expand Down