Unverified Commit 0190e404 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

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.
parent 459c7fb8
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection' show HashMap; import 'dart:collection' show HashMap;
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -20,6 +21,7 @@ import 'media_query.dart'; ...@@ -20,6 +21,7 @@ import 'media_query.dart';
import 'navigator.dart'; import 'navigator.dart';
import 'pages.dart'; import 'pages.dart';
import 'performance_overlay.dart'; import 'performance_overlay.dart';
import 'scrollable.dart';
import 'semantics_debugger.dart'; import 'semantics_debugger.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'text.dart'; import 'text.dart';
...@@ -1041,12 +1043,46 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1041,12 +1043,46 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
} }
final Map<LogicalKeySet, Intent> _keyMap = <LogicalKeySet, Intent>{ final Map<LogicalKeySet, Intent> _keyMap = <LogicalKeySet, Intent>{
// Next/previous keyboard traversal.
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key), LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key), LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), // Directional keyboard traversal. Not available on web.
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), if (!kIsWeb) ...<LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up), 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.enter): const Intent(ActivateAction.key),
LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key), LogicalKeySet(LogicalKeyboardKey.space): const Intent(SelectAction.key),
}; };
...@@ -1057,6 +1093,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1057,6 +1093,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
NextFocusAction.key: () => NextFocusAction(), NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(), PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(), DirectionalFocusAction.key: () => DirectionalFocusAction(),
ScrollAction.key: () => ScrollAction(),
}; };
@override @override
...@@ -1169,7 +1206,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1169,7 +1206,6 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
: _locale; : _locale;
assert(_debugCheckLocalizations(appLocale)); assert(_debugCheckLocalizations(appLocale));
return Shortcuts( return Shortcuts(
shortcuts: _keyMap, shortcuts: _keyMap,
child: Actions( child: Actions(
......
...@@ -1002,8 +1002,8 @@ class DirectionalFocusIntent extends Intent { ...@@ -1002,8 +1002,8 @@ class DirectionalFocusIntent extends Intent {
final bool ignoreTextFields; final bool ignoreTextFields;
} }
/// An [Action] that moves the focus to the focusable node in the given /// An [Action] that moves the focus to the focusable node in the direction
/// [direction] configured by the associated [DirectionalFocusIntent]. /// configured by the associated [DirectionalFocusIntent.direction].
/// ///
/// This is the [Action] associated with the [key] and bound by default to the /// This is the [Action] associated with the [key] and bound by default to the
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown], /// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
...@@ -1016,9 +1016,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase { ...@@ -1016,9 +1016,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase {
/// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent]. /// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent].
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction); static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
/// The direction in which to look for the next focusable node when invoked.
TraversalDirection direction;
@override @override
void invoke(FocusNode node, DirectionalFocusIntent intent) { void invoke(FocusNode node, DirectionalFocusIntent intent) {
if (!intent.ignoreTextFields || node.context.widget is! EditableText) { if (!intent.ignoreTextFields || node.context.widget is! EditableText) {
......
...@@ -11,13 +11,16 @@ import 'package:flutter/rendering.dart'; ...@@ -11,13 +11,16 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
import 'focus_manager.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'notification_listener.dart'; import 'notification_listener.dart';
import 'scroll_configuration.dart'; import 'scroll_configuration.dart';
import 'scroll_context.dart'; import 'scroll_context.dart';
import 'scroll_controller.dart'; import 'scroll_controller.dart';
import 'scroll_metrics.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scroll_position.dart'; import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart'; import 'scroll_position_with_single_context.dart';
...@@ -81,6 +84,7 @@ class Scrollable extends StatefulWidget { ...@@ -81,6 +84,7 @@ class Scrollable extends StatefulWidget {
this.controller, this.controller,
this.physics, this.physics,
@required this.viewportBuilder, @required this.viewportBuilder,
this.incrementCalculator,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.semanticChildCount, this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
...@@ -155,6 +159,19 @@ class Scrollable extends StatefulWidget { ...@@ -155,6 +159,19 @@ class Scrollable extends StatefulWidget {
/// slivers and sizes itself based on the size of the slivers. /// slivers and sizes itself based on the size of the slivers.
final ViewportBuilder viewportBuilder; 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 /// Whether the scroll actions introduced by this [Scrollable] are exposed
/// in the semantics tree. /// in the semantics tree.
/// ///
...@@ -767,3 +784,231 @@ class _RenderScrollSemantics extends RenderProxyBox { ...@@ -767,3 +784,231 @@ class _RenderScrollSemantics extends RenderProxyBox {
_innerNode = null; _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,
);
}
}
...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:io';
import 'dart:ui' as ui; 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_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -13,12 +16,14 @@ Future<void> pumpTest( ...@@ -13,12 +16,14 @@ Future<void> pumpTest(
TargetPlatform platform, { TargetPlatform platform, {
bool scrollable = true, bool scrollable = true,
bool reverse = false, bool reverse = false,
ScrollController controller,
}) async { }) async {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: ThemeData( theme: ThemeData(
platform: platform, platform: platform,
), ),
home: CustomScrollView( home: CustomScrollView(
controller: controller,
reverse: reverse, reverse: reverse,
physics: scrollable ? null : const NeverScrollableScrollPhysics(), physics: scrollable ? null : const NeverScrollableScrollPhysics(),
slivers: const <Widget>[ slivers: const <Widget>[
...@@ -31,6 +36,14 @@ Future<void> pumpTest( ...@@ -31,6 +36,14 @@ Future<void> pumpTest(
const double dragOffset = 200.0; 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) { double getScrollOffset(WidgetTester tester) {
final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
return viewport.offset.pixels; return viewport.offset.pixels;
...@@ -267,4 +280,354 @@ void main() { ...@@ -267,4 +280,354 @@ void main() {
expect(getScrollOffset(tester), 20.0); 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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
autofocus: index == 0,
child: SizedBox(key: ValueKey<String>('Box $index'), height: 50.0),
),
);
},
),
),
),
);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<String>('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<String>('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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
autofocus: index == 0,
child: SizedBox(key: ValueKey<String>('Box $index'), height: 50.0),
),
);
},
),
),
),
);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<String>('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<String>('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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
autofocus: index == 0,
child: SizedBox(key: ValueKey<String>('Box $index'), width: 50.0),
),
);
},
),
),
),
);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
autofocus: index == 0,
child: SizedBox(key: ValueKey<String>('Box $index'), width: 50.0),
),
);
},
),
),
),
),
);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
focusNode: focusNode,
child: SizedBox(key: ValueKey<String>('Box $index'), height: 50.0),
),
);
},
),
),
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<String>('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<String>('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<Widget>.generate(
20,
(int index) {
return SliverToBoxAdapter(
child: Focus(
focusNode: focusNode,
child: SizedBox(key: ValueKey<String>('Box $index'), width: 50.0),
),
);
},
),
),
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(0.0));
expect(tester.getRect(find.byKey(const ValueKey<String>('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<String>('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<String> items = List<String>.generate(20, (int index) => 'Item $index');
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
platform: TargetPlatform.fuchsia,
),
home: CustomScrollView(
controller: controller,
center: const ValueKey<String>('Center'),
slivers: items.map<Widget>(
(String item) {
return SliverToBoxAdapter(
key: item == 'Item 10' ? const ValueKey<String>('Center') : null,
child: Focus(
autofocus: item == 'Item 10',
child: Container(
key: ValueKey<String>(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<String>('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<String>('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<String>('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);
} }
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