From 81baee43603069da75a55a41a1c1840107b09173 Mon Sep 17 00:00:00 2001 From: Dwayne Slater Date: Wed, 6 Apr 2022 09:31:12 -0700 Subject: [PATCH] Configurable padding around FocusNodes in Scrollables (#96815) --- .../lib/src/widgets/focus_manager.dart | 24 + .../lib/src/widgets/focus_traversal.dart | 2 +- .../flutter/lib/src/widgets/page_view.dart | 1 + .../lib/src/widgets/scroll_position.dart | 13 +- .../flutter/lib/src/widgets/scrollable.dart | 11 + .../test/widgets/focus_traversal_test.dart | 433 ++++++++++++++++++ 6 files changed, 482 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 71e49045ac..c0c87fbef9 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -759,6 +759,16 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { return null; } + /// Returns the amount of additional space to reveal around the attached widget + /// when focused inside a scrolling container via [Scrollable.ensureVisible]. + /// + /// For example, a value of `EdgeInsets.all(16.0)` ensures 16 pixels of + /// the adjacent widget are visible when this node receives focus. + /// + /// By default, this returns [FocusManager.defaultEnsureVisiblePadding] from the + /// associated [FocusManager], or [EdgeInsets.zero]. + EdgeInsets get ensureVisiblePadding => _manager?.defaultEnsureVisiblePadding ?? EdgeInsets.zero; + /// Returns the size of the attached widget's [RenderObject], in logical /// units. /// @@ -1710,6 +1720,20 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { return handled; } + /// The default amount of additonal space to reveal when a widget is focused + /// inside a scrolling container via [Scrollable.ensureVisible]. + /// + /// Defaults to [EdgeInsets.zero], which does not add any additional space + /// when widgets are revealed. + /// + /// For example, a value of `EdgeInsets.all(16.0)` ensures 16 pixels of + /// the adjacent widget are visible when focusing a widget inside of a + /// scrolling container. + /// + /// Individual [FocusNode]s may increase or decrease this padding, use + /// [FocusNode.ensureVisiblePadding] to obtain a node's desired padding. + EdgeInsets defaultEnsureVisiblePadding = EdgeInsets.zero; + /// The node that currently has the primary focus. FocusNode? get primaryFocus => _primaryFocus; FocusNode? _primaryFocus; diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 7a2d49030f..9ed8c34e27 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -36,7 +36,7 @@ void _focusAndEnsureVisible( ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, }) { node.requestFocus(); - Scrollable.ensureVisible(node.context!, alignment: 1.0, alignmentPolicy: alignmentPolicy); + Scrollable.ensureVisible(node.context!, alignment: 1.0, padding: node.ensureVisiblePadding, alignmentPolicy: alignmentPolicy); } // A class to temporarily hold information about FocusTraversalGroups when diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart index f095b4d636..b0c5206535 100644 --- a/packages/flutter/lib/src/widgets/page_view.dart +++ b/packages/flutter/lib/src/widgets/page_view.dart @@ -346,6 +346,7 @@ class _PagePosition extends ScrollPositionWithSingleContext implements PageMetri Future ensureVisible( RenderObject object, { double alignment = 0.0, + EdgeInsets padding = EdgeInsets.zero, Duration duration = Duration.zero, Curve curve = Curves.ease, ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 92b0c429d8..08962febe1 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -676,6 +676,10 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// Animates the position such that the given object is as visible as possible /// by just scrolling this position. /// + /// The [padding] is used to add extra space around the [object] when revealing it. + /// For example, `EdgeInsets.only(bottom: 16.0)` will ensure an additional 16 pixels + /// of space are visible below the [object]. + /// /// The optional `targetRenderObject` parameter is used to determine which area /// of that object should be as visible as possible. If `targetRenderObject` /// is null, the entire [RenderObject] (as defined by its @@ -686,9 +690,12 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// /// * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is /// applied, and the way the given `object` is aligned. + /// * [FocusNode.ensureVisiblePadding] which specifies the [padding] used when + /// a widget is focused via focus traversal. Future ensureVisible( RenderObject object, { double alignment = 0.0, + EdgeInsets padding = EdgeInsets.zero, Duration duration = Duration.zero, Curve curve = Curves.ease, ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, @@ -699,14 +706,18 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { final RenderAbstractViewport viewport = RenderAbstractViewport.of(object)!; assert(viewport != null); - Rect? targetRect; + Rect targetRect; if (targetRenderObject != null && targetRenderObject != object) { targetRect = MatrixUtils.transformRect( targetRenderObject.getTransformTo(object), object.paintBounds.intersect(targetRenderObject.paintBounds), ); + } else { + targetRect = object.paintBounds; } + targetRect = padding.inflateRect(targetRect); + double target; switch (alignmentPolicy) { case ScrollPositionAlignmentPolicy.explicit: diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index f5a6652475..6314beabee 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -311,9 +311,19 @@ class Scrollable extends StatefulWidget { /// Scrolls the scrollables that enclose the given context so as to make the /// given context visible. + /// + /// The [padding] is used to add extra space around the [context]'s + /// associated widget when revealing it. For example, `EdgeInsets.only(bottom: 16.0)` + /// will ensure an additional 16 pixels of space are visible below the widget. + /// + /// See also: + /// + /// * [FocusNode.ensureVisiblePadding] which specifies the [padding] used when + /// a widget is focused via focus traversal. static Future ensureVisible( BuildContext context, { double alignment = 0.0, + EdgeInsets padding = EdgeInsets.zero, Duration duration = Duration.zero, Curve curve = Curves.ease, ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, @@ -332,6 +342,7 @@ class Scrollable extends StatefulWidget { futures.add(scrollable.position.ensureVisible( context.findRenderObject()!, alignment: alignment, + padding: padding, duration: duration, curve: curve, alignmentPolicy: alignmentPolicy, diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index ff4c6e8054..cf38ee6ff8 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -1874,6 +1875,438 @@ void main() { expect(controller.offset, equals(0.0)); }, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347 + testWidgets('Focus traversal inside a vertical scrollable applies ensure visible padding.', (WidgetTester tester) async { + tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.all(50.0); + + addTearDown(() { + tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero; + }); + + const double minScrollExtent = 0.0; + const double maxScrollExtent = 700.0; + + final List items = List.generate(11, (int index) => index).toList(); + final List nodes = List.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList(); + final FocusNode topNode = FocusNode(debugLabel: 'Header'); + final FocusNode bottomNode = FocusNode(debugLabel: 'Footer'); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + Focus(focusNode: topNode, child: Container(height: 100)), + Expanded( + child: ListView( + controller: controller, + children: items.map((int item) { + return Focus( + focusNode: nodes[item], + child: Container(height: 100), + ); + }).toList(), + ), + ), + Focus(focusNode: bottomNode, child: Container(height: 100)), + ], + ), + ), + ); + + // Start at the top + expect(controller.offset, equals(0.0)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(topNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Enter the list. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Go down until we hit the bottom of the visible area, taking padding into account. + for (int i = 1; i <= 2; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); + } + + // Now keep going down, and the scrollable should scroll automatically. + for (int i = 3; i <= 10; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + final double expectedOffset = min(100.0 * (i - 3) + 50.0, maxScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset"); + } + + // Now go one more, and see that the footer gets focused. + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(bottomNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(nodes[10].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + // Now reverse directions and go back to the top. + + // These should not cause a scroll. + final double lowestOffset = controller.offset; + for (int i = 10; i >= 9; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll'); + } + + // These should all cause a scroll. + for (int i = 8; i >= 1; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + final double expectedOffset = max(100.0 * (i - 1) - 50.0, minScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll"); + } + + // Back at the top. + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Now we jump to the header. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(topNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + }, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347 + + testWidgets('Focus traversal inside a horizontal scrollable applies ensure visible padding.', (WidgetTester tester) async { + tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.all(50.0); + + addTearDown(() { + tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero; + }); + + const double minScrollExtent = 0.0; + const double maxScrollExtent = 500.0; + + final List items = List.generate(11, (int index) => index).toList(); + final List nodes = List.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList(); + final FocusNode leftNode = FocusNode(debugLabel: 'Left Side'); + final FocusNode rightNode = FocusNode(debugLabel: 'Right Side'); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Row( + children: [ + Focus(focusNode: leftNode, child: Container(width: 100)), + Expanded( + child: ListView( + scrollDirection: Axis.horizontal, + controller: controller, + children: items.map((int item) { + return Focus( + focusNode: nodes[item], + child: Container(width: 100), + ); + }).toList(), + ), + ), + Focus(focusNode: rightNode, child: Container(width: 100)), + ], + ), + ), + ); + + // Start at the right + expect(controller.offset, equals(0.0)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(leftNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Enter the list. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Go right until we hit the right of the visible area, taking padding into account. + for (int i = 1; i <= 4; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); + } + + // Now keep going right, and the scrollable should scroll automatically. + for (int i = 5; i <= 10; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + final double expectedOffset = min(100.0 * (i - 5) + 50.0, maxScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset"); + } + + // Now go one more, and see that the right edge gets focused. + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(rightNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(nodes[10].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + // Now reverse directions and go back to the left. + + // These should not cause a scroll. + final double lowestOffset = controller.offset; + for (int i = 10; i >= 7; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll'); + } + + // These should all cause a scroll. + for (int i = 6; i >= 1; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + final double expectedOffset = max(100.0 * (i - 1) - 50.0, minScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll"); + } + + // Back at the left side of the scrollable. + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Now we jump to the left edge of the app. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(leftNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + }, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347 + + testWidgets('Focus traversal inside a vertical scrollable applies asymmetric ensure visible padding.', (WidgetTester tester) async { + const double leadingPadding = 25.0; + const double trailingPadding = 50.0; + + tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.only(top: leadingPadding, bottom: trailingPadding); + + addTearDown(() { + tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero; + }); + + const double minScrollExtent = 0.0; + const double maxScrollExtent = 700.0; + + final List items = List.generate(11, (int index) => index).toList(); + final List nodes = List.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList(); + final FocusNode topNode = FocusNode(debugLabel: 'Header'); + final FocusNode bottomNode = FocusNode(debugLabel: 'Footer'); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + Focus(focusNode: topNode, child: Container(height: 100)), + Expanded( + child: ListView( + controller: controller, + children: items.map((int item) { + return Focus( + focusNode: nodes[item], + child: Container(height: 100), + ); + }).toList(), + ), + ), + Focus(focusNode: bottomNode, child: Container(height: 100)), + ], + ), + ), + ); + + // Start at the top + expect(controller.offset, equals(0.0)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(topNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Enter the list. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Go down until we hit the bottom of the visible area, taking padding into account. + for (int i = 1; i <= 2; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); + } + + // Now keep going down, and the scrollable should scroll automatically. + for (int i = 3; i <= 10; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + final double expectedOffset = min(100.0 * (i - 3) + trailingPadding, maxScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset"); + } + + // Now go one more, and see that the footer gets focused. + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(bottomNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(nodes[10].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + // Now reverse directions and go back to the top. + + // These should not cause a scroll. + final double lowestOffset = controller.offset; + for (int i = 10; i >= 9; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll'); + } + + // These should all cause a scroll. + for (int i = 8; i >= 1; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + final double expectedOffset = max(100.0 * (i - 1) - leadingPadding, minScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll"); + } + + // Back at the top. + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Now we jump to the header. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(topNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + }, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347 + + testWidgets('Focus traversal inside a horizontal scrollable applies asymmetric ensure visible padding.', (WidgetTester tester) async { + const double leadingPadding = 25.0; + const double trailingPadding = 50.0; + + tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.only(left: leadingPadding, right: trailingPadding); + + addTearDown(() { + tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero; + }); + + const double minScrollExtent = 0.0; + const double maxScrollExtent = 500.0; + + final List items = List.generate(11, (int index) => index).toList(); + final List nodes = List.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList(); + final FocusNode leftNode = FocusNode(debugLabel: 'Left Side'); + final FocusNode rightNode = FocusNode(debugLabel: 'Right Side'); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Row( + children: [ + Focus(focusNode: leftNode, child: Container(width: 100)), + Expanded( + child: ListView( + scrollDirection: Axis.horizontal, + controller: controller, + children: items.map((int item) { + return Focus( + focusNode: nodes[item], + child: Container(width: 100), + ); + }).toList(), + ), + ), + Focus(focusNode: rightNode, child: Container(width: 100)), + ], + ), + ), + ); + + // Start at the right + expect(controller.offset, equals(0.0)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(leftNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Enter the list. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Go right until we hit the right of the visible area, taking padding into account. + for (int i = 1; i <= 4; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); + } + + // Now keep going right, and the scrollable should scroll automatically. + for (int i = 5; i <= 10; ++i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + final double expectedOffset = min(100.0 * (i - 5) + trailingPadding, maxScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset"); + } + + // Now go one more, and see that the right edge gets focused. + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(rightNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(nodes[10].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(maxScrollExtent)); + + // Now reverse directions and go back to the left. + + // These should not cause a scroll. + final double lowestOffset = controller.offset; + for (int i = 10; i >= 7; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll'); + } + + // These should all cause a scroll. + for (int i = 6; i >= 1; --i) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + final double expectedOffset = max(100.0 * (i - 1) - leadingPadding, minScrollExtent); + expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll"); + } + + // Back at the left side of the scrollable. + expect(nodes[0].hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + + // Now we jump to the left edge of the app. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(leftNode.hasPrimaryFocus, isTrue); + expect(controller.offset, equals(0.0)); + }, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347 + testWidgets('Arrow focus traversal actions can be re-enabled for text fields.', (WidgetTester tester) async { final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey'); final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');