Unverified Commit 7e36cf17 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Mac Page Up / Page Down in text fields (#105497)

Adds support for Mac/iOS's behavior of scrolling (but not moving the cursor) when using page up/down in a text field.
parent 497a5280
...@@ -1767,7 +1767,10 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1767,7 +1767,10 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
// fall through to the defaultShortcuts. // fall through to the defaultShortcuts.
child: DefaultTextEditingShortcuts( child: DefaultTextEditingShortcuts(
child: Actions( child: Actions(
actions: widget.actions ?? WidgetsApp.defaultActions, actions: widget.actions ?? <Type, Action<Intent>>{
...WidgetsApp.defaultActions,
ScrollIntent: Action<ScrollIntent>.overridable(context: context, defaultAction: ScrollAction()),
},
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
child: TapRegionSurface( child: TapRegionSurface(
......
...@@ -3,11 +3,13 @@ ...@@ -3,11 +3,13 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'focus_traversal.dart'; import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
import 'scrollable.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'text_editing_intents.dart'; import 'text_editing_intents.dart';
...@@ -157,8 +159,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { ...@@ -157,8 +159,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
/// {@macro flutter.widgets.ProxyWidget.child} /// {@macro flutter.widgets.ProxyWidget.child}
final Widget child; final Widget child;
// These are shortcuts are shared between most platforms except macOS for it // These shortcuts are shared between all platforms except Apple platforms,
// uses different modifier keys as the line/word modifier. // because they use different modifier keys as the line/word modifier.
static final Map<ShortcutActivator, Intent> _commonShortcuts = <ShortcutActivator, Intent>{ static final Map<ShortcutActivator, Intent> _commonShortcuts = <ShortcutActivator, Intent>{
// Delete Shortcuts. // Delete Shortcuts.
for (final bool pressShift in const <bool>[true, false]) for (final bool pressShift in const <bool>[true, false])
...@@ -315,6 +317,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { ...@@ -315,6 +317,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false),
const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), 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.pageUp, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false),
const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false),
...@@ -553,9 +557,8 @@ Intent? intentForMacOSSelector(String selectorName) { ...@@ -553,9 +557,8 @@ Intent? intentForMacOSSelector(String selectorName) {
'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false), 'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false),
'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true), 'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true),
// TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497) 'scrollPageUp:': ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page),
'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false), 'scrollPageDown:': ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page),
'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true),
'pageUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false), 'pageUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false),
'pageDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false), 'pageDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false),
......
...@@ -33,6 +33,7 @@ import 'media_query.dart'; ...@@ -33,6 +33,7 @@ import 'media_query.dart';
import 'scroll_configuration.dart'; import 'scroll_configuration.dart';
import 'scroll_controller.dart'; import 'scroll_controller.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'spell_check.dart'; import 'spell_check.dart';
...@@ -1907,6 +1908,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1907,6 +1908,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
TextSelectionOverlay? _selectionOverlay; TextSelectionOverlay? _selectionOverlay;
final GlobalKey _scrollableKey = GlobalKey();
ScrollController? _internalScrollController; ScrollController? _internalScrollController;
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController()); ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
...@@ -3953,6 +3955,96 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3953,6 +3955,96 @@ class EditableTextState extends State<EditableText> 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) { void _updateSelection(UpdateSelectionIntent intent) {
bringIntoView(intent.newSelection.extent); bringIntoView(intent.newSelection.extent);
userUpdateTextEditingValue( userUpdateTextEditingValue(
...@@ -4058,6 +4150,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4058,6 +4150,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// Extend/Move Selection // Extend/Move Selection
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)), ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
ExtendSelectionByPageIntent: _makeOverridable(CallbackAction<ExtendSelectionByPageIntent>(onInvoke: _extendSelectionByPage)),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)), ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)), ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)), ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
...@@ -4067,6 +4160,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4067,6 +4160,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)), ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)),
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)), ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)), ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),
ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll),
// Copy Paste // Copy Paste
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
...@@ -4099,6 +4193,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -4099,6 +4193,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
includeSemantics: false, includeSemantics: false,
debugLabel: kReleaseMode ? null : 'EditableText', debugLabel: kReleaseMode ? null : 'EditableText',
child: Scrollable( child: Scrollable(
key: _scrollableKey,
excludeFromSemantics: true, excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController, controller: _scrollController,
......
...@@ -1789,14 +1789,14 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -1789,14 +1789,14 @@ class ScrollAction extends Action<ScrollIntent> {
return false; return false;
} }
// Returns the scroll increment for a single scroll request, for use when /// Returns the scroll increment for a single scroll request, for use when
// scrolling using a hardware keyboard. /// scrolling using a hardware keyboard.
// ///
// Must not be called when the position is null, or when any of the position /// Must not be called when the position is null, or when any of the position
// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are /// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
// null. The type and state arguments must not be null, and the widget must /// 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. /// have already been laid out so that the position fields are valid.
double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) { static double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) {
assert(type != null); assert(type != null);
assert(state.position != null); assert(state.position != null);
assert(state.position.hasPixels); assert(state.position.hasPixels);
...@@ -1820,9 +1820,9 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -1820,9 +1820,9 @@ class ScrollAction extends Action<ScrollIntent> {
} }
} }
// Find out how much of an increment to move by, taking the different /// Find out how much of an increment to move by, taking the different
// directions into account. /// directions into account.
double _getIncrement(ScrollableState state, ScrollIntent intent) { static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) {
final double increment = _calculateScrollIncrement(state, type: intent.type); final double increment = _calculateScrollIncrement(state, type: intent.type);
switch (intent.direction) { switch (intent.direction) {
case AxisDirection.down: case AxisDirection.down:
...@@ -1912,7 +1912,7 @@ class ScrollAction extends Action<ScrollIntent> { ...@@ -1912,7 +1912,7 @@ class ScrollAction extends Action<ScrollIntent> {
if (state!._physics != null && !state._physics!.shouldAcceptUserOffset(state.position)) { if (state!._physics != null && !state._physics!.shouldAcceptUserOffset(state.position)) {
return; return;
} }
final double increment = _getIncrement(state, intent); final double increment = getDirectionalIncrement(state, intent);
if (increment == 0.0) { if (increment == 0.0) {
return; return;
} }
......
...@@ -245,6 +245,15 @@ class ScrollToDocumentBoundaryIntent extends DirectionalTextEditingIntent { ...@@ -245,6 +245,15 @@ class ScrollToDocumentBoundaryIntent extends DirectionalTextEditingIntent {
}) : super(forward); }) : 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. /// An [Intent] to select everything in the field.
class SelectAllTextIntent extends Intent { class SelectAllTextIntent extends Intent {
/// Creates an instance of [SelectAllTextIntent]. /// Creates an instance of [SelectAllTextIntent].
......
...@@ -8102,6 +8102,178 @@ void main() { ...@@ -8102,6 +8102,178 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows }) variant: const TargetPlatformVariant(<TargetPlatform>{ 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>[
LogicalKeyboardKey.pageUp,
],
targetPlatform: defaultTargetPlatform,
);
expect(scrollController.position.pixels, 0.0);
// Page Down scrolls proportionally to the height of the viewport.
await sendKeys(
tester,
<LogicalKeyboardKey>[
LogicalKeyboardKey.pageDown,
],
targetPlatform: defaultTargetPlatform,
);
expect(scrollController.position.pixels, lineHeight * lines * 0.8);
// Another Page Down reaches the bottom.
await sendKeys(
tester,
<LogicalKeyboardKey>[
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>[
LogicalKeyboardKey.pageUp,
],
targetPlatform: defaultTargetPlatform,
);
expect(scrollController.position.pixels, lineHeight * lines - lineHeight * lines * 0.8);
// Another Page Up reaches the top.
await sendKeys(
tester,
<LogicalKeyboardKey>[
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>{ 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>[
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>[
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>[
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>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
// Regression test for https://github.com/flutter/flutter/issues/31287 // Regression test for https://github.com/flutter/flutter/issues/31287
testWidgets('text selection handle visibility', (WidgetTester tester) async { testWidgets('text selection handle visibility', (WidgetTester tester) async {
// Text with two separate words to select. // Text with two separate words to select.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment