From 297a7c756546b4d5a78b40cfbb5d4fdc6a1b227c Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Tue, 16 Nov 2021 14:58:05 -0800 Subject: [PATCH] Desktop edge scrolling (#93170) --- .../flutter/lib/src/cupertino/text_field.dart | 21 +- .../flutter/lib/src/gestures/monodrag.dart | 8 +- .../flutter/lib/src/material/text_field.dart | 14 +- .../lib/src/widgets/text_selection.dart | 8 +- .../test/cupertino/text_field_test.dart | 2 +- .../test/material/text_field_test.dart | 372 +++++++++++++++++- 6 files changed, 409 insertions(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 663e5a1acb..623ce0fc5d 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -967,15 +967,30 @@ class _CupertinoTextFieldState extends State with Restoratio } void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { - if (cause == SelectionChangedCause.longPress) { - _editableText.bringIntoView(selection.base); - } final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); if (willShowSelectionHandles != _showSelectionHandles) { setState(() { _showSelectionHandles = willShowSelectionHandles; }); } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (cause == SelectionChangedCause.longPress + || cause == SelectionChangedCause.drag) { + _editableText.bringIntoView(selection.extent); + } + return; + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.drag) { + _editableText.bringIntoView(selection.extent); + } + return; + } } @override diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index a3aaeb3bf2..6385e881ad 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -614,7 +614,13 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer { /// some time has passed. class PanGestureRecognizer extends DragGestureRecognizer { /// Create a gesture recognizer for tracking movement on a plane. - PanGestureRecognizer({ Object? debugOwner }) : super(debugOwner: debugOwner); + PanGestureRecognizer({ + Object? debugOwner, + Set? supportedDevices, + }) : super( + debugOwner: debugOwner, + supportedDevices: supportedDevices, + ); @override bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 8e6dc27f5f..333b374368 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -1075,15 +1075,19 @@ class _TextFieldState extends State with RestorationMixin implements switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: - if (cause == SelectionChangedCause.longPress) { - _editableText?.bringIntoView(selection.base); + if (cause == SelectionChangedCause.longPress + || cause == SelectionChangedCause.drag) { + _editableText?.bringIntoView(selection.extent); } return; - case TargetPlatform.android: - case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - // Do nothing. + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.drag) { + _editableText?.bringIntoView(selection.extent); + } + return; } } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 9feb7fe024..c4c5154a44 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1527,11 +1527,9 @@ class _TextSelectionGestureDetectorState extends State( - () => HorizontalDragGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.mouse), - (HorizontalDragGestureRecognizer instance) { + gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(debugOwner: this, supportedDevices: { PointerDeviceKind.mouse }), + (PanGestureRecognizer instance) { instance // Text selection should start from the position of the first pointer // down event. diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 8d363efa8e..07bf6f279d 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -2414,7 +2414,7 @@ void main() { expect(firstCharEndpoint.length, 1); // The first character is now offscreen to the left. expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-308.20, epsilon: 0.25)); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'long tap after a double tap select is not affected', diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 5306d3c941..887481196b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -7794,9 +7794,379 @@ void main() { ); expect(firstCharEndpoint.length, 1); // The first character is now offscreen to the left. - expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257, epsilon: 1)); + expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('mouse click and drag can edge scroll', (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, + ), + ), + ), + ), + ); + + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(textOffsetToPosition(tester, 66).dx, 1056); + + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 19), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + + await gesture.moveTo(textOffsetToPosition(tester, 56)); + // To the edge of the screen basically. + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 56), + ); + + // Keep moving out. + await gesture.moveTo(textOffsetToPosition(tester, 62)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 62), + ); + await gesture.moveTo(textOffsetToPosition(tester, 66)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 66), + ); // 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: 19, extentOffset: 66), + ); + + // The last character is now on screen near the right edge. + expect( + textOffsetToPosition(tester, 66).dx, + moreOrLessEquals(TestSemantics.fullScreen.width, epsilon: 2.0), + ); + + // The first character is now offscreen to the left. + expect(textOffsetToPosition(tester, 0).dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, variant: TargetPlatformVariant.all()); + + testWidgets('keyboard selection change scrolls the field', (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, + ), + ), + ), + ), + ); + + // Just testing the test and making sure that the last character is off + // the right side of the screen. + expect(textOffsetToPosition(tester, 66).dx, 1056); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 13), + ); + + // Move to position 56 with the right arrow (near the edge of the screen). + for (int i = 0; i < (56 - 13); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 56), + ); + + // Keep moving out. + for (int i = 0; i < (62 - 56); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 62), + ); + for (int i = 0; i < (66 - 62); i += 1) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 66), + ); // We're at the edge now. + + await tester.pumpAndSettle(); + + // The last character is now on screen near the right edge. + expect( + textOffsetToPosition(tester, 66).dx, + moreOrLessEquals(TestSemantics.fullScreen.width, epsilon: 2.0), + ); + + // The first character is now offscreen to the left. + expect(textOffsetToPosition(tester, 0).dx, moreOrLessEquals(-257.0, epsilon: 1)); + }, variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] Browser handles arrow keys differently. + ); + + testWidgets('long press drag can edge scroll vertically', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neigse Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + maxLines: 2, + controller: controller, + ), + ), + ), + ), + ); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + // Start long pressing on the first line. + final TestGesture gesture = + await tester.startGesture(textOffsetToPosition(tester, 19)); + // TODO(justinmc): Make sure you've got all things torn down. + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + expect( + controller.selection, + const TextSelection.collapsed(offset: 19), + ); + await tester.pumpAndSettle(); + + // Move down to the second line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 65), + ); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 65).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Keep selecting down to the third and final line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 110), + ); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, 110).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('keyboard selection change scrolls the field vertically', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + maxLines: 2, + controller: controller, + ), + ), + ), + ), + ); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 13), + ); + + // Move down to the second line. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 59), + ); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 66).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Move down to the third and final line. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection.collapsed(offset: 104), + ); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + }, variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] Browser handles arrow keys differently. + ); + + testWidgets('mouse click and drag can edge scroll vertically', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField( + maxLines: 2, + controller: controller, + ), + ), + ), + ), + ); + + // Just testing the test and making sure that the last character is outside + // the bottom of the field. + final int textLength = controller.text.length; + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + final double firstCharY = textOffsetToPosition(tester, 0).dy; + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight * 2, epsilon: 1), + ); + + // Start selecting on the first line. + final TestGesture gesture = + await tester.startGesture( + textOffsetToPosition(tester, 19), + pointer: 7, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 60).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Select down to the second line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 65), + ); + + // Still hasn't scrolled. + expect( + textOffsetToPosition(tester, 60).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Keep selecting down to the third and final line. + await gesture.moveBy(Offset(0.0, lineHeight)); + await tester.pumpAndSettle(); + expect( + controller.selection, + const TextSelection(baseOffset: 19, extentOffset: 110), + ); + + // The last character is no longer three line heights down from the top of + // the field, it's now only two line heights down, because it has scrolled + // down by one line. + expect( + textOffsetToPosition(tester, textLength).dy, + moreOrLessEquals(firstCharY + lineHeight, epsilon: 1), + ); + + // Likewise, the first character is now scrolled out of the top of the field + // by one line. + expect( + textOffsetToPosition(tester, 0).dy, + moreOrLessEquals(firstCharY - lineHeight, epsilon: 1), + ); + }, variant: TargetPlatformVariant.all()); + testWidgets( 'long tap after a double tap select is not affected', (WidgetTester tester) async {