Directional focus edge traversal behavior. (#161285)

Fixes: #160843 

This PR adds edge behavior feature for traversing focus using arrow
keys. This allows cycling through the focus within the `FocusScope` in a
closed loop using the arrow keys. Which may be needed by TV application
developers.

Additionally, as in case #160843, `TraversalEdgeBehavior.parentScope`
can be used to allow nested Navigators to traverse focus beyond the
Navigator using arrow keys.

```dart
MaterialApp(
  home: Column(
    children: <Widget>[
      makeFocus(0),
      Navigator(
        onGenerateRoute: (RouteSettings settings) {
          return MaterialPageRoute<void>(
            traversalDirectionedEdgeBehavior: TraversalEdgeBehavior.parentScope,
            builder: (BuildContext context) {
              return Column(
                children: <Widget>[
                  makeFocus(1),
                  makeFocus(2),
                ],
              );
            },
          );
        },
      ),
      makeFocus(3),
    ],
  ),
);
```


## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
yim 2025-02-07 13:26:19 +08:00 committed by GitHub
parent f0396970e9
commit e6a65afca4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 696 additions and 130 deletions

View File

@ -42,6 +42,8 @@ class MaterialPageRoute<T> extends PageRoute<T> with MaterialRouteTransitionMixi
super.fullscreenDialog,
super.allowSnapshotting = true,
super.barrierDismissible = false,
super.traversalEdgeBehavior,
super.directionalTraversalEdgeBehavior,
}) {
assert(opaque);
}

View File

@ -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;

View File

@ -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<FocusNode> traversalDescendants,
TraversalDirection direction, {
bool forward = true,
}) {
final ScrollableState? focusedScrollable = Scrollable.maybeOf(focusedChild.context!);
switch (direction) {
case TraversalDirection.down:
case TraversalDirection.up:
Iterable<FocusNode> eligibleNodes = _sortAndFilterVertically(
direction,
focusedChild.rect,
traversalDescendants,
forward: forward,
);
if (eligibleNodes.isEmpty) {
break;
}
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> 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<FocusNode> 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<FocusNode> eligibleNodes = _sortAndFilterHorizontally(
direction,
focusedChild.rect,
traversalDescendants,
forward: forward,
);
if (eligibleNodes.isEmpty) {
break;
}
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> 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<FocusNode> 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<FocusNode> _sortAndFilterHorizontally(
TraversalDirection direction,
Rect target,
Iterable<FocusNode> nodes,
) {
Iterable<FocusNode> nodes, {
bool forward = true,
}) {
assert(direction == TraversalDirection.left || direction == TraversalDirection.right);
final List<FocusNode> 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<FocusNode> _sortAndFilterVertically(
TraversalDirection direction,
Rect target,
Iterable<FocusNode> nodes,
) {
Iterable<FocusNode> nodes, {
bool forward = true,
}) {
assert(direction == TraversalDirection.up || direction == TraversalDirection.down);
final List<FocusNode> 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<FocusNode> eligibleNodes = _sortAndFilterVertically(
direction,
focusedChild.rect,
nearestScope.traversalDescendants,
);
if (eligibleNodes.isEmpty) {
break;
}
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> 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<FocusNode> 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<FocusNode> eligibleNodes = _sortAndFilterHorizontally(
direction,
focusedChild.rect,
nearestScope.traversalDescendants,
);
if (eligibleNodes.isEmpty) {
break;
}
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> 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<FocusNode> 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);
}
}

View File

@ -1255,6 +1255,12 @@ class DefaultTransitionDelegate<T> extends TransitionDelegate<T> {
/// {@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:

View File

@ -25,6 +25,8 @@ abstract class PageRoute<T> extends ModalRoute<T> {
PageRoute({
super.settings,
super.requestFocus,
super.traversalEdgeBehavior,
super.directionalTraversalEdgeBehavior,
this.fullscreenDialog = false,
this.allowSnapshotting = true,
bool barrierDismissible = false,

View File

@ -1092,13 +1092,21 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
void _updateFocusScopeNode() {
final TraversalEdgeBehavior traversalEdgeBehavior;
final TraversalEdgeBehavior directionalTraversalEdgeBehavior;
final ModalRoute<T> 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<T> extends State<_ModalScope<T>> {
/// * [Route], which further documents the meaning of the `T` generic type argument.
abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T> {
/// 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<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// If set to null, [Navigator.routeTraversalEdgeBehavior] is used.
final TraversalEdgeBehavior? traversalEdgeBehavior;
/// Controls the directional transfer of focus beyond the first and the last
/// items of a [FocusScopeNode].
///
/// If set to null, [Navigator.routeDirectionalTraversalEdgeBehavior] is used.
final TraversalEdgeBehavior? directionalTraversalEdgeBehavior;
// The API for general users of this class
/// Returns the modal route most closely associated with the given context.
@ -2295,7 +2315,13 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// * [Navigator.pop], which is used to dismiss the route.
abstract class PopupRoute<T> extends ModalRoute<T> {
/// 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<T> extends PopupRoute<T> {
super.requestFocus,
this.anchorPoint,
super.traversalEdgeBehavior,
super.directionalTraversalEdgeBehavior,
}) : _pageBuilder = pageBuilder,
_barrierDismissible = barrierDismissible,
_barrierLabel = barrierLabel,

View File

@ -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<bool?> focus = List<bool?>.generate(6, (int _) => null);
final List<FocusNode> nodes = List<FocusNode>.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<void> pumpApp() async {
/// Layout is:
/// [0]
/// ---------Child FocusScope---------
/// [1]
/// [2]
/// [3]
/// ---------Child FocusScope End---------
/// [4] [5]
Widget home = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[makeFocus(0)]),
FocusScope(
node: childScope,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
makeFocus(1),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
makeFocus(2),
Padding(padding: const EdgeInsets.only(top: 100), child: makeFocus(3)),
],
),
],
),
),
Row(children: <Widget>[makeFocus(4), makeFocus(5)]),
],
);
// Prevent the arrow keys from scrolling on the web.
if (isBrowser) {
home = Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
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<bool?>.generate(focus.length, (int _) => null);
}
Future<void> 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(<bool?>[null, null, null, null, null, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, true, null, false, null, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, null, null, false, null, true]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, null, null, false, null, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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<void> {

View File

@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
List<bool?> focus = List<bool?>.generate(4, (int _) => null);
final List<FocusNode> nodes = List<FocusNode>.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<void> pumpApp() async {
Widget home = Column(
children: <Widget>[
makeFocus(0),
Navigator(
key: navigatorKey,
onGenerateRoute: (RouteSettings settings) {
return MaterialPageRoute<void>(
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 <ShortcutActivator, Intent>{
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<void>(
directionalTraversalEdgeBehavior: behavior,
builder: (BuildContext context) {
return Column(children: <Widget>[makeFocus(1), makeFocus(2)]);
},
),
);
}
void clear() {
focus = List<bool?>.generate(focus.length, (int _) => null);
}
await pumpApp();
Future<void> 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(<bool?>[null, null, null, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, true, false, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, null, false, true]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[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(<bool?>[null, null, false, null]));
clear();
await resetTo(1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(focus, orderedEquals(<bool?>[null, false, null, null]));
clear();
});
}
typedef AnnouncementCallBack = void Function(Route<dynamic>?);

View File

@ -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<void> pumpWith(TraversalEdgeBehavior behavior) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
key: UniqueKey(),
routeDirectionalTraversalEdgeBehavior: behavior,
onGenerateRoute: (RouteSettings settings) {
return MaterialPageRoute<void>(
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) {