From 0190e40457d43e17bdfaf046dfa634cbc5bf28b9 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Tue, 26 Nov 2019 09:56:25 -0800 Subject: [PATCH] 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. --- packages/flutter/lib/src/widgets/app.dart | 46 ++- .../lib/src/widgets/focus_traversal.dart | 7 +- .../flutter/lib/src/widgets/scrollable.dart | 245 ++++++++++++ .../flutter/test/widgets/scrollable_test.dart | 363 ++++++++++++++++++ 4 files changed, 651 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index a15d7ef192ccbb..3502657a4f690a 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -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'; @@ -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'; @@ -1041,12 +1043,46 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { } final Map _keyMap = { + // 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(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(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(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(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), }; @@ -1057,6 +1093,7 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { NextFocusAction.key: () => NextFocusAction(), PreviousFocusAction.key: () => PreviousFocusAction(), DirectionalFocusAction.key: () => DirectionalFocusAction(), + ScrollAction.key: () => ScrollAction(), }; @override @@ -1169,7 +1206,6 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { : _locale; assert(_debugCheckLocalizations(appLocale)); - return Shortcuts( shortcuts: _keyMap, child: Actions( diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index b6a119dfb7f6b7..66fc2823037813 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -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], @@ -1016,9 +1016,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase { /// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent]. static const LocalKey key = ValueKey(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) { diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 06645fe4952ff0..1df7acb45987ac 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -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'; @@ -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, @@ -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. /// @@ -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(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, + ); + } +} diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index b9a318305b36ed..264bf816b6407e 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -13,12 +16,14 @@ Future pumpTest( TargetPlatform platform, { bool scrollable = true, bool reverse = false, + ScrollController controller, }) async { await tester.pumpWidget(MaterialApp( theme: ThemeData( platform: platform, ), home: CustomScrollView( + controller: controller, reverse: reverse, physics: scrollable ? null : const NeverScrollableScrollPhysics(), slivers: const [ @@ -31,6 +36,14 @@ Future pumpTest( const double dragOffset = 200.0; +// TODO(gspencergoog): Change this to use TargetPlatform.macOS once that is available. +// https://github.com/flutter/flutter/issues/31366 +// Can't be const, since Platform.macOS asserts if called in const context. +// ignore: prefer_const_declarations +final LogicalKeyboardKey modifierKey = (!kIsWeb && Platform.isMacOS) + ? LogicalKeyboardKey.metaLeft + : LogicalKeyboardKey.controlLeft; + double getScrollOffset(WidgetTester tester) { final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); return viewport.offset.pixels; @@ -267,4 +280,354 @@ void main() { expect(getScrollOffset(tester), 20.0); }); + + testWidgets("Keyboard scrolling doesn't happen if scroll physics are set to NeverScrollableScrollPhysics", (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + physics: const NeverScrollableScrollPhysics(), + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + autofocus: index == 0, + child: SizedBox(key: ValueKey('Box $index'), height: 50.0), + ), + ); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Vertical scrollables are scrolled when activated via keyboard.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + autofocus: index == 0, + child: SizedBox(key: ValueKey('Box $index'), height: 50.0), + ), + ); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, -50.0, 800.0, 0.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, -400.0, 800.0, -350.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 50.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Horizontal scrollables are scrolled when activated via keyboard.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + autofocus: index == 0, + child: SizedBox(key: ValueKey('Box $index'), width: 50.0), + ), + ); + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 50.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(-50.0, 0.0, 0.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 50.0, 600.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Horizontal scrollables are scrolled the correct direction in RTL locales.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: Directionality( + textDirection: TextDirection.rtl, + child: CustomScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + autofocus: index == 0, + child: SizedBox(key: ValueKey('Box $index'), width: 50.0), + ), + ); + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(750.0, 0.0, 800.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(800.0, 0.0, 850.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(750.0, 0.0, 800.0, 600.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Reversed vertical scrollables are scrolled when activated via keyboard.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + final FocusNode focusNode = FocusNode(debugLabel: 'SizedBox'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + reverse: true, + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + focusNode: focusNode, + child: SizedBox(key: ValueKey('Box $index'), height: 50.0), + ), + ); + }, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 550.0, 800.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 600.0, 800.0, 650.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 550.0, 800.0, 600.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 950.0, 800.0, 1000.0))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 550.0, 800.0, 600.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Reversed horizontal scrollables are scrolled when activated via keyboard.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + final FocusNode focusNode = FocusNode(debugLabel: 'SizedBox'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + scrollDirection: Axis.horizontal, + reverse: true, + slivers: List.generate( + 20, + (int index) { + return SliverToBoxAdapter( + child: Focus( + focusNode: focusNode, + child: SizedBox(key: ValueKey('Box $index'), width: 50.0), + ), + ); + }, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(750.0, 0.0, 800.0, 600.00))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(const ValueKey('Box 0'), skipOffstage: false)), equals(const Rect.fromLTRB(800.0, 0.0, 850.0, 600.0))); + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); + + testWidgets('Custom scrollables with a center sliver are scrolled when activated via keyboard.', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + final List items = List.generate(20, (int index) => 'Item $index'); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: TargetPlatform.fuchsia, + ), + home: CustomScrollView( + controller: controller, + center: const ValueKey('Center'), + slivers: items.map( + (String item) { + return SliverToBoxAdapter( + key: item == 'Item 10' ? const ValueKey('Center') : null, + child: Focus( + autofocus: item == 'Item 10', + child: Container( + key: ValueKey(item), + alignment: Alignment.center, + height: 100, + child: Text(item), + ), + ), + ); + }, + ).toList(), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(controller.position.pixels, equals(0.0)); + expect(tester.getRect(find.byKey(const ValueKey('Item 10'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 100.0))); + for (int i = 0; i < 10; ++i) { + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + } + // Starts at #10 already, so doesn't work out to 500.0 because it hits bottom. + expect(controller.position.pixels, equals(400.0)); + expect(tester.getRect(find.byKey(const ValueKey('Item 10'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, -400.0, 800.0, -300.0))); + for (int i = 0; i < 10; ++i) { + await tester.sendKeyDownEvent(modifierKey); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyUpEvent(modifierKey); + await tester.pumpAndSettle(); + } + // Goes up two past "center" where it started, so negative. + expect(controller.position.pixels, equals(-100.0)); + expect(tester.getRect(find.byKey(const ValueKey('Item 10'), skipOffstage: false)), equals(const Rect.fromLTRB(0.0, 100.0, 800.0, 200.0))); + + // TODO(gspencergoog): Once we can test against TargetPlatform.macOS instead + // of Platform.isMacOS, don't skip this on web anymore. + // https://github.com/flutter/flutter/issues/31366 + }, skip: kIsWeb); }