From 142dd6022f8fd1c43ea12384f355fb56ebe2e100 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Wed, 29 Mar 2023 13:22:03 -0700 Subject: [PATCH] Match iOS Longpress behavior with native (#123630) Match iOS Longpress behavior with native --- .../lib/src/widgets/text_selection.dart | 38 ++- .../test/cupertino/text_field_test.dart | 38 ++- .../test/cupertino/text_selection_test.dart | 12 + .../test/material/text_field_test.dart | 236 +++++++++++++++++- .../test/widgets/text_selection_test.dart | 26 +- 5 files changed, 331 insertions(+), 19 deletions(-) diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index fe864b872f0..619d3ed308d 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -2005,6 +2005,14 @@ class TextSelectionGestureDetectorBuilder { // cursor will not move on drag update. bool? _dragBeganOnPreviousSelection; + // For iOS long press behavior when the field is not focused. iOS uses this value + // to determine if a long press began on a field that was not focused. + // + // If the field was not focused when the long press began, a long press will select + // the word and a long press move will select word-by-word. If the field was + // focused, the cursor moves to the long press position. + bool _longPressStartedWithoutFocus = false; + /// Handler for [TextSelectionGestureDetector.onTapDown]. /// /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets @@ -2240,10 +2248,15 @@ class TextSelectionGestureDetectorBuilder { switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); + if (!renderEditable.hasFocus) { + _longPressStartedWithoutFocus = true; + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: @@ -2291,10 +2304,18 @@ class TextSelectionGestureDetectorBuilder { switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); + if (_longPressStartedWithoutFocus) { + renderEditable.selectWordsInRange( + from: details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: @@ -2342,6 +2363,7 @@ class TextSelectionGestureDetectorBuilder { if (shouldShowSelectionToolbar) { editableText.showToolbar(); } + _longPressStartedWithoutFocus = false; _dragStartViewportOffset = 0.0; _dragStartScrollOffset = 0.0; } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 47d06f0861c..85934b27915 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1591,6 +1591,7 @@ void main() { home: Column( children: [ CupertinoTextField( + autofocus: true, controller: controller, toolbarOptions: const ToolbarOptions(copy: true), ), @@ -1599,6 +1600,9 @@ void main() { ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Long press to put the cursor after the "w". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); @@ -2060,12 +2064,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Long press to put the cursor after the "w". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); @@ -2830,12 +2838,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); await tester.longPressAt(textFieldStart + const Offset(50.0, 5.0)); @@ -2870,12 +2882,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' await tester.longPressAt(ePos); @@ -2971,7 +2987,7 @@ void main() { ); testWidgets( - 'long press drag moves the cursor under the drag and shows toolbar on lift on Apple platforms', + 'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -2980,12 +2996,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); final TestGesture gesture = @@ -3135,12 +3155,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final RenderEditable renderEditable = tester.renderObject( find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last, ); @@ -3289,12 +3313,16 @@ void main() { CupertinoApp( home: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Use a position higher than wPos to avoid tapping the context menu on // desktop. final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel' @@ -7313,6 +7341,7 @@ void main() { child: Column( children: [ CupertinoTextField( + autofocus: true, key: const Key('field0'), controller: controller, style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)), @@ -7329,6 +7358,9 @@ void main() { ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); @@ -7363,6 +7395,7 @@ void main() { child: Column( children: [ CupertinoTextField( + autofocus: true, key: const Key('field0'), controller: controller, style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)), @@ -7378,6 +7411,9 @@ void main() { ), ); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); diff --git a/packages/flutter/test/cupertino/text_selection_test.dart b/packages/flutter/test/cupertino/text_selection_test.dart index b5283ab9140..ed653778571 100644 --- a/packages/flutter/test/cupertino/text_selection_test.dart +++ b/packages/flutter/test/cupertino/text_selection_test.dart @@ -200,6 +200,7 @@ void main() { data: const MediaQueryData(size: Size(800.0, 600.0)), child: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), @@ -207,6 +208,9 @@ void main() { ), )); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Initially, the menu isn't shown at all. expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); @@ -432,6 +436,7 @@ void main() { data: const MediaQueryData(size: Size(800.0, 600.0)), child: Center( child: CupertinoTextField( + autofocus: true, controller: controller, ), ), @@ -439,6 +444,9 @@ void main() { ), )); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Initially, the menu isn't shown at all. expect(find.text(_longLocalizations.cutButtonLabel), findsNothing); expect(find.text(_longLocalizations.copyButtonLabel), findsNothing); @@ -546,6 +554,7 @@ void main() { data: const MediaQueryData(size: Size(800.0, 600.0)), child: Center( child: CupertinoTextField( + autofocus: true, padding: const EdgeInsets.all(8.0), controller: controller, maxLines: 2, @@ -555,6 +564,9 @@ void main() { ), )); + // This extra pump is so autofocus can propagate to renderEditable. + await tester.pump(); + // Initially, the menu isn't shown at all. expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 54d24d89a61..168e62b266b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -10717,7 +10717,51 @@ void main() { ); testWidgets( - 'long press moves cursor to the exact long press position and shows toolbar', + 'long press moves cursor to the exact long press position and shows toolbar when the field is focused', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + autofocus: true, + controller: controller, + ), + ), + ), + ), + ); + + // This extra pump allows the selection set by autofocus to propagate to + // the RenderEditable. + await tester.pump(); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0)); + await tester.pumpAndSettle(); + + // Collapsed cursor for iOS long press. + expect( + controller.selection, + const TextSelection.collapsed(offset: 3), + ); + + // Collapsed toolbar shows 2 buttons. + final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1; + expect( + find.byType(CupertinoButton), + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons), + ); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + + testWidgets( + 'long press that starts on an unfocused TextField selects the word at the exact long press position and shows toolbar', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -10742,14 +10786,13 @@ void main() { // Collapsed cursor for iOS long press. expect( controller.selection, - const TextSelection.collapsed(offset: 3), + const TextSelection(baseOffset: 0, extentOffset: 7), ); - // Collapsed toolbar shows 2 buttons. - final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1; + // Collapsed toolbar shows 3 buttons. expect( find.byType(CupertinoButton), - isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons), + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3), ); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), @@ -10800,6 +10843,7 @@ void main() { home: Material( child: Center( child: TextField( + autofocus: true, controller: controller, ), ), @@ -10807,6 +10851,9 @@ void main() { ), ); + // This extra pump is so autofocus can propogate to renderEditable. + await tester.pump(); + final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' await tester.longPressAt(ePos); @@ -10910,7 +10957,7 @@ void main() { ); testWidgets( - 'long press drag moves the cursor under the drag and shows toolbar on lift', + 'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -10920,6 +10967,7 @@ void main() { home: Material( child: Center( child: TextField( + autofocus: true, controller: controller, ), ), @@ -10927,6 +10975,9 @@ void main() { ), ); + // This extra pump is so autofocus can propogate to renderEditable. + await tester.pump(); + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); final TestGesture gesture = @@ -10981,6 +11032,77 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); + testWidgets( + 'long press drag on an unfocused TextField selects word-by-word and shows toolbar on lift', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + + final TestGesture gesture = + await tester.startGesture(textfieldStart + const Offset(50.0, 9.0)); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on iOS shows collapsed selection cursor. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + // Cursor move doesn't trigger a toolbar initially. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 12), + ); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection position is now moved with the drag. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 23), + ); + // Still no toolbar. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 23), + ); + // The toolbar now shows up. + expect( + find.byType(CupertinoButton), + isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3), + ); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + testWidgets('long press drag can edge scroll on non-Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', @@ -11069,7 +11191,7 @@ void main() { expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); - testWidgets('long press drag can edge scroll on Apple platforms', (WidgetTester tester) async { + testWidgets('long press drag can edge scroll on Apple platforms - unfocused TextField', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', ); @@ -11098,6 +11220,98 @@ void main() { final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + final TestGesture gesture = + await tester.startGesture(textfieldStart); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(900, 5)); + // To the edge of the screen basically. + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 59), + ); + // Keep moving out. + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66), + ); + await gesture.moveBy(const Offset(1, 0)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); // We're at the edge now. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + // The selection isn't affected by the gesture lift. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); + + lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // The last character is now on screen near the right edge. + expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1)); + + final List firstCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 0), // First character's position. + ); + expect(firstCharEndpoint.length, 1); + // The first character is now offscreen to the left. + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('long press drag can edge scroll on Apple platforms - focused TextField', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + autofocus: true, + controller: controller, + ), + ), + ), + ), + ); + + // This extra pump is so autofocus can propogate to renderEditable. + await tester.pump(); + + final RenderEditable renderEditable = findRenderEditable(tester); + + List lastCharEndpoint = renderEditable.getEndpointsForSelection( + const TextSelection.collapsed(offset: 66), // Last character's position. + ); + + expect(lastCharEndpoint.length, 1); + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(lastCharEndpoint[0].point.dx, 1056); + + final Offset textfieldStart = tester.getTopLeft(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture(textfieldStart + const Offset(300, 5)); await tester.pump(const Duration(milliseconds: 500)); @@ -11309,6 +11523,7 @@ void main() { home: Material( child: Center( child: TextField( + autofocus: true, maxLines: 2, controller: controller, ), @@ -11317,6 +11532,9 @@ void main() { ), ); + // This extra pump is so autofocus can propogate to renderEditable. + await tester.pump(); + // Just testing the test and making sure that the last character is outside // the bottom of the field. final int textLength = controller.text.length; @@ -11595,6 +11813,7 @@ void main() { home: Material( child: Center( child: TextField( + autofocus: true, controller: controller, ), ), @@ -11602,6 +11821,9 @@ void main() { ), ); + // This extra pump is so autofocus can propogate to renderEditable. + await tester.pump(); + // The second tap is slightly higher to avoid tapping the context menu on // desktop. final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel' diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index f385aaa51eb..db31eefa8ec 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -460,8 +460,12 @@ void main() { expect(dragEndCount, 1); }); - testWidgets('test TextSelectionGestureDetectorBuilder long press on Apple Platforms', (WidgetTester tester) async { + testWidgets('test TextSelectionGestureDetectorBuilder long press on Apple Platforms - focused renderEditable', (WidgetTester tester) async { await pumpTextSelectionGestureDetectorBuilder(tester); + final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); + final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); + renderEditable.hasFocus = true; + final TestGesture gesture = await tester.startGesture( const Offset(200.0, 200.0), pointer: 0, @@ -470,13 +474,29 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); - final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); expect(state.showToolbarCalled, isTrue); expect(renderEditable.selectPositionAtCalled, isTrue); expect(renderEditable.lastCause, SelectionChangedCause.longPress); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('test TextSelectionGestureDetectorBuilder long press on iOS - renderEditable not focused', (WidgetTester tester) async { + await pumpTextSelectionGestureDetectorBuilder(tester); + final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); + final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); + + final TestGesture gesture = await tester.startGesture( + const Offset(200.0, 200.0), + pointer: 0, + ); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(state.showToolbarCalled, isTrue); + expect(renderEditable.selectWordCalled, isTrue); + expect(renderEditable.lastCause, SelectionChangedCause.longPress); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + testWidgets('test TextSelectionGestureDetectorBuilder long press on non-Apple Platforms', (WidgetTester tester) async { await pumpTextSelectionGestureDetectorBuilder(tester); final TestGesture gesture = await tester.startGesture(