From 40b5dfde7e63c4c584b9037d7a0f6513e8928eeb Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 12:42:53 -0700 Subject: [PATCH 1/7] fix edge scrolling for mobile on material TextField --- .../flutter/lib/src/material/text_field.dart | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 992dccd731bff..23ab4423f82f1 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -49,6 +49,23 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete final _TextFieldState _state; + /// The viewport offset pixels of the [RenderEditable] at the last drag start. + double _dragStartViewportOffset = 0.0; + + /// The viewport offset pixels of any [Scrollable] containing the + /// [RenderEditable] at the last drag start. + double _dragStartScrollOffset = 0.0; + + double get _scrollPosition { + final ScrollableState? scrollableState = + delegate.editableTextKey.currentContext == null + ? null + : Scrollable.of(delegate.editableTextKey.currentContext!); + return scrollableState == null + ? 0.0 + : scrollableState.position.pixels; + } + @override void onForcePressStart(ForcePressDetails details) { super.onForcePressStart(details); @@ -67,6 +84,15 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete if (delegate.selectionEnabled) { final TargetPlatform targetPlatform = Theme.of(_state.context).platform; + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = Offset( + 0.0, + _scrollPosition - _dragStartScrollOffset, + ); + switch (targetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: @@ -80,7 +106,7 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete case TargetPlatform.linux: case TargetPlatform.windows: renderEditable.selectWordsInRange( - from: details.globalPosition - details.offsetFromOrigin, + from: details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset, to: details.globalPosition, cause: SelectionChangedCause.longPress, ); @@ -142,6 +168,10 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete case TargetPlatform.windows: break; } + + + _dragStartViewportOffset = renderEditable.offset.pixels; + _dragStartScrollOffset = _scrollPosition; } } } @@ -1151,16 +1181,12 @@ class _TextFieldState extends State with RestorationMixin implements switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - if (cause == SelectionChangedCause.longPress - || cause == SelectionChangedCause.drag) { - _editableText?.bringIntoView(selection.extent); - } - break; case TargetPlatform.linux: case TargetPlatform.windows: case TargetPlatform.fuchsia: case TargetPlatform.android: - if (cause == SelectionChangedCause.drag) { + if (cause == SelectionChangedCause.longPress + || cause == SelectionChangedCause.drag) { _editableText?.bringIntoView(selection.extent); } break; From 84f983f7847fd880da9131c712ba9d784df0b589 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 13:28:38 -0700 Subject: [PATCH 2/7] Add test --- .../test/material/text_field_test.dart | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 97242ac16cb89..15b7ddc7470c2 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -8293,7 +8293,95 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); - testWidgets('long press drag can edge scroll', (WidgetTester tester) async { + 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', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + controller: controller, + ), + ), + ), + ), + ); + + 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); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + expect(find.byType(TextButton), 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(TextButton), 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(TextButton), 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.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); + + testWidgets('long press drag can edge scroll on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', ); From b34b32d4dcdbf040a156f36b99ff2f88fd4541c3 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 14:15:27 -0700 Subject: [PATCH 3/7] fix tests, still need to make new test for cupertino --- .../flutter/lib/src/cupertino/text_field.dart | 8 +- .../flutter/lib/src/material/text_field.dart | 91 +------------------ .../lib/src/widgets/text_selection.dart | 54 +++++++++-- .../test/cupertino/text_field_test.dart | 25 +++-- .../test/material/text_field_test.dart | 2 +- 5 files changed, 70 insertions(+), 110 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index a639739728b18..6ef80f31d2466 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -1036,16 +1036,12 @@ class _CupertinoTextFieldState extends State with Restoratio switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - if (cause == SelectionChangedCause.longPress - || cause == SelectionChangedCause.drag) { - _editableText.bringIntoView(selection.extent); - } - break; case TargetPlatform.linux: case TargetPlatform.windows: case TargetPlatform.fuchsia: case TargetPlatform.android: - if (cause == SelectionChangedCause.drag) { + if (cause == SelectionChangedCause.longPress + || cause == SelectionChangedCause.drag) { _editableText.bringIntoView(selection.extent); } break; diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 23ab4423f82f1..529bcfaeb1c9b 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -49,23 +49,6 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete final _TextFieldState _state; - /// The viewport offset pixels of the [RenderEditable] at the last drag start. - double _dragStartViewportOffset = 0.0; - - /// The viewport offset pixels of any [Scrollable] containing the - /// [RenderEditable] at the last drag start. - double _dragStartScrollOffset = 0.0; - - double get _scrollPosition { - final ScrollableState? scrollableState = - delegate.editableTextKey.currentContext == null - ? null - : Scrollable.of(delegate.editableTextKey.currentContext!); - return scrollableState == null - ? 0.0 - : scrollableState.position.pixels; - } - @override void onForcePressStart(ForcePressDetails details) { super.onForcePressStart(details); @@ -79,54 +62,6 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete // Not required. } - @override - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (delegate.selectionEnabled) { - final TargetPlatform targetPlatform = Theme.of(_state.context).platform; - - // Adjust the drag start offset for possible viewport offset changes. - final Offset editableOffset = renderEditable.maxLines == 1 - ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) - : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); - final Offset scrollableOffset = Offset( - 0.0, - _scrollPosition - _dragStartScrollOffset, - ); - - switch (targetPlatform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - renderEditable.selectWordsInRange( - from: details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset, - to: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); - break; - } - - switch (targetPlatform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - editableText.showMagnifier(details.globalPosition); - break; - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - break; - } - } - } - @override void onSingleTapUp(TapUpDetails details) { editableText.hideToolbar(); @@ -137,41 +72,19 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete @override void onSingleLongTapStart(LongPressStartDetails details) { + super.onSingleLongTapStart(details); if (delegate.selectionEnabled) { - final TargetPlatform targetPlatform = Theme.of(_state.context).platform; - - switch (targetPlatform) { + switch (Theme.of(_state.context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); break; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - renderEditable.selectWord(cause: SelectionChangedCause.longPress); Feedback.forLongPress(_state.context); break; } - - switch (targetPlatform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - editableText.showMagnifier(details.globalPosition); - break; - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - break; - } - - - _dragStartViewportOffset = renderEditable.offset.pixels; - _dragStartScrollOffset = _scrollPosition; } } } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index df987b4adc715..44b526ab405a6 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1950,10 +1950,21 @@ class TextSelectionGestureDetectorBuilder { @protected void onSingleLongTapStart(LongPressStartDetails details) { if (delegate.selectionEnabled) { - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, - ); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + break; + } switch (defaultTargetPlatform) { case TargetPlatform.android: @@ -1966,6 +1977,9 @@ class TextSelectionGestureDetectorBuilder { case TargetPlatform.windows: break; } + + _dragStartViewportOffset = renderEditable.offset.pixels; + _dragStartScrollOffset = _scrollPosition; } } @@ -1981,11 +1995,35 @@ class TextSelectionGestureDetectorBuilder { @protected void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { if (delegate.selectionEnabled) { - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.longPress, + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = Offset( + 0.0, + _scrollPosition - _dragStartScrollOffset, ); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWordsInRange( + from: details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + break; + } + switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.iOS: @@ -2024,6 +2062,8 @@ class TextSelectionGestureDetectorBuilder { if (shouldShowSelectionToolbar) { editableText.showToolbar(); } + _dragStartViewportOffset = 0.0; + _dragStartScrollOffset = 0.0; } /// Handler for [TextSelectionGestureDetector.onSecondaryTap]. diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index d719758c97a07..ba7eacf4d8310 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1561,7 +1561,8 @@ void main() { expect(text.style!.fontWeight, FontWeight.w400); }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. - testWidgets('text field toolbar options correctly changes options', (WidgetTester tester) async { + // TODO(Renzo-Olivares): Test for non-Apple platforms. + testWidgets('text field toolbar options correctly changes options on Apple Platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', ); @@ -1602,7 +1603,10 @@ void main() { expect(find.text('Copy'), findsOneWidget); expect(find.text('Cut'), findsNothing); expect(find.text('Select All'), findsNothing); - }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + skip: isContextMenuProvidedByPlatform, + ); // [intended] only applies to platforms where we supply the context menu. testWidgets('Read only text field', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'readonly'); @@ -1757,8 +1761,9 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + // TODO(Renzo-Olivares): Make test for non-Apple platforms. testWidgets( - 'double tap selects word and first tap of double tap moves cursor', + 'double tap selects word and first tap of double tap moves cursor for Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -1795,6 +1800,7 @@ void main() { // Selected text shows 3 toolbar buttons. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -2364,8 +2370,9 @@ void main() { expect(find.text('Cut'), findsNothing); }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. + // TODO(Renzo-Olivares): Make test for non-Apple platforms. 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 on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -2394,6 +2401,7 @@ void main() { // Collapsed toolbar shows 2 buttons. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2)); }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -2432,8 +2440,9 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + // TODO(Renzo-Olivares): Make test for non-Apple platforms. testWidgets( - 'long press drag moves the cursor under the drag and shows toolbar on lift', + 'long press drag 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', @@ -2493,6 +2502,7 @@ void main() { // The toolbar now shows up. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2)); }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets('long press drag can edge scroll', (WidgetTester tester) async { @@ -6105,7 +6115,8 @@ void main() { expect(find.byKey(fakeMagnifier.key!), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); - testWidgets('Can long press to show, unshow, and update magnifier', (WidgetTester tester) async { + // TODO(Renzo-Olivares): write test for non-Apple platform. + testWidgets('Can long press to show, unshow, and update magnifier on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; await tester.pumpWidget( @@ -6163,7 +6174,7 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(fakeMagnifier.key!), findsNothing); - }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); group('TapRegion integration', () { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 15b7ddc7470c2..ab58c70d4e3fc 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2360,7 +2360,7 @@ void main() { break; } }, - variant: TargetPlatformVariant.all(), + variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { From 9ba2a8e6a930c8a74a2885f24e0ca79b9a2882fc Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 15:03:14 -0700 Subject: [PATCH 4/7] Add missing tests --- .../test/cupertino/text_field_test.dart | 341 +++++++++++++++++- 1 file changed, 335 insertions(+), 6 deletions(-) diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index ba7eacf4d8310..d5328e8c18a71 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1561,7 +1561,6 @@ void main() { expect(text.style!.fontWeight, FontWeight.w400); }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. - // TODO(Renzo-Olivares): Test for non-Apple platforms. testWidgets('text field toolbar options correctly changes options on Apple Platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -1608,6 +1607,52 @@ void main() { skip: isContextMenuProvidedByPlatform, ); // [intended] only applies to platforms where we supply the context menu. + testWidgets('text field toolbar options correctly changes options on non-Apple Platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Column( + children: [ + CupertinoTextField( + controller: controller, + toolbarOptions: const ToolbarOptions(copy: true), + ), + ], + ), + ), + ); + + // Long press to select 'Atwater' + const int index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + // Double tap on the same location to select the word around the cursor. + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + // Selected text shows 'Copy'. + expect(find.text('Paste'), findsNothing); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Cut'), findsNothing); + expect(find.text('Select All'), findsNothing); + }, + variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), + skip: isContextMenuProvidedByPlatform, + ); // [intended] only applies to platforms where we supply the context menu. + testWidgets('Read only text field', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'readonly'); @@ -1761,7 +1806,52 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); - // TODO(Renzo-Olivares): Make test for non-Apple platforms. + testWidgets( + 'double tap selects word and first tap of double tap moves cursor for non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + // Long press to select 'Atwater'. + const int index = 3; + await tester.longPressAt(textOffsetToPosition(tester, index)); + await tester.pump(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + // Double tap in the middle of 'Peel' to select the word. + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(textOffsetToPosition(tester, 10)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 8, extentOffset: 12), + ); + + // Selected text shows 3 toolbar buttons. + expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); + + // Tap somewhere else to move the cursor. + await tester.tapAt(textOffsetToPosition(tester, index)); + await tester.pumpAndSettle(); + expect(controller.selection, const TextSelection.collapsed(offset: index)); + }, + variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS}), + ); + testWidgets( 'double tap selects word and first tap of double tap moves cursor for Apple platforms', (WidgetTester tester) async { @@ -2370,7 +2460,39 @@ void main() { expect(find.text('Cut'), findsNothing); }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. - // TODO(Renzo-Olivares): Make test for non-Apple platforms. + testWidgets( + 'long press selects the word at the long press position and shows toolbar on non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + await tester.longPressAt(textFieldStart + const Offset(50.0, 5.0)); + await tester.pumpAndSettle(); + + // Select word, 'Atwater, on long press. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + + // non-Collapsed toolbar shows 3 buttons. + expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); + }, + variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), + ); + testWidgets( 'long press moves cursor to the exact long press position and shows toolbar on Apple platforms', (WidgetTester tester) async { @@ -2440,7 +2562,70 @@ void main() { expect(find.byType(CupertinoButton), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); - // TODO(Renzo-Olivares): Make test for non-Apple platforms. + testWidgets( + 'long press drag selects word by word and shows toolbar on lift on non-Apple platforms', + (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + final TestGesture gesture = + await tester.startGesture(textFieldStart + const Offset(50.0, 5.0)); + await tester.pump(const Duration(milliseconds: 500)); + + // Long press on non-Apple platforms selects the word at the long press position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), + ); + // Toolbar only shows up on long press up. + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + + // The selection is extended word by word to the drag position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 12, affinity: TextAffinity.upstream), + ); + expect(find.byType(CupertinoButton), findsNothing); + + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + + // The selection is extended word by word to the drag position. + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream), + ); + 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, affinity: TextAffinity.upstream), + ); + // The toolbar now shows up. + expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); + }, + variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), + ); + testWidgets( 'long press drag moves the cursor under the drag and shows toolbar on lift on Apple platforms', (WidgetTester tester) async { @@ -2505,7 +2690,93 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); - testWidgets('long press drag can edge scroll', (WidgetTester tester) async { + 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', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + 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, moreOrLessEquals(1094.73, epsilon: 0.25)); + + final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField)); + + 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(950, 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(786.73, 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(-308.20, epsilon: 1)); + }, variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('long press drag can edge scroll on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', ); @@ -6115,7 +6386,65 @@ void main() { expect(find.byKey(fakeMagnifier.key!), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); - // TODO(Renzo-Olivares): write test for non-Apple platform. + testWidgets('Can long press to show, unshow, and update magnifier on non-Apple platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + magnifierConfiguration: TextMagnifierConfiguration( + magnifierBuilder: ( + _, + MagnifierController controller, + ValueNotifier localMagnifierInfo + ) { + magnifierInfo = localMagnifierInfo; + return fakeMagnifier; + }, + ), + ), + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + await tester.pumpAndSettle(); + + // Tap at 'e' to move the cursor before the 'e'. + await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, isTargetPlatformAndroid ? 5 : 4); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + + // Long press the 'e' to select 'def' and show the magnifier. + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, testValue.indexOf('e'))); + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 7); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + + final Offset firstLongPressGesturePosition = magnifierInfo.value.globalGesturePosition; + + // Move the gesture to 'h' to extend the selection to 'ghi'. + await gesture.moveTo(textOffsetToPosition(tester, testValue.indexOf('h'))); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + expect(find.byKey(fakeMagnifier.key!), findsOneWidget); + // Expect the position the magnifier gets to have moved. + expect(firstLongPressGesturePosition, isNot(magnifierInfo.value.globalGesturePosition)); + + // End the long press to hide the magnifier. + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byKey(fakeMagnifier.key!), findsNothing); + }, variant: const TargetPlatformVariant({ TargetPlatform.android })); + testWidgets('Can long press to show, unshow, and update magnifier on Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; From 506032fd8ac54e926df64d0374889c3a100bb24e Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 15:20:04 -0700 Subject: [PATCH 5/7] fix analyzer --- packages/flutter/test/cupertino/text_field_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index d5328e8c18a71..89ac0554c7c2e 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1602,10 +1602,10 @@ void main() { expect(find.text('Copy'), findsOneWidget); expect(find.text('Cut'), findsNothing); expect(find.text('Select All'), findsNothing); - }, + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), - skip: isContextMenuProvidedByPlatform, - ); // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu. + ); testWidgets('text field toolbar options correctly changes options on non-Apple Platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -1648,10 +1648,10 @@ void main() { expect(find.text('Copy'), findsOneWidget); expect(find.text('Cut'), findsNothing); expect(find.text('Select All'), findsNothing); - }, + }, variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), - skip: isContextMenuProvidedByPlatform, - ); // [intended] only applies to platforms where we supply the context menu. + skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu. + ); testWidgets('Read only text field', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(text: 'readonly'); From 00ef48d453a690d42d54be68dacc4a2623ea49e8 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Fri, 7 Oct 2022 15:24:12 -0700 Subject: [PATCH 6/7] fix tests --- .../test/widgets/text_selection_test.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 72fd86af25768..85faf6e523b5b 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -433,7 +433,7 @@ void main() { expect(dragEndCount, 1); }); - testWidgets('test TextSelectionGestureDetectorBuilder long press', (WidgetTester tester) async { + testWidgets('test TextSelectionGestureDetectorBuilder long press on Apple Platforms', (WidgetTester tester) async { await pumpTextSelectionGestureDetectorBuilder(tester); final TestGesture gesture = await tester.startGesture( const Offset(200.0, 200.0), @@ -447,7 +447,23 @@ void main() { final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); expect(state.showToolbarCalled, isTrue); expect(renderEditable.selectPositionAtCalled, isTrue); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('test TextSelectionGestureDetectorBuilder long press on non-Apple Platforms', (WidgetTester tester) async { + await pumpTextSelectionGestureDetectorBuilder(tester); + 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(); + + final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); + final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); + expect(state.showToolbarCalled, isTrue); + expect(renderEditable.selectWordCalled, isTrue); + }, variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('TextSelectionGestureDetectorBuilder right click Apple platforms', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/80119 From 541d2c89e73c6dc17d1885ce1da9af9b42ec4fbf Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Mon, 24 Oct 2022 10:28:45 -0700 Subject: [PATCH 7/7] Address review comments --- .../test/cupertino/text_field_test.dart | 4 +- .../test/material/text_field_test.dart | 118 +++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 89ac0554c7c2e..b068bb8d5674a 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -2487,7 +2487,7 @@ void main() { const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream), ); - // non-Collapsed toolbar shows 3 buttons. + // Non-Collapsed toolbar shows 3 buttons. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); }, variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), @@ -6445,7 +6445,7 @@ void main() { expect(find.byKey(fakeMagnifier.key!), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.android })); - testWidgets('Can long press to show, unshow, and update magnifier on Apple platforms', (WidgetTester tester) async { + testWidgets('Can long press to show, unshow, and update magnifier on iOS', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; await tester.pumpWidget( diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index ab58c70d4e3fc..c448fa540cc9c 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2254,7 +2254,123 @@ void main() { expect(controller.selection.extentOffset, testValue.indexOf('g')); }); - testWidgets('Can drag handles to change selection', (WidgetTester tester) async { + testWidgets('Can drag handles to change selection on Apple platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + // Double tap the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + // The first tap. + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + // The second tap. + await gesture.down(ePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + + final RenderEditable renderEditable = findRenderEditable(tester); + List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // We use a small offset because the endpoint is on the very corner + // of the handle. + Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + Offset newHandlePos = textOffsetToPosition(tester, testValue.length); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + + // Drag the left handle 2 letters to the left. + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 2); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + // On Apple platforms, dragging the base handle makes it the extent. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 2); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 2); + expect(controller.selection.extentOffset, 11); + break; + } + + // Drag the left handle 2 letters to the left again. + endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + handlePos = endpoints[0].point + const Offset(-1.0, 1.0); + newHandlePos = textOffsetToPosition(tester, 0); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // The left handle was already the extent, and it remains so. + expect(controller.selection.baseOffset, 11); + expect(controller.selection.extentOffset, 0); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 11); + break; + } + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + + testWidgets('Can drag handles to change selection on non-Apple platforms', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); await tester.pumpWidget(