diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 5aac7565d457..496258736d82 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -1767,7 +1767,10 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { // fall through to the defaultShortcuts. child: DefaultTextEditingShortcuts( child: Actions( - actions: widget.actions ?? WidgetsApp.defaultActions, + actions: widget.actions ?? >{ + ...WidgetsApp.defaultActions, + ScrollIntent: Action.overridable(context: context, defaultAction: ScrollAction()), + }, child: FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), child: TapRegionSurface( diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 46d78c724f28..01f389e9f771 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -3,11 +3,13 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; import 'focus_traversal.dart'; import 'framework.dart'; +import 'scrollable.dart'; import 'shortcuts.dart'; import 'text_editing_intents.dart'; @@ -157,8 +159,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; - // These are shortcuts are shared between most platforms except macOS for it - // uses different modifier keys as the line/word modifier. + // These shortcuts are shared between all platforms except Apple platforms, + // because they use different modifier keys as the line/word modifier. static final Map _commonShortcuts = { // Delete Shortcuts. for (final bool pressShift in const [true, false]) @@ -315,6 +317,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), + const SingleActivator(LogicalKeyboardKey.pageUp): const ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + const SingleActivator(LogicalKeyboardKey.pageDown): const ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), @@ -553,9 +557,8 @@ Intent? intentForMacOSSelector(String selectorName) { 'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false), 'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true), - // TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497) - 'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false), - 'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true), + 'scrollPageUp:': ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + 'scrollPageDown:': ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), 'pageUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), 'pageDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 41a6cf7c4355..0f3672f26db4 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -33,6 +33,7 @@ import 'media_query.dart'; import 'scroll_configuration.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; +import 'scroll_position.dart'; import 'scrollable.dart'; import 'shortcuts.dart'; import 'spell_check.dart'; @@ -1907,6 +1908,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien TextSelectionOverlay? _selectionOverlay; + final GlobalKey _scrollableKey = GlobalKey(); ScrollController? _internalScrollController; ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController()); @@ -3953,6 +3955,96 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + /// Handles [ScrollIntent] by scrolling the [Scrollable] inside of + /// [EditableText]. + void _scroll(ScrollIntent intent) { + if (intent.type != ScrollIncrementType.page) { + return; + } + + final ScrollPosition position = _scrollController.position; + if (widget.maxLines == 1) { + _scrollController.jumpTo(position.maxScrollExtent); + return; + } + + // If the field isn't scrollable, do nothing. For example, when the lines of + // text is less than maxLines, the field has nothing to scroll. + if (position.maxScrollExtent == 0.0 && position.minScrollExtent == 0.0) { + return; + } + + final ScrollableState? state = _scrollableKey.currentState as ScrollableState?; + final double increment = ScrollAction.getDirectionalIncrement(state!, intent); + final double destination = clampDouble( + position.pixels + increment, + position.minScrollExtent, + position.maxScrollExtent, + ); + if (destination == position.pixels) { + return; + } + _scrollController.jumpTo(destination); + } + + /// Extend the selection down by page if the `forward` parameter is true, or + /// up by page otherwise. + void _extendSelectionByPage(ExtendSelectionByPageIntent intent) { + if (widget.maxLines == 1) { + return; + } + + final TextSelection nextSelection; + final Rect extentRect = renderEditable.getLocalRectForCaret( + _value.selection.extent, + ); + final ScrollableState? state = _scrollableKey.currentState as ScrollableState?; + final double increment = ScrollAction.getDirectionalIncrement( + state!, + ScrollIntent( + direction: intent.forward ? AxisDirection.down : AxisDirection.up, + type: ScrollIncrementType.page, + ), + ); + final ScrollPosition position = _scrollController.position; + if (intent.forward) { + if (_value.selection.extentOffset >= _value.text.length) { + return; + } + final Offset nextExtentOffset = + Offset(extentRect.left, extentRect.top + increment); + final double height = position.maxScrollExtent + renderEditable.size.height; + final TextPosition nextExtent = nextExtentOffset.dy + position.pixels >= height + ? TextPosition(offset: _value.text.length) + : renderEditable.getPositionForPoint( + renderEditable.localToGlobal(nextExtentOffset), + ); + nextSelection = _value.selection.copyWith( + extentOffset: nextExtent.offset, + ); + } else { + if (_value.selection.extentOffset <= 0) { + return; + } + final Offset nextExtentOffset = + Offset(extentRect.left, extentRect.top + increment); + final TextPosition nextExtent = nextExtentOffset.dy + position.pixels <= 0 + ? const TextPosition(offset: 0) + : renderEditable.getPositionForPoint( + renderEditable.localToGlobal(nextExtentOffset), + ); + nextSelection = _value.selection.copyWith( + extentOffset: nextExtent.offset, + ); + } + + bringIntoView(nextSelection.extent); + userUpdateTextEditingValue( + _value.copyWith(selection: nextSelection), + SelectionChangedCause.keyboard, + ); + } + void _updateSelection(UpdateSelectionIntent intent) { bringIntoView(intent.newSelection.extent); userUpdateTextEditingValue( @@ -4058,6 +4150,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // Extend/Move Selection ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction(this, false, _characterBoundary)), + ExtendSelectionByPageIntent: _makeOverridable(CallbackAction(onInvoke: _extendSelectionByPage)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _nextWordBoundary)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _linebreak)), ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction(onInvoke: _expandSelectionToLinebreak)), @@ -4067,6 +4160,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction(this, true, _documentBoundary)), ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction(onInvoke: _scrollToDocumentBoundary)), + ScrollIntent: CallbackAction(onInvoke: _scroll), // Copy Paste SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), @@ -4099,6 +4193,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien includeSemantics: false, debugLabel: kReleaseMode ? null : 'EditableText', child: Scrollable( + key: _scrollableKey, excludeFromSemantics: true, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, controller: _scrollController, diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 335dd0880cbe..7f07f2494ab0 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -1789,14 +1789,14 @@ class ScrollAction extends Action { return false; } - // Returns the scroll increment for a single scroll request, for use when - // scrolling using a hardware keyboard. - // - // Must not be called when the position is null, or when any of the position - // metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are - // null. The type and state arguments must not be null, and the widget must - // have already been laid out so that the position fields are valid. - double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) { + /// Returns the scroll increment for a single scroll request, for use when + /// scrolling using a hardware keyboard. + /// + /// Must not be called when the position is null, or when any of the position + /// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are + /// null. The type and state arguments must not be null, and the widget must + /// have already been laid out so that the position fields are valid. + static double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) { assert(type != null); assert(state.position != null); assert(state.position.hasPixels); @@ -1820,9 +1820,9 @@ class ScrollAction extends Action { } } - // Find out how much of an increment to move by, taking the different - // directions into account. - double _getIncrement(ScrollableState state, ScrollIntent intent) { + /// Find out how much of an increment to move by, taking the different + /// directions into account. + static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) { final double increment = _calculateScrollIncrement(state, type: intent.type); switch (intent.direction) { case AxisDirection.down: @@ -1912,7 +1912,7 @@ class ScrollAction extends Action { if (state!._physics != null && !state._physics!.shouldAcceptUserOffset(state.position)) { return; } - final double increment = _getIncrement(state, intent); + final double increment = getDirectionalIncrement(state, intent); if (increment == 0.0) { return; } diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index 2b3d7e7abbfa..9b853fbe96d9 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -245,6 +245,15 @@ class ScrollToDocumentBoundaryIntent extends DirectionalTextEditingIntent { }) : super(forward); } +/// Scrolls up or down by page depending on the [forward] parameter. +/// Extends the selection up or down by page based on the [forward] parameter. +class ExtendSelectionByPageIntent extends DirectionalTextEditingIntent { + /// Creates a [ExtendSelectionByPageIntent]. + const ExtendSelectionByPageIntent({ + required bool forward, + }) : super(forward); +} + /// An [Intent] to select everything in the field. class SelectAllTextIntent extends Intent { /// Creates an instance of [SelectAllTextIntent]. diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 45e4484b2bdf..79591e35b98e 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -8102,6 +8102,178 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.windows }) ); + testWidgets('pageup/pagedown keys on Apple platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + final ScrollController scrollController = ScrollController(); + const int lines = 2; + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + minLines: lines, + maxLines: lines, + controller: controller, + scrollController: scrollController, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(controller.value.selection.isCollapsed, isTrue); + expect(controller.value.selection.baseOffset, 0); + expect(scrollController.position.pixels, 0.0); + final double lineHeight = findRenderEditable(tester).preferredLineHeight; + expect(scrollController.position.viewportDimension, lineHeight * lines); + + // Page Up does nothing at the top. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, 0.0); + + // Page Down scrolls proportionally to the height of the viewport. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageDown, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, lineHeight * lines * 0.8); + + // Another Page Down reaches the bottom. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageDown, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, lineHeight * lines); + + // Page Up now scrolls back up proportionally to the height of the viewport. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, lineHeight * lines - lineHeight * lines * 0.8); + + // Another Page Up reaches the top. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, 0.0); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + + testWidgets('pageup/pagedown keys in a one line field on Apple platforms', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(text: testText); + controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 0, + affinity: TextAffinity.upstream, + ); + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + minLines: 1, + controller: controller, + scrollController: scrollController, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + await tester.pump(); // Wait for autofocus to take effect. + + expect(controller.value.selection.isCollapsed, isTrue); + expect(controller.value.selection.baseOffset, 0); + expect(scrollController.position.pixels, 0.0); + + // Page Up scrolls to the end. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageUp, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, scrollController.position.maxScrollExtent); + expect(controller.value.selection.isCollapsed, isTrue); + expect(controller.value.selection.baseOffset, 0); + + // Return scroll to the start. + await sendKeys( + tester, + [ + LogicalKeyboardKey.home, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, 0.0); + expect(controller.value.selection.isCollapsed, isTrue); + expect(controller.value.selection.baseOffset, 0); + + // Page Down also scrolls to the end. + await sendKeys( + tester, + [ + LogicalKeyboardKey.pageDown, + ], + targetPlatform: defaultTargetPlatform, + ); + expect(scrollController.position.pixels, scrollController.position.maxScrollExtent); + expect(controller.value.selection.isCollapsed, isTrue); + expect(controller.value.selection.baseOffset, 0); + }, + skip: kIsWeb, // [intended] on web these keys are handled by the browser. + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + ); + // Regression test for https://github.com/flutter/flutter/issues/31287 testWidgets('text selection handle visibility', (WidgetTester tester) async { // Text with two separate words to select.