Skip to content

Commit d81c8aa

Browse files
SelectionArea long press selection overlay behavior should match native (#133967)
During a long press, on native iOS the context menu does not show until the long press has ended. The handles are shown immediately when the long press begins. This is true for static and editable text. For static text on Android, the context menu appears when the long press is initiated, but the handles do not appear until the long press has ended. For editable text on Android, the context menu does not appear until the long press ended, and the handles also do not appear until the end. For both platforms in editable/static contexts the context menu does not show while doing a long press drag. I think the behavior where the context menu is not shown until the long press ends makes the most sense even though Android varies in this depending on the context. The user is not able to react to the context menu until the long press has ended. Other details: On a windows touch screen device the context menu does not show up until the long press ends in editable/static text contexts. On a long press hold it selects the word on drag start as well as popping up the selection handles (static text).
1 parent c21bf45 commit d81c8aa

4 files changed

Lines changed: 117 additions & 2 deletions

File tree

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,8 +539,12 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
539539
HapticFeedback.selectionClick();
540540
widget.focusNode.requestFocus();
541541
_selectWordAt(offset: details.globalPosition);
542-
_showToolbar();
543-
_showHandles();
542+
// Platforms besides Android will show the text selection handles when
543+
// the long press is initiated. Android shows the text selection handles when
544+
// the long press has ended, usually after a pointer up event is received.
545+
if (defaultTargetPlatform != TargetPlatform.android) {
546+
_showHandles();
547+
}
544548
_updateSelectedContentIfNeeded();
545549
}
546550

@@ -552,6 +556,10 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
552556
void _handleTouchLongPressEnd(LongPressEndDetails details) {
553557
_finalizeSelection();
554558
_updateSelectedContentIfNeeded();
559+
_showToolbar();
560+
if (defaultTargetPlatform == TargetPlatform.android) {
561+
_showHandles();
562+
}
555563
}
556564

557565
bool _positionIsOnActiveSelection({required Offset globalPosition}) {

packages/flutter/test/material/selection_area_test.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ void main() {
151151
await tester.pump(const Duration(milliseconds: 500));
152152
// `are` is selected.
153153
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
154+
155+
await gesture.up();
154156
await tester.pumpAndSettle();
155157

156158
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
@@ -189,6 +191,8 @@ void main() {
189191
await tester.pump(const Duration(milliseconds: 500));
190192
// `are` is selected.
191193
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
194+
195+
await gesture.up();
192196
await tester.pumpAndSettle();
193197

194198
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
@@ -262,6 +266,7 @@ void main() {
262266
await gesture.up();
263267
final List<TextBox> boxes = paragraph2.getBoxesForSelection(paragraph2.selections[0]);
264268
expect(boxes.length, 1);
269+
await tester.pumpAndSettle();
265270
// There is a selection now.
266271
// We check the presence of the copy button to make sure the selection toolbar
267272
// is showing.

packages/flutter/test/widgets/scrollable_selection_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ void main() {
561561
addTearDown(gesture.removePointer);
562562
await tester.pump(const Duration(milliseconds: 500));
563563
await gesture.up();
564+
await tester.pumpAndSettle();
564565
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
565566

566567
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);
@@ -619,6 +620,7 @@ void main() {
619620
addTearDown(gesture.removePointer);
620621
await tester.pump(const Duration(milliseconds: 500));
621622
await gesture.up();
623+
await tester.pumpAndSettle();
622624
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
623625

624626
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);
@@ -674,6 +676,7 @@ void main() {
674676
addTearDown(gesture.removePointer);
675677
await tester.pump(const Duration(milliseconds: 500));
676678
await gesture.up();
679+
await tester.pumpAndSettle();
677680
expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
678681

679682
final List<TextBox> boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]);

packages/flutter/test/widgets/selectable_region_test.dart

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,100 @@ void main() {
949949
await gesture.up();
950950
});
951951

952+
testWidgets(
953+
'long press selection overlay behavior on iOS and Android',
954+
(WidgetTester tester) async {
955+
// This test verifies that all platforms wait until long press end to
956+
// show the context menu, and only Android waits until long press end to
957+
// show the selection handles.
958+
final bool isPlatformAndroid = defaultTargetPlatform == TargetPlatform.android;
959+
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
960+
final UniqueKey toolbarKey = UniqueKey();
961+
await tester.pumpWidget(
962+
MaterialApp(
963+
home: SelectableRegion(
964+
focusNode: FocusNode(),
965+
selectionControls: materialTextSelectionHandleControls,
966+
contextMenuBuilder: (
967+
BuildContext context,
968+
SelectableRegionState selectableRegionState,
969+
) {
970+
buttonTypes = selectableRegionState.contextMenuButtonItems
971+
.map((ContextMenuButtonItem buttonItem) => buttonItem.type)
972+
.toSet();
973+
return SizedBox.shrink(key: toolbarKey);
974+
},
975+
child: const Text('How are you?'),
976+
),
977+
),
978+
);
979+
980+
expect(buttonTypes.isEmpty, true);
981+
expect(find.byKey(toolbarKey), findsNothing);
982+
983+
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
984+
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2));
985+
addTearDown(gesture.removePointer);
986+
await tester.pump(const Duration(milliseconds: 500));
987+
await tester.pumpAndSettle();
988+
989+
// All platform except Android should show the selection handles when the
990+
// long press starts.
991+
List<FadeTransition> transitions = find.descendant(
992+
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'),
993+
matching: find.byType(FadeTransition),
994+
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
995+
expect(transitions.length, isPlatformAndroid ? 0 : 2);
996+
FadeTransition? left;
997+
FadeTransition? right;
998+
if (!isPlatformAndroid) {
999+
left = transitions[0];
1000+
right = transitions[1];
1001+
expect(left.opacity.value, equals(1.0));
1002+
expect(right.opacity.value, equals(1.0));
1003+
}
1004+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
1005+
expect(find.byKey(toolbarKey), findsNothing);
1006+
1007+
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
1008+
await tester.pumpAndSettle();
1009+
transitions = find.descendant(
1010+
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'),
1011+
matching: find.byType(FadeTransition),
1012+
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
1013+
// All platform except Android should show the selection handles while doing
1014+
// a long press drag.
1015+
expect(transitions.length, isPlatformAndroid ? 0 : 2);
1016+
if (!isPlatformAndroid) {
1017+
left = transitions[0];
1018+
right = transitions[1];
1019+
expect(left.opacity.value, equals(1.0));
1020+
expect(right.opacity.value, equals(1.0));
1021+
}
1022+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
1023+
expect(find.byKey(toolbarKey), findsNothing);
1024+
1025+
await gesture.up();
1026+
await tester.pumpAndSettle();
1027+
transitions = find.descendant(
1028+
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionHandleOverlay'),
1029+
matching: find.byType(FadeTransition),
1030+
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
1031+
expect(transitions.length, 2);
1032+
left = transitions[0];
1033+
right = transitions[1];
1034+
1035+
// All platforms should show the selection handles and context menu when
1036+
// the long press ends.
1037+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
1038+
expect(left.opacity.value, equals(1.0));
1039+
expect(right.opacity.value, equals(1.0));
1040+
expect(find.byKey(toolbarKey), findsOneWidget);
1041+
},
1042+
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
1043+
skip: kIsWeb, // [intended] Web uses its native context menu.
1044+
);
1045+
9521046
testWidgetsWithLeakTracking(
9531047
'single tap on the previous selection toggles the toolbar on iOS',
9541048
(WidgetTester tester) async {
@@ -2695,6 +2789,9 @@ void main() {
26952789
// `are` is selected.
26962790
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
26972791
await tester.pumpAndSettle();
2792+
2793+
await gesture.up();
2794+
await tester.pumpAndSettle();
26982795
// Text selection toolbar has appeared.
26992796
expect(find.text('Copy'), findsOneWidget);
27002797

@@ -2862,6 +2959,8 @@ void main() {
28622959
await tester.pump(const Duration(milliseconds: 500));
28632960
// `are` is selected.
28642961
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
2962+
2963+
await gesture.up();
28652964
await tester.pumpAndSettle();
28662965

28672966
expect(buttonTypes, contains(ContextMenuButtonType.copy));

0 commit comments

Comments
 (0)