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:
parent
f0396970e9
commit
e6a65afca4
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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> {
|
||||
|
@ -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>?);
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user