Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Keyboard scrolling of Scrollable (#45019)
This adds the ability to scroll and page up/down in a Scrollable using the keyboard. Currently, the macOS bindings use Platform.isMacOS as a check, but we'll switch that to be defaultTargetPlatform == TargetPlatform.macOS once that exists.
  • Loading branch information
gspencergoog committed Nov 26, 2019
1 parent 459c7fb commit 0190e40
Show file tree
Hide file tree
Showing 4 changed files with 651 additions and 10 deletions.
46 changes: 41 additions & 5 deletions packages/flutter/lib/src/widgets/app.dart
Expand Up @@ -4,6 +4,7 @@

import 'dart:async';
import 'dart:collection' show HashMap;
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
Expand All @@ -20,6 +21,7 @@ import 'media_query.dart';
import 'navigator.dart';
import 'pages.dart';
import 'performance_overlay.dart';
import 'scrollable.dart';
import 'semantics_debugger.dart';
import 'shortcuts.dart';
import 'text.dart';
Expand Down Expand Up @@ -1041,12 +1043,46 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
}

final Map<LogicalKeySet, Intent> _keyMap = <LogicalKeySet, Intent>{
// Next/previous keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),

// Directional keyboard traversal. Not available on web.
if (!kIsWeb) ...<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up)
},

// Keyboard scrolling.
// TODO(gspencergoog): Convert all of the Platform.isMacOS checks to be
// defaultTargetPlatform == TargetPlatform.macOS, once that exists.
// https://github.com/flutter/flutter/issues/31366
if (!kIsWeb && !Platform.isMacOS) ...<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowDown): const ScrollIntent(direction: AxisDirection.down),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): const ScrollIntent(direction: AxisDirection.left),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.arrowRight): const ScrollIntent(direction: AxisDirection.right),
},
if (!kIsWeb && Platform.isMacOS) ...<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowDown): const ScrollIntent(direction: AxisDirection.down),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowLeft): const ScrollIntent(direction: AxisDirection.left),
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.arrowRight): const ScrollIntent(direction: AxisDirection.right),
},

// Web scrolling.
if (kIsWeb) ...<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp): const ScrollIntent(direction: AxisDirection.up),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const ScrollIntent(direction: AxisDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const ScrollIntent(direction: AxisDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const ScrollIntent(direction: AxisDirection.right),
},

LogicalKeySet(LogicalKeyboardKey.pageUp): const ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page),
LogicalKeySet(LogicalKeyboardKey.pageDown): const ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page),

LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key),
};
Expand All @@ -1057,6 +1093,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
ScrollAction.key: () => ScrollAction(),
};

@override
Expand Down Expand Up @@ -1169,7 +1206,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
: _locale;

assert(_debugCheckLocalizations(appLocale));

return Shortcuts(
shortcuts: _keyMap,
child: Actions(
Expand Down
7 changes: 2 additions & 5 deletions packages/flutter/lib/src/widgets/focus_traversal.dart
Expand Up @@ -1002,8 +1002,8 @@ class DirectionalFocusIntent extends Intent {
final bool ignoreTextFields;
}

/// An [Action] that moves the focus to the focusable node in the given
/// [direction] configured by the associated [DirectionalFocusIntent].
/// An [Action] that moves the focus to the focusable node in the direction
/// configured by the associated [DirectionalFocusIntent.direction].
///
/// This is the [Action] associated with the [key] and bound by default to the
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
Expand All @@ -1016,9 +1016,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase {
/// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent].
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);

/// The direction in which to look for the next focusable node when invoked.
TraversalDirection direction;

@override
void invoke(FocusNode node, DirectionalFocusIntent intent) {
if (!intent.ignoreTextFields || node.context.widget is! EditableText) {
Expand Down
245 changes: 245 additions & 0 deletions packages/flutter/lib/src/widgets/scrollable.dart
Expand Up @@ -11,13 +11,16 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/painting.dart';

import 'actions.dart';
import 'basic.dart';
import 'focus_manager.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'notification_listener.dart';
import 'scroll_configuration.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart';
import 'scroll_metrics.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
Expand Down Expand Up @@ -81,6 +84,7 @@ class Scrollable extends StatefulWidget {
this.controller,
this.physics,
@required this.viewportBuilder,
this.incrementCalculator,
this.excludeFromSemantics = false,
this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start,
Expand Down Expand Up @@ -155,6 +159,19 @@ class Scrollable extends StatefulWidget {
/// slivers and sizes itself based on the size of the slivers.
final ViewportBuilder viewportBuilder;

/// An optional function that will be called to calculate the distance to
/// scroll when the scrollable is asked to scroll via the keyboard using a
/// [ScrollAction].
///
/// If not supplied, the [Scrollable] will scroll a default amount when a
/// keyboard navigation key is pressed (e.g. pageUp/pageDown, control-upArrow,
/// etc.), or otherwise invoked by a [ScrollAction].
///
/// If [incrementCalculator] is null, the default for
/// [ScrollIncrementType.page] is 80% of the size of the scroll window, and
/// for [ScrollIncrementType.line], 50 logical pixels.
final ScrollIncrementCalculator incrementCalculator;

/// Whether the scroll actions introduced by this [Scrollable] are exposed
/// in the semantics tree.
///
Expand Down Expand Up @@ -767,3 +784,231 @@ class _RenderScrollSemantics extends RenderProxyBox {
_innerNode = null;
}
}

/// A typedef for a function that can calculate the offset for a type of scroll
/// increment given a [ScrollIncrementDetails].
///
/// This function is used as the type for [Scrollable.incrementCalculator],
/// which is called from a [ScrollAction].
typedef ScrollIncrementCalculator = double Function(ScrollIncrementDetails details);

/// Describes the type of scroll increment that will be performed by a
/// [ScrollAction] on a [Scrollable].
///
/// This is used to configure a [ScrollIncrementDetails] object to pass to a
/// [ScrollIncrementCalculator] function on a [Scrollable].
///
/// {@template flutter.widgets.scrollable.scroll_increment_type.intent}
/// This indicates the *intent* of the scroll, not necessarily the size. Not all
/// scrollable areas will have the concept of a "line" or "page", but they can
/// respond to the different standard key bindings that cause scrolling, which
/// are bound to keys that people use to indicate a "line" scroll (e.g.
/// control-arrowDown keys) or a "page" scroll (e.g. pageDown key). It is
/// recommended that at least the relative magnitudes of the scrolls match
/// expectations.
/// {@endtemplate}
enum ScrollIncrementType {
/// Indicates that the [ScrollIncrementCalculator] should return the scroll
/// distance it should move when the user requests to scroll by a "line".
///
/// The distance a "line" scrolls refers to what should happen when the key
/// binding for "scroll down/up by a line" is triggered. It's up to the
/// [ScrollIncrementCalculator] function to decide what that means for a
/// particular scrollable.
line,

/// Indicates that the [ScrollIncrementCalculator] should return the scroll
/// distance it should move when the user requests to scroll by a "page".
///
/// The distance a "page" scrolls refers to what should happen when the key
/// binding for "scroll down/up by a page" is triggered. It's up to the
/// [ScrollIncrementCalculator] function to decide what that means for a
/// particular scrollable.
page,
}

/// A details object that describes the type of scroll increment being requested
/// of a [ScrollIncrementCalculator] function, as well as the current metrics
/// for the scrollable.
class ScrollIncrementDetails {
/// A const constructor for a [ScrollIncrementDetails].
///
/// All of the arguments must not be null, and are required.
const ScrollIncrementDetails({
@required this.type,
@required this.metrics,
}) : assert(type != null),
assert(metrics != null);

/// The type of scroll this is (e.g. line, page, etc.).
///
/// {@macro flutter.widgets.scrollable.scroll_increment_type.intent}
final ScrollIncrementType type;

/// The current metrics of the scrollable that is being scrolled.
final ScrollMetrics metrics;
}

/// An [Intent] that represents scrolling the nearest scrollable by an amount
/// appropriate for the [type] specified.
///
/// The actual amount of the scroll is determined by the
/// [Scrollable.incrementCalculator], or by its defaults if that is not
/// specified.
class ScrollIntent extends Intent {
/// Creates a const [ScrollIntent] that requests scrolling in the given
/// [direction], with the given [type].
///
/// If [reversed] is specified, then the scroll will happen in the opposite
/// direction from the normal scroll direction.
const ScrollIntent({
@required this.direction,
this.type = ScrollIncrementType.line,
}) : assert(direction != null),
assert(type != null),
super(ScrollAction.key);

/// The direction in which to scroll the scrollable containing the focused
/// widget.
final AxisDirection direction;

/// The type of scrolling that is intended.
final ScrollIncrementType type;

@override
bool isEnabled(BuildContext context) {
return Scrollable.of(context) != null;
}
}

/// An [Action] that scrolls the [Scrollable] that encloses the current
/// [primaryFocus] by the amount configured in the [ScrollIntent] given to it.
///
/// If [Scrollable.incrementCalculator] is null for the scrollable, the default
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
/// pixels.
class ScrollAction extends Action {
/// Creates a const [ScrollAction].
ScrollAction() : super(key);

/// The [LocalKey] that uniquely connects this action to a [ScrollIntent].
static const LocalKey key = ValueKey<Type>(ScrollAction);

// 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 }) {
assert(type != null);
assert(state.position != null);
assert(state.position.pixels != null);
assert(state.position.viewportDimension != null);
assert(state.position.maxScrollExtent != null);
assert(state.position.minScrollExtent != null);
assert(state.widget.physics == null || state.widget.physics.shouldAcceptUserOffset(state.position));
if (state.widget.incrementCalculator != null) {
return state.widget.incrementCalculator(
ScrollIncrementDetails(
type: type,
metrics: state.position,
),
);
}
switch (type) {
case ScrollIncrementType.line:
return 50.0;
case ScrollIncrementType.page:
return 0.8 * state.position.viewportDimension;
}
return 0.0;
}

// Find out how much of an increment to move by, taking the different
// directions into account.
double _getIncrement(ScrollableState state, ScrollIntent intent) {
final double increment = _calculateScrollIncrement(state, type: intent.type);
switch (intent.direction) {
case AxisDirection.down:
switch (state.axisDirection) {
case AxisDirection.up:
return -increment;
break;
case AxisDirection.down:
return increment;
break;
case AxisDirection.right:
case AxisDirection.left:
return 0.0;
}
break;
case AxisDirection.up:
switch (state.axisDirection) {
case AxisDirection.up:
return increment;
break;
case AxisDirection.down:
return -increment;
break;
case AxisDirection.right:
case AxisDirection.left:
return 0.0;
}
break;
case AxisDirection.left:
switch (state.axisDirection) {
case AxisDirection.right:
return -increment;
break;
case AxisDirection.left:
return increment;
break;
case AxisDirection.up:
case AxisDirection.down:
return 0.0;
}
break;
case AxisDirection.right:
switch (state.axisDirection) {
case AxisDirection.right:
return increment;
break;
case AxisDirection.left:
return -increment;
break;
case AxisDirection.up:
case AxisDirection.down:
return 0.0;
}
break;
}
return 0.0;
}

@override
void invoke(FocusNode node, ScrollIntent intent) {
final ScrollableState state = Scrollable.of(node.context);
assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
assert(state.position.pixels != null, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');
assert(state.position.viewportDimension != null);
assert(state.position.maxScrollExtent != null);
assert(state.position.minScrollExtent != null);

// Don't do anything if the user isn't allowed to scroll.
if (state.widget.physics != null && !state.widget.physics.shouldAcceptUserOffset(state.position)) {
return;
}
final double increment = _getIncrement(state, intent);
if (increment == 0.0) {
return;
}
state.position.moveTo(
state.position.pixels + increment,
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
}
}

0 comments on commit 0190e40

Please sign in to comment.