Skip to content

Commit 7e36cf1

Browse files
authored
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.
1 parent 497a528 commit 7e36cf1

File tree

6 files changed

+300
-18
lines changed

6 files changed

+300
-18
lines changed

packages/flutter/lib/src/widgets/app.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1767,7 +1767,10 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
17671767
// fall through to the defaultShortcuts.
17681768
child: DefaultTextEditingShortcuts(
17691769
child: Actions(
1770-
actions: widget.actions ?? WidgetsApp.defaultActions,
1770+
actions: widget.actions ?? <Type, Action<Intent>>{
1771+
...WidgetsApp.defaultActions,
1772+
ScrollIntent: Action<ScrollIntent>.overridable(context: context, defaultAction: ScrollAction()),
1773+
},
17711774
child: FocusTraversalGroup(
17721775
policy: ReadingOrderTraversalPolicy(),
17731776
child: TapRegionSurface(

packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
// found in the LICENSE file.
44

55
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/painting.dart';
67
import 'package:flutter/services.dart';
78

89
import 'actions.dart';
910
import 'focus_traversal.dart';
1011
import 'framework.dart';
12+
import 'scrollable.dart';
1113
import 'shortcuts.dart';
1214
import 'text_editing_intents.dart';
1315

@@ -157,8 +159,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
157159
/// {@macro flutter.widgets.ProxyWidget.child}
158160
final Widget child;
159161

160-
// These are shortcuts are shared between most platforms except macOS for it
161-
// uses different modifier keys as the line/word modifier.
162+
// These shortcuts are shared between all platforms except Apple platforms,
163+
// because they use different modifier keys as the line/word modifier.
162164
static final Map<ShortcutActivator, Intent> _commonShortcuts = <ShortcutActivator, Intent>{
163165
// Delete Shortcuts.
164166
for (final bool pressShift in const <bool>[true, false])
@@ -315,6 +317,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
315317
const SingleActivator(LogicalKeyboardKey.home, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false),
316318
const SingleActivator(LogicalKeyboardKey.end, shift: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true),
317319

320+
const SingleActivator(LogicalKeyboardKey.pageUp): const ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page),
321+
const SingleActivator(LogicalKeyboardKey.pageDown): const ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page),
318322
const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false),
319323
const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): const ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false),
320324

@@ -553,9 +557,8 @@ Intent? intentForMacOSSelector(String selectorName) {
553557
'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false),
554558
'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true),
555559

556-
// TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497)
557-
'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false),
558-
'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true),
560+
'scrollPageUp:': ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page),
561+
'scrollPageDown:': ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page),
559562
'pageUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: false, collapseSelection: false),
560563
'pageDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentPageIntent(forward: true, collapseSelection: false),
561564

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import 'media_query.dart';
3333
import 'scroll_configuration.dart';
3434
import 'scroll_controller.dart';
3535
import 'scroll_physics.dart';
36+
import 'scroll_position.dart';
3637
import 'scrollable.dart';
3738
import 'shortcuts.dart';
3839
import 'spell_check.dart';
@@ -1907,6 +1908,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
19071908

19081909
TextSelectionOverlay? _selectionOverlay;
19091910

1911+
final GlobalKey _scrollableKey = GlobalKey();
19101912
ScrollController? _internalScrollController;
19111913
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
19121914

@@ -3953,6 +3955,96 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
39533955
}
39543956
}
39553957

3958+
/// Handles [ScrollIntent] by scrolling the [Scrollable] inside of
3959+
/// [EditableText].
3960+
void _scroll(ScrollIntent intent) {
3961+
if (intent.type != ScrollIncrementType.page) {
3962+
return;
3963+
}
3964+
3965+
final ScrollPosition position = _scrollController.position;
3966+
if (widget.maxLines == 1) {
3967+
_scrollController.jumpTo(position.maxScrollExtent);
3968+
return;
3969+
}
3970+
3971+
// If the field isn't scrollable, do nothing. For example, when the lines of
3972+
// text is less than maxLines, the field has nothing to scroll.
3973+
if (position.maxScrollExtent == 0.0 && position.minScrollExtent == 0.0) {
3974+
return;
3975+
}
3976+
3977+
final ScrollableState? state = _scrollableKey.currentState as ScrollableState?;
3978+
final double increment = ScrollAction.getDirectionalIncrement(state!, intent);
3979+
final double destination = clampDouble(
3980+
position.pixels + increment,
3981+
position.minScrollExtent,
3982+
position.maxScrollExtent,
3983+
);
3984+
if (destination == position.pixels) {
3985+
return;
3986+
}
3987+
_scrollController.jumpTo(destination);
3988+
}
3989+
3990+
/// Extend the selection down by page if the `forward` parameter is true, or
3991+
/// up by page otherwise.
3992+
void _extendSelectionByPage(ExtendSelectionByPageIntent intent) {
3993+
if (widget.maxLines == 1) {
3994+
return;
3995+
}
3996+
3997+
final TextSelection nextSelection;
3998+
final Rect extentRect = renderEditable.getLocalRectForCaret(
3999+
_value.selection.extent,
4000+
);
4001+
final ScrollableState? state = _scrollableKey.currentState as ScrollableState?;
4002+
final double increment = ScrollAction.getDirectionalIncrement(
4003+
state!,
4004+
ScrollIntent(
4005+
direction: intent.forward ? AxisDirection.down : AxisDirection.up,
4006+
type: ScrollIncrementType.page,
4007+
),
4008+
);
4009+
final ScrollPosition position = _scrollController.position;
4010+
if (intent.forward) {
4011+
if (_value.selection.extentOffset >= _value.text.length) {
4012+
return;
4013+
}
4014+
final Offset nextExtentOffset =
4015+
Offset(extentRect.left, extentRect.top + increment);
4016+
final double height = position.maxScrollExtent + renderEditable.size.height;
4017+
final TextPosition nextExtent = nextExtentOffset.dy + position.pixels >= height
4018+
? TextPosition(offset: _value.text.length)
4019+
: renderEditable.getPositionForPoint(
4020+
renderEditable.localToGlobal(nextExtentOffset),
4021+
);
4022+
nextSelection = _value.selection.copyWith(
4023+
extentOffset: nextExtent.offset,
4024+
);
4025+
} else {
4026+
if (_value.selection.extentOffset <= 0) {
4027+
return;
4028+
}
4029+
final Offset nextExtentOffset =
4030+
Offset(extentRect.left, extentRect.top + increment);
4031+
final TextPosition nextExtent = nextExtentOffset.dy + position.pixels <= 0
4032+
? const TextPosition(offset: 0)
4033+
: renderEditable.getPositionForPoint(
4034+
renderEditable.localToGlobal(nextExtentOffset),
4035+
);
4036+
nextSelection = _value.selection.copyWith(
4037+
extentOffset: nextExtent.offset,
4038+
);
4039+
}
4040+
4041+
bringIntoView(nextSelection.extent);
4042+
userUpdateTextEditingValue(
4043+
_value.copyWith(selection: nextSelection),
4044+
SelectionChangedCause.keyboard,
4045+
);
4046+
}
4047+
39564048
void _updateSelection(UpdateSelectionIntent intent) {
39574049
bringIntoView(intent.newSelection.extent);
39584050
userUpdateTextEditingValue(
@@ -4058,6 +4150,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
40584150

40594151
// Extend/Move Selection
40604152
ExtendSelectionByCharacterIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionByCharacterIntent>(this, false, _characterBoundary)),
4153+
ExtendSelectionByPageIntent: _makeOverridable(CallbackAction<ExtendSelectionByPageIntent>(onInvoke: _extendSelectionByPage)),
40614154
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, true, _nextWordBoundary)),
40624155
ExtendSelectionToLineBreakIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToLineBreakIntent>(this, true, _linebreak)),
40634156
ExpandSelectionToLineBreakIntent: _makeOverridable(CallbackAction<ExpandSelectionToLineBreakIntent>(onInvoke: _expandSelectionToLinebreak)),
@@ -4067,6 +4160,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
40674160
ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_UpdateTextSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, true, _documentBoundary)),
40684161
ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_ExtendSelectionOrCaretPositionAction(this, _nextWordBoundary)),
40694162
ScrollToDocumentBoundaryIntent: _makeOverridable(CallbackAction<ScrollToDocumentBoundaryIntent>(onInvoke: _scrollToDocumentBoundary)),
4163+
ScrollIntent: CallbackAction<ScrollIntent>(onInvoke: _scroll),
40704164

40714165
// Copy Paste
40724166
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
@@ -4099,6 +4193,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
40994193
includeSemantics: false,
41004194
debugLabel: kReleaseMode ? null : 'EditableText',
41014195
child: Scrollable(
4196+
key: _scrollableKey,
41024197
excludeFromSemantics: true,
41034198
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
41044199
controller: _scrollController,

packages/flutter/lib/src/widgets/scrollable.dart

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,14 +1789,14 @@ class ScrollAction extends Action<ScrollIntent> {
17891789
return false;
17901790
}
17911791

1792-
// Returns the scroll increment for a single scroll request, for use when
1793-
// scrolling using a hardware keyboard.
1794-
//
1795-
// Must not be called when the position is null, or when any of the position
1796-
// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
1797-
// null. The type and state arguments must not be null, and the widget must
1798-
// have already been laid out so that the position fields are valid.
1799-
double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) {
1792+
/// Returns the scroll increment for a single scroll request, for use when
1793+
/// scrolling using a hardware keyboard.
1794+
///
1795+
/// Must not be called when the position is null, or when any of the position
1796+
/// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
1797+
/// null. The type and state arguments must not be null, and the widget must
1798+
/// have already been laid out so that the position fields are valid.
1799+
static double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) {
18001800
assert(type != null);
18011801
assert(state.position != null);
18021802
assert(state.position.hasPixels);
@@ -1820,9 +1820,9 @@ class ScrollAction extends Action<ScrollIntent> {
18201820
}
18211821
}
18221822

1823-
// Find out how much of an increment to move by, taking the different
1824-
// directions into account.
1825-
double _getIncrement(ScrollableState state, ScrollIntent intent) {
1823+
/// Find out how much of an increment to move by, taking the different
1824+
/// directions into account.
1825+
static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) {
18261826
final double increment = _calculateScrollIncrement(state, type: intent.type);
18271827
switch (intent.direction) {
18281828
case AxisDirection.down:
@@ -1912,7 +1912,7 @@ class ScrollAction extends Action<ScrollIntent> {
19121912
if (state!._physics != null && !state._physics!.shouldAcceptUserOffset(state.position)) {
19131913
return;
19141914
}
1915-
final double increment = _getIncrement(state, intent);
1915+
final double increment = getDirectionalIncrement(state, intent);
19161916
if (increment == 0.0) {
19171917
return;
19181918
}

packages/flutter/lib/src/widgets/text_editing_intents.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ class ScrollToDocumentBoundaryIntent extends DirectionalTextEditingIntent {
245245
}) : super(forward);
246246
}
247247

248+
/// Scrolls up or down by page depending on the [forward] parameter.
249+
/// Extends the selection up or down by page based on the [forward] parameter.
250+
class ExtendSelectionByPageIntent extends DirectionalTextEditingIntent {
251+
/// Creates a [ExtendSelectionByPageIntent].
252+
const ExtendSelectionByPageIntent({
253+
required bool forward,
254+
}) : super(forward);
255+
}
256+
248257
/// An [Intent] to select everything in the field.
249258
class SelectAllTextIntent extends Intent {
250259
/// Creates an instance of [SelectAllTextIntent].

0 commit comments

Comments
 (0)