diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index a31e343b51..05a417db95 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -42,6 +42,8 @@ class MaterialPageRoute extends PageRoute with MaterialRouteTransitionMixi super.fullscreenDialog, super.allowSnapshotting = true, super.barrierDismissible = false, + super.traversalEdgeBehavior, + super.directionalTraversalEdgeBehavior, }) { assert(opaque); } diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index f289a6ae43..8fb574db3f 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -1358,6 +1358,7 @@ class FocusScopeNode extends FocusNode { super.skipTraversal, super.canRequestFocus, this.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop, + this.directionalTraversalEdgeBehavior = TraversalEdgeBehavior.stop, }) : super(descendantsAreFocusable: true); @override @@ -1374,6 +1375,13 @@ class FocusScopeNode extends FocusNode { /// and apply the new behavior. TraversalEdgeBehavior traversalEdgeBehavior; + /// Controls the directional transfer of focus when the focus is on the first or last item. + /// + /// Changing this field value has no immediate effect on the UI. Instead, next time + /// focus traversal takes place [FocusTraversalPolicy] will read this value + /// and apply the new behavior. + TraversalEdgeBehavior directionalTraversalEdgeBehavior; + /// Returns true if this scope is the focused child of its parent scope. bool get isFirstFocus => enclosingScope!.focusedChild == this; diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 20dd29dad7..64dbe58bf8 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -93,8 +93,13 @@ enum TraversalDirection { left, } -/// Controls the transfer of focus beyond the first and the last items of a -/// [FocusScopeNode]. +/// Controls the focus transfer at the edges of a [FocusScopeNode]. +/// For movement transfers (previous or next), the edge represents +/// the first or last items. For directional transfers, the edge +/// represents the outermost items of the [FocusScopeNode], For example: +/// for moving downwards, the edge node is the one with the largest bottom +/// coordinate; for moving leftwards, the edge node is the one with the +/// smallest x coordinate. /// /// This enumeration only controls the traversal behavior performed by /// [FocusTraversalPolicy]. Other methods of focus transfer, such as direct @@ -108,10 +113,16 @@ enum TraversalDirection { enum TraversalEdgeBehavior { /// Keeps the focus among the items of the focus scope. /// - /// Requesting the next focus after the last focusable item will transfer the - /// focus to the first item, and requesting focus previous to the first item - /// will transfer the focus to the last item, thus forming a closed loop of - /// focusable items. + /// Transfer focus to the edge node in the opposite direction of [FocusScopeNode] + /// as the edge node continues to move, thus forming a closed loop of focusable items. + /// + /// For moving transfers, requesting the next focus after the last focusable item will + /// transfer focus to the first item, and requesting focus before the first item will + /// transfer focus to the last item. + /// + /// For directional transfers, continuing to request right focus at the rightmost node + /// will transfer focus to the leftmost node. Continuing to request left focus at the + /// leftmost node will transfer focus to the rightmost node. closedLoop, /// Allows the focus to leave the [FlutterView]. @@ -137,6 +148,11 @@ enum TraversalEdgeBehavior { /// If there is no parent scope above the current scope, fallback to /// [closedLoop] behavior. parentScope, + + /// Stops the focus traversal at the edge of the focus scope. + /// + /// Keeps the focus in its current position when it reaches the edge of a focus scope. + stop, } /// Determines how focusable widgets are traversed within a [FocusTraversalGroup]. @@ -618,6 +634,8 @@ abstract class FocusTraversalPolicy with Diagnosticable { alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, forward: forward, ); + case TraversalEdgeBehavior.stop: + return false; } } if (!forward && focusedChild == sortedNodes.first) { @@ -645,6 +663,8 @@ abstract class FocusTraversalPolicy with Diagnosticable { alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, forward: forward, ); + case TraversalEdgeBehavior.stop: + return false; } } @@ -702,14 +722,24 @@ class _DirectionalPolicyData { /// follow the same path on the way up as it did on the way down, since changing /// the axis of motion resets the history. /// -/// This class implements an algorithm that considers an infinite band extending -/// along the direction of movement, the width or height (depending on -/// direction) of the currently focused widget, and finds the closest widget in -/// that band along the direction of movement. If nothing is found in that band, -/// then it picks the widget with an edge closest to the band in the +/// This class implements an algorithm that considers an band extending +/// along the direction of movement within the [FocusScope], the width or height +/// (depending on direction) of the currently focused widget, and finds the closest +/// widget inthat band along the direction of movement. If nothing is found in that +/// band,then it picks the widget with an edge closest to the band in the /// perpendicular direction. If two out-of-band widgets are the same distance /// from the band, then it picks the one closest along the direction of -/// movement. +/// movement. When reaching the edge in the direction specified by [FocusScope], +/// different behaviors are taken according to [FocusScopeNode.directionalTraversalEdgeBehavior]. +/// For [TraversalEdgeBehavior.closedLoop], the algorithm will reselect +/// the farthest node in the opposite direction within the band. For +/// [TraversalEdgeBehavior.parentScope], the band will extend to the parent +/// [FocusScopeNode],and if it is still an edge node in the parent, it will continue +/// to search according to the parent's [FocusScopeNode.directionalTraversalEdgeBehavior], +/// If there is no parent scope above the current scope, fallback to +/// [TraversalEdgeBehavior.closedLoop] behavior. For [TraversalEdgeBehavior.leaveFlutterView], +/// the focus will be lost. For [TraversalEdgeBehavior.stop], the current focused +/// element will remain. /// /// The goal of this algorithm is to pick a widget that (to the user) doesn't /// appear to traverse along the wrong axis, as it might if it only sorted @@ -779,6 +809,120 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { return sorted.firstOrNull; } + FocusNode? _findNextFocusInDirection( + FocusNode focusedChild, + Iterable traversalDescendants, + TraversalDirection direction, { + bool forward = true, + }) { + final ScrollableState? focusedScrollable = Scrollable.maybeOf(focusedChild.context!); + switch (direction) { + case TraversalDirection.down: + case TraversalDirection.up: + Iterable eligibleNodes = _sortAndFilterVertically( + direction, + focusedChild.rect, + traversalDescendants, + forward: forward, + ); + if (eligibleNodes.isEmpty) { + break; + } + if (focusedScrollable != null && !focusedScrollable.position.atEdge) { + final Iterable filteredEligibleNodes = eligibleNodes.where( + (FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable, + ); + if (filteredEligibleNodes.isNotEmpty) { + eligibleNodes = filteredEligibleNodes; + } + } + if (direction == TraversalDirection.up) { + eligibleNodes = eligibleNodes.toList().reversed; + } + // Find any nodes that intersect the band of the focused child. + final Rect band = Rect.fromLTRB( + focusedChild.rect.left, + -double.infinity, + focusedChild.rect.right, + double.infinity, + ); + final Iterable inBand = eligibleNodes.where( + (FocusNode node) => !node.rect.intersect(band).isEmpty, + ); + if (inBand.isNotEmpty) { + if (forward) { + return _sortByDistancePreferVertical(focusedChild.rect.center, inBand).first; + } + return _sortByDistancePreferVertical(focusedChild.rect.center, inBand).last; + } + // Only out-of-band targets are eligible, so pick the one that is + // closest to the center line horizontally, and if any are the same + // distance horizontally, pick the closest one of those vertically. + if (forward) { + return _sortClosestEdgesByDistancePreferHorizontal( + focusedChild.rect.center, + eligibleNodes, + ).first; + } + return _sortClosestEdgesByDistancePreferHorizontal( + focusedChild.rect.center, + eligibleNodes, + ).last; + case TraversalDirection.right: + case TraversalDirection.left: + Iterable eligibleNodes = _sortAndFilterHorizontally( + direction, + focusedChild.rect, + traversalDescendants, + forward: forward, + ); + if (eligibleNodes.isEmpty) { + break; + } + if (focusedScrollable != null && !focusedScrollable.position.atEdge) { + final Iterable filteredEligibleNodes = eligibleNodes.where( + (FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable, + ); + if (filteredEligibleNodes.isNotEmpty) { + eligibleNodes = filteredEligibleNodes; + } + } + if (direction == TraversalDirection.left) { + eligibleNodes = eligibleNodes.toList().reversed; + } + // Find any nodes that intersect the band of the focused child. + final Rect band = Rect.fromLTRB( + -double.infinity, + focusedChild.rect.top, + double.infinity, + focusedChild.rect.bottom, + ); + final Iterable inBand = eligibleNodes.where( + (FocusNode node) => !node.rect.intersect(band).isEmpty, + ); + if (inBand.isNotEmpty) { + if (forward) { + return _sortByDistancePreferHorizontal(focusedChild.rect.center, inBand).first; + } + return _sortByDistancePreferHorizontal(focusedChild.rect.center, inBand).last; + } + // Only out-of-band targets are eligible, so pick the one that is + // closest to the center line vertically, and if any are the same + // distance vertically, pick the closest one of those horizontally. + if (forward) { + return _sortClosestEdgesByDistancePreferVertical( + focusedChild.rect.center, + eligibleNodes, + ).first; + } + return _sortClosestEdgesByDistancePreferVertical( + focusedChild.rect.center, + eligibleNodes, + ).last; + } + return null; + } + static int _verticalCompare(Offset target, Offset a, Offset b) { return (a.dy - target.dy).abs().compareTo((b.dy - target.dy).abs()); } @@ -906,15 +1050,22 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { Iterable _sortAndFilterHorizontally( TraversalDirection direction, Rect target, - Iterable nodes, - ) { + Iterable nodes, { + bool forward = true, + }) { assert(direction == TraversalDirection.left || direction == TraversalDirection.right); final List sorted = nodes.where(switch (direction) { TraversalDirection.left => - (FocusNode node) => node.rect != target && node.rect.center.dx <= target.left, + (FocusNode node) => + node.rect != target && + (forward ? node.rect.center.dx <= target.left : node.rect.center.dx >= target.left), TraversalDirection.right => - (FocusNode node) => node.rect != target && node.rect.center.dx >= target.right, + (FocusNode node) => + node.rect != target && + (forward + ? node.rect.center.dx >= target.right + : node.rect.center.dx <= target.right), TraversalDirection.up || TraversalDirection.down => throw ArgumentError('Invalid direction $direction'), }).toList(); @@ -932,15 +1083,22 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { Iterable _sortAndFilterVertically( TraversalDirection direction, Rect target, - Iterable nodes, - ) { + Iterable nodes, { + bool forward = true, + }) { assert(direction == TraversalDirection.up || direction == TraversalDirection.down); final List sorted = nodes.where(switch (direction) { TraversalDirection.up => - (FocusNode node) => node.rect != target && node.rect.center.dy <= target.top, + (FocusNode node) => + node.rect != target && + (forward ? node.rect.center.dy <= target.top : node.rect.center.dy >= target.top), TraversalDirection.down => - (FocusNode node) => node.rect != target && node.rect.center.dy >= target.bottom, + (FocusNode node) => + node.rect != target && + (forward + ? node.rect.center.dy >= target.bottom + : node.rect.center.dy <= target.bottom), TraversalDirection.left || TraversalDirection.right => throw ArgumentError('Invalid direction $direction'), }).toList(); @@ -1048,6 +1206,98 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { } } + bool _requestTraversalFocusInDirection( + FocusNode currentNode, + FocusNode node, + FocusScopeNode nearestScope, + TraversalDirection direction, + ) { + if (node is FocusScopeNode) { + if (node.focusedChild != null) { + return _requestTraversalFocusInDirection(currentNode, node.focusedChild!, node, direction); + } + final FocusNode firstNode = findFirstFocusInDirection(node, direction) ?? currentNode; + switch (direction) { + case TraversalDirection.up: + case TraversalDirection.left: + requestFocusCallback( + firstNode, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + ); + case TraversalDirection.right: + case TraversalDirection.down: + requestFocusCallback( + firstNode, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); + } + return true; + } + final bool nodeHadPrimaryFocus = node.hasPrimaryFocus; + switch (direction) { + case TraversalDirection.up: + case TraversalDirection.left: + requestFocusCallback( + node, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + ); + case TraversalDirection.right: + case TraversalDirection.down: + requestFocusCallback(node, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); + } + return !nodeHadPrimaryFocus; + } + + bool _onEdgeForDirection( + FocusNode currentNode, + FocusNode focusedChild, + TraversalDirection direction, { + FocusScopeNode? scope, + }) { + FocusScopeNode nearestScope = scope ?? currentNode.nearestScope!; + FocusNode? found; + switch (nearestScope.directionalTraversalEdgeBehavior) { + case TraversalEdgeBehavior.leaveFlutterView: + focusedChild.unfocus(); + return false; + case TraversalEdgeBehavior.parentScope: + final FocusScopeNode? parentScope = nearestScope.enclosingScope; + if (parentScope != null && parentScope != FocusManager.instance.rootScope) { + invalidateScopeData(nearestScope); + nearestScope = parentScope; + invalidateScopeData(nearestScope); + found = _findNextFocusInDirection( + focusedChild, + nearestScope.traversalDescendants, + direction, + ); + if (found == null) { + return _onEdgeForDirection(currentNode, focusedChild, direction, scope: nearestScope); + } + } else { + found = _findNextFocusInDirection( + focusedChild, + nearestScope.traversalDescendants, + direction, + forward: false, + ); + } + case TraversalEdgeBehavior.closedLoop: + found = _findNextFocusInDirection( + focusedChild, + nearestScope.traversalDescendants, + direction, + forward: false, + ); + case TraversalEdgeBehavior.stop: + return false; + } + if (found != null) { + return _requestTraversalFocusInDirection(currentNode, found, nearestScope, direction); + } + return false; + } + /// Focuses the next widget in the given [direction] in the [FocusScope] that /// contains the [currentNode]. /// @@ -1091,115 +1341,16 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild)) { return true; } - FocusNode? found; - final ScrollableState? focusedScrollable = Scrollable.maybeOf(focusedChild.context!); - switch (direction) { - case TraversalDirection.down: - case TraversalDirection.up: - Iterable eligibleNodes = _sortAndFilterVertically( - direction, - focusedChild.rect, - nearestScope.traversalDescendants, - ); - if (eligibleNodes.isEmpty) { - break; - } - if (focusedScrollable != null && !focusedScrollable.position.atEdge) { - final Iterable filteredEligibleNodes = eligibleNodes.where( - (FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable, - ); - if (filteredEligibleNodes.isNotEmpty) { - eligibleNodes = filteredEligibleNodes; - } - } - if (direction == TraversalDirection.up) { - eligibleNodes = eligibleNodes.toList().reversed; - } - // Find any nodes that intersect the band of the focused child. - final Rect band = Rect.fromLTRB( - focusedChild.rect.left, - -double.infinity, - focusedChild.rect.right, - double.infinity, - ); - final Iterable inBand = eligibleNodes.where( - (FocusNode node) => !node.rect.intersect(band).isEmpty, - ); - if (inBand.isNotEmpty) { - found = _sortByDistancePreferVertical(focusedChild.rect.center, inBand).first; - break; - } - // Only out-of-band targets are eligible, so pick the one that is - // closest to the center line horizontally, and if any are the same - // distance horizontally, pick the closest one of those vertically. - found = - _sortClosestEdgesByDistancePreferHorizontal( - focusedChild.rect.center, - eligibleNodes, - ).first; - case TraversalDirection.right: - case TraversalDirection.left: - Iterable eligibleNodes = _sortAndFilterHorizontally( - direction, - focusedChild.rect, - nearestScope.traversalDescendants, - ); - if (eligibleNodes.isEmpty) { - break; - } - if (focusedScrollable != null && !focusedScrollable.position.atEdge) { - final Iterable filteredEligibleNodes = eligibleNodes.where( - (FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable, - ); - if (filteredEligibleNodes.isNotEmpty) { - eligibleNodes = filteredEligibleNodes; - } - } - if (direction == TraversalDirection.left) { - eligibleNodes = eligibleNodes.toList().reversed; - } - // Find any nodes that intersect the band of the focused child. - final Rect band = Rect.fromLTRB( - -double.infinity, - focusedChild.rect.top, - double.infinity, - focusedChild.rect.bottom, - ); - final Iterable inBand = eligibleNodes.where( - (FocusNode node) => !node.rect.intersect(band).isEmpty, - ); - if (inBand.isNotEmpty) { - found = _sortByDistancePreferHorizontal(focusedChild.rect.center, inBand).first; - break; - } - // Only out-of-band targets are eligible, so pick the one that is - // closest to the center line vertically, and if any are the same - // distance vertically, pick the closest one of those horizontally. - found = - _sortClosestEdgesByDistancePreferVertical( - focusedChild.rect.center, - eligibleNodes, - ).first; - } + final FocusNode? found = _findNextFocusInDirection( + focusedChild, + nearestScope.traversalDescendants, + direction, + ); if (found != null) { _pushPolicyData(direction, nearestScope, focusedChild); - switch (direction) { - case TraversalDirection.up: - case TraversalDirection.left: - requestFocusCallback( - found, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, - ); - case TraversalDirection.down: - case TraversalDirection.right: - requestFocusCallback( - found, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, - ); - } - return true; + return _requestTraversalFocusInDirection(currentNode, found, nearestScope, direction); } - return false; + return _onEdgeForDirection(currentNode, focusedChild, direction); } } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index aa593aafce..4281901b2b 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -1255,6 +1255,12 @@ class DefaultTransitionDelegate extends TransitionDelegate { /// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior} const TraversalEdgeBehavior kDefaultRouteTraversalEdgeBehavior = TraversalEdgeBehavior.parentScope; +/// The default value of [Navigator.routeDirectionalTraversalEdgeBehavior]. +/// +/// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior} +const TraversalEdgeBehavior kDefaultRouteDirectionalTraversalEdgeBehavior = + TraversalEdgeBehavior.stop; + /// A widget that manages a set of child widgets with a stack discipline. /// /// Many apps have a navigator near the top of their widget hierarchy in order @@ -1576,6 +1582,7 @@ class Navigator extends StatefulWidget { this.requestFocus = true, this.restorationScopeId, this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior, + this.routeDirectionalTraversalEdgeBehavior = kDefaultRouteDirectionalTraversalEdgeBehavior, this.onDidRemovePage, }); @@ -1724,6 +1731,12 @@ class Navigator extends StatefulWidget { /// {@endtemplate} final TraversalEdgeBehavior routeTraversalEdgeBehavior; + /// Controls the directional transfer of focus beyond the first and the last + /// items of a focus scope that defines focus traversal of widgets within a route. + /// + /// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior} + final TraversalEdgeBehavior routeDirectionalTraversalEdgeBehavior; + /// The name for the default route of the application. /// /// See also: diff --git a/packages/flutter/lib/src/widgets/pages.dart b/packages/flutter/lib/src/widgets/pages.dart index 0546548447..2d16ed0880 100644 --- a/packages/flutter/lib/src/widgets/pages.dart +++ b/packages/flutter/lib/src/widgets/pages.dart @@ -25,6 +25,8 @@ abstract class PageRoute extends ModalRoute { PageRoute({ super.settings, super.requestFocus, + super.traversalEdgeBehavior, + super.directionalTraversalEdgeBehavior, this.fullscreenDialog = false, this.allowSnapshotting = true, bool barrierDismissible = false, diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 957e426922..c676858f98 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -1092,13 +1092,21 @@ class _ModalScopeState extends State<_ModalScope> { void _updateFocusScopeNode() { final TraversalEdgeBehavior traversalEdgeBehavior; + final TraversalEdgeBehavior directionalTraversalEdgeBehavior; final ModalRoute route = widget.route; if (route.traversalEdgeBehavior != null) { traversalEdgeBehavior = route.traversalEdgeBehavior!; } else { traversalEdgeBehavior = route.navigator!.widget.routeTraversalEdgeBehavior; } + if (route.directionalTraversalEdgeBehavior != null) { + directionalTraversalEdgeBehavior = route.directionalTraversalEdgeBehavior!; + } else { + directionalTraversalEdgeBehavior = + route.navigator!.widget.routeDirectionalTraversalEdgeBehavior; + } focusScopeNode.traversalEdgeBehavior = traversalEdgeBehavior; + focusScopeNode.directionalTraversalEdgeBehavior = directionalTraversalEdgeBehavior; if (route.isCurrent && _shouldRequestFocus) { route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode); } @@ -1231,7 +1239,13 @@ class _ModalScopeState extends State<_ModalScope> { /// * [Route], which further documents the meaning of the `T` generic type argument. abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute { /// Creates a route that blocks interaction with previous routes. - ModalRoute({super.settings, super.requestFocus, this.filter, this.traversalEdgeBehavior}); + ModalRoute({ + super.settings, + super.requestFocus, + this.filter, + this.traversalEdgeBehavior, + this.directionalTraversalEdgeBehavior, + }); /// The filter to add to the barrier. /// @@ -1245,6 +1259,12 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute extends TransitionRoute with LocalHistoryRoute extends ModalRoute { /// Initializes the [PopupRoute]. - PopupRoute({super.settings, super.requestFocus, super.filter, super.traversalEdgeBehavior}); + PopupRoute({ + super.settings, + super.requestFocus, + super.filter, + super.traversalEdgeBehavior, + super.directionalTraversalEdgeBehavior, + }); @override bool get opaque => false; @@ -2497,6 +2523,7 @@ class RawDialogRoute extends PopupRoute { super.requestFocus, this.anchorPoint, super.traversalEdgeBehavior, + super.directionalTraversalEdgeBehavior, }) : _pageBuilder = pageBuilder, _barrierDismissible = barrierDismissible, _barrierLabel = barrierLabel, diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index 9d64ca85a6..ded96da229 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -3410,8 +3410,8 @@ void main() { }); // This test creates a FocusScopeNode configured to traverse focus in a closed - // loop. After traversing one loop, it changes the behavior to leave the - // FlutterView, then verifies that the new behavior did indeed take effect. + // loop. After traversing one loop, it changes the behavior to `leaveFlutterView` and `stop`, + // then verifies that the new behavior did indeed take effect. testWidgets('FocusScopeNode.traversalEdgeBehavior takes effect after update', ( WidgetTester tester, ) async { @@ -3481,6 +3481,18 @@ void main() { expect(nodeA.hasFocus, false); expect(nodeB.hasFocus, false); + // Change the behavior and verify that the new behavior is in effect. + scope.traversalEdgeBehavior = TraversalEdgeBehavior.stop; + expect(scope.traversalEdgeBehavior, TraversalEdgeBehavior.stop); + + // B -> A, but stop at the edge + nodeB.requestFocus(); + await tester.pump(); + expect(nodeB.hasFocus, true); + expect(await nextFocus(), false); + expect(nodeA.hasFocus, false); + expect(nodeB.hasFocus, true); + // Change the behavior back to closedLoop and verify it's in effect. Also, // this time traverse in the opposite direction. nodeA.requestFocus(); @@ -3558,6 +3570,179 @@ void main() { await tester.pump(); expect(calledCallback, isTrue); }); + + testWidgets('Edge cases for inDirection', (WidgetTester tester) async { + List focus = List.generate(6, (int _) => null); + final List nodes = List.generate( + 6, + (int index) => FocusNode(debugLabel: 'Node $index'), + ); + final FocusScopeNode childScope = FocusScopeNode(debugLabel: 'Child Scope'); + addTearDown(() { + for (final FocusNode node in nodes) { + node.dispose(); + } + childScope.dispose(); + }); + + Focus makeFocus(int index) { + return Focus( + debugLabel: '[$index]', + focusNode: nodes[index], + onFocusChange: (bool isFocused) => focus[index] = isFocused, + child: const SizedBox(width: 100, height: 100), + ); + } + + Future pumpApp() async { + /// Layout is: + /// [0] + /// ---------Child FocusScope--------- + /// [1] + /// [2] + /// [3] + /// ---------Child FocusScope End--------- + /// [4] [5] + Widget home = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.center, children: [makeFocus(0)]), + FocusScope( + node: childScope, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + makeFocus(1), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + makeFocus(2), + Padding(padding: const EdgeInsets.only(top: 100), child: makeFocus(3)), + ], + ), + ], + ), + ), + Row(children: [makeFocus(4), makeFocus(5)]), + ], + ); + // Prevent the arrow keys from scrolling on the web. + if (isBrowser) { + home = Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent( + TraversalDirection.up, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent( + TraversalDirection.down, + ), + }, + child: home, + ); + } + await tester.pumpWidget(MaterialApp(home: home)); + } + + await pumpApp(); + + void clear() { + focus = List.generate(focus.length, (int _) => null); + } + + Future resetTo(int index) async { + nodes[index].requestFocus(); + await tester.pump(); + clear(); + } + + // childScope's directionalTraversalEdgeBehavior is TraversalEdgeBehavior.stop + // focus is should not change + childScope.directionalTraversalEdgeBehavior = TraversalEdgeBehavior.stop; + await resetTo(3); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, null, null, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, null, null, null])); + clear(); + + // childScope's directionalTraversalEdgeBehavior is TraversalEdgeBehavior.closedLoop + // focus is should change in a loop + childScope.directionalTraversalEdgeBehavior = TraversalEdgeBehavior.closedLoop; + await resetTo(3); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, true, null, false, null, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, false, true, null, null, null])); + clear(); + + // childScope's directionalTraversalEdgeBehavior is TraversalEdgeBehavior.parentScope + // focus can change to the parent scope + childScope.directionalTraversalEdgeBehavior = TraversalEdgeBehavior.parentScope; + await resetTo(3); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, false, null, true])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([true, false, null, null, null, null])); + clear(); + + // childScope's directionalTraversalEdgeBehavior is TraversalEdgeBehavior.leaveFlutterView + // focus will be lost + childScope.directionalTraversalEdgeBehavior = TraversalEdgeBehavior.leaveFlutterView; + await resetTo(3); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, false, null, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, false, null, null, null, null])); + clear(); + }); + + testWidgets('When there is no focused node, the focus can be set to the FocusScopeNode.', ( + WidgetTester tester, + ) async { + final FocusScopeNode scope = FocusScopeNode(); + final FocusScopeNode childScope = FocusScopeNode(); + final FocusNode nodeA = FocusNode(); + addTearDown(() { + scope.dispose(); + childScope.dispose(); + nodeA.dispose(); + }); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: scope, + child: FocusScope( + node: childScope, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Focus(focusNode: nodeA, child: const Text('A')), + ), + ), + ), + ), + ); + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expect(childScope.hasFocus, isTrue); + expect(nodeA.hasFocus, isFalse); + }); } class TestRoute extends PageRouteBuilder { diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 935e58297f..ba22b44994 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -5621,6 +5621,153 @@ void main() { expect(results, hasLength(1)); expect(results.first, result); }); + + testWidgets('Directional focus traversal behavior with nested Navigators.', ( + WidgetTester tester, + ) async { + final GlobalKey navigatorKey = GlobalKey(); + List focus = List.generate(4, (int _) => null); + final List nodes = List.generate( + 6, + (int index) => FocusNode(debugLabel: 'Node $index'), + ); + addTearDown(() { + for (final FocusNode node in nodes) { + node.dispose(); + } + }); + Focus makeFocus(int index) { + return Focus( + debugLabel: '[$index]', + focusNode: nodes[index], + onFocusChange: (bool isFocused) => focus[index] = isFocused, + child: const SizedBox(width: 100, height: 100), + ); + } + + Future pumpApp() async { + Widget home = Column( + children: [ + makeFocus(0), + Navigator( + key: navigatorKey, + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return const Center(child: Text('home')); + }, + ); + }, + ), + makeFocus(3), + ], + ); + // Prevent the arrow keys from scrolling on the web. + if (isBrowser) { + home = Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent( + TraversalDirection.up, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent( + TraversalDirection.down, + ), + }, + child: home, + ); + } + await tester.pumpWidget(MaterialApp(home: home)); + } + + /// Layout is: + /// ---------MaterialApp--------- + /// [0] + /// ---------Nested Navigator--------- + /// [1] + /// [2] + /// ---------Nested Navigator End--------- + /// [3] + /// ---------MaterialApp End--------- + void pushWith(TraversalEdgeBehavior behavior) { + navigatorKey.currentState!.push( + MaterialPageRoute( + directionalTraversalEdgeBehavior: behavior, + builder: (BuildContext context) { + return Column(children: [makeFocus(1), makeFocus(2)]); + }, + ), + ); + } + + void clear() { + focus = List.generate(focus.length, (int _) => null); + } + + await pumpApp(); + Future resetTo(int index) async { + nodes[index].requestFocus(); + await tester.pump(); + clear(); + } + + pushWith(TraversalEdgeBehavior.stop); + await tester.pumpAndSettle(); + await resetTo(2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, null, null, null])); + clear(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + pushWith(TraversalEdgeBehavior.closedLoop); + await tester.pumpAndSettle(); + await resetTo(2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, true, false, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, false, true, null])); + clear(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + pushWith(TraversalEdgeBehavior.parentScope); + await tester.pumpAndSettle(); + await resetTo(2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, false, true])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([true, false, null, null])); + clear(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + pushWith(TraversalEdgeBehavior.leaveFlutterView); + await tester.pumpAndSettle(); + await resetTo(2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(focus, orderedEquals([null, null, false, null])); + clear(); + await resetTo(1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(focus, orderedEquals([null, false, null, null])); + clear(); + }); } typedef AnnouncementCallBack = void Function(Route?); diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index fe4b1e00fd..b9a574dd55 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -2625,6 +2625,37 @@ void main() { expect(notifications.last.canHandlePop, isTrue); }); }); + + testWidgets("ModalRoute's default directionalTraversalEdgeBehavior is the same as Navigator's", ( + WidgetTester tester, + ) async { + Future pumpWith(TraversalEdgeBehavior behavior) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + key: UniqueKey(), + routeDirectionalTraversalEdgeBehavior: behavior, + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return const Center(child: Text('page')); + }, + settings: settings, + ); + }, + ), + ), + ); + } + + for (final TraversalEdgeBehavior element in TraversalEdgeBehavior.values) { + await pumpWith(element); + await tester.pumpAndSettle(); + final FocusScopeNode focusScope = FocusScope.of(tester.element(find.text('page'))); + expect(focusScope.directionalTraversalEdgeBehavior, element); + } + }); } double _getOpacity(GlobalKey key, WidgetTester tester) {