diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index cef464db9ee1b..65806d1dd2583 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -1035,16 +1035,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 f477f6e3ebb6b..55ad075a84347 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -62,45 +62,6 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete // Not required. } - @override - void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { - if (delegate.selectionEnabled) { - final TargetPlatform targetPlatform = Theme.of(_state.context).platform; - - 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, - 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) { super.onSingleTapUp(details); @@ -110,37 +71,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; - } } } } @@ -1150,16 +1093,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; diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 9ec54dabd479b..dd308fb6627a8 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1996,10 +1996,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: @@ -2012,6 +2023,9 @@ class TextSelectionGestureDetectorBuilder { case TargetPlatform.windows: break; } + + _dragStartViewportOffset = renderEditable.offset.pixels; + _dragStartScrollOffset = _scrollPosition; } } @@ -2027,11 +2041,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: @@ -2070,6 +2108,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 9b6d3d365353c..fe2b8521a5b1f 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1561,7 +1561,7 @@ 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 { + 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 +1602,56 @@ 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('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'); @@ -1917,7 +1966,53 @@ void main() { ); 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 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 { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -1954,6 +2049,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( @@ -2524,7 +2620,40 @@ void main() { }, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu. testWidgets( - 'long press moves cursor to the exact long press position and shows toolbar', + '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 { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -2553,6 +2682,7 @@ void main() { // Collapsed toolbar shows 2 buttons. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2)); }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -2588,7 +2718,71 @@ void main() { }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( - 'long press drag moves the cursor under the drag and shows toolbar on lift', + '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 { final TextEditingController controller = TextEditingController( text: 'Atwater Peel Sherbrooke Bonaventure', @@ -2648,9 +2842,96 @@ 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 { + 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', ); @@ -6263,7 +6544,66 @@ 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 { + 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 iOS', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); final bool isTargetPlatformAndroid = defaultTargetPlatform == TargetPlatform.android; await tester.pumpWidget( @@ -6321,7 +6661,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 acf96dc9208a2..0b0fd27331199 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( @@ -2360,7 +2476,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 { @@ -8578,7 +8694,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', ); diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index e83c245d15a08..505c05a84898c 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