Skip to content

Commit 0234b18

Browse files
authored
Tweak directional focus traversal (#116230)
* Use distance instead of coord * Sort by distance prefer axis * Switch initial sort back to sort by coordinate. * revert test change * Fix tests * Simplify test case * Add a test for irregular grids * Review Changes
1 parent b02a9c2 commit 0234b18

File tree

2 files changed

+252
-112
lines changed

2 files changed

+252
-112
lines changed

packages/flutter/lib/src/widgets/focus_traversal.dart

+84-61
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,6 @@ enum TraversalDirection {
8282
/// This direction is unaffected by the [Directionality] of the current
8383
/// context.
8484
left,
85-
86-
// TODO(gspencer): Add diagonal traversal directions used by TV remotes and
87-
// game controllers when we support them.
8885
}
8986

9087
/// An object used to specify a focus traversal policy used for configuring a
@@ -547,6 +544,46 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
547544
return null;
548545
}
549546

547+
static int _verticalCompare(Offset target, Offset a, Offset b) {
548+
return (a.dy - target.dy).abs().compareTo((b.dy - target.dy).abs());
549+
}
550+
551+
static int _horizontalCompare(Offset target, Offset a, Offset b) {
552+
return (a.dx - target.dx).abs().compareTo((b.dx - target.dx).abs());
553+
}
554+
555+
// Sort the ones that are closest to target vertically first, and if two are
556+
// the same vertical distance, pick the one that is closest horizontally.
557+
static Iterable<FocusNode> _sortByDistancePreferVertical(Offset target, Iterable<FocusNode> nodes) {
558+
final List<FocusNode> sorted = nodes.toList();
559+
mergeSort<FocusNode>(sorted, compare: (FocusNode nodeA, FocusNode nodeB) {
560+
final Offset a = nodeA.rect.center;
561+
final Offset b = nodeB.rect.center;
562+
final int vertical = _verticalCompare(target, a, b);
563+
if (vertical == 0) {
564+
return _horizontalCompare(target, a, b);
565+
}
566+
return vertical;
567+
});
568+
return sorted;
569+
}
570+
571+
// Sort the ones that are closest horizontally first, and if two are the same
572+
// horizontal distance, pick the one that is closest vertically.
573+
static Iterable<FocusNode> _sortByDistancePreferHorizontal(Offset target, Iterable<FocusNode> nodes) {
574+
final List<FocusNode> sorted = nodes.toList();
575+
mergeSort<FocusNode>(sorted, compare: (FocusNode nodeA, FocusNode nodeB) {
576+
final Offset a = nodeA.rect.center;
577+
final Offset b = nodeB.rect.center;
578+
final int horizontal = _horizontalCompare(target, a, b);
579+
if (horizontal == 0) {
580+
return _verticalCompare(target, a, b);
581+
}
582+
return horizontal;
583+
});
584+
return sorted;
585+
}
586+
550587
// Sorts nodes from left to right horizontally, and removes nodes that are
551588
// either to the right of the left side of the target node if we're going
552589
// left, or to the left of the right side of the target node if we're going
@@ -555,52 +592,54 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
555592
// This doesn't need to take into account directionality because it is
556593
// typically intending to actually go left or right, not in a reading
557594
// direction.
558-
Iterable<FocusNode>? _sortAndFilterHorizontally(
595+
Iterable<FocusNode> _sortAndFilterHorizontally(
559596
TraversalDirection direction,
560597
Rect target,
561-
FocusNode nearestScope,
598+
Iterable<FocusNode> nodes,
562599
) {
563600
assert(direction == TraversalDirection.left || direction == TraversalDirection.right);
564-
final Iterable<FocusNode> nodes = nearestScope.traversalDescendants;
565-
assert(!nodes.contains(nearestScope));
566-
final List<FocusNode> sorted = nodes.toList();
567-
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dx.compareTo(b.rect.center.dx));
568-
Iterable<FocusNode>? result;
601+
final Iterable<FocusNode> filtered;
569602
switch (direction) {
570603
case TraversalDirection.left:
571-
result = sorted.where((FocusNode node) => node.rect != target && node.rect.center.dx <= target.left);
604+
filtered = nodes.where((FocusNode node) => node.rect != target && node.rect.center.dx <= target.left);
572605
break;
573606
case TraversalDirection.right:
574-
result = sorted.where((FocusNode node) => node.rect != target && node.rect.center.dx >= target.right);
607+
filtered = nodes.where((FocusNode node) => node.rect != target && node.rect.center.dx >= target.right);
575608
break;
576609
case TraversalDirection.up:
577610
case TraversalDirection.down:
578-
break;
611+
throw ArgumentError('Invalid direction $direction');
579612
}
580-
return result;
613+
final List<FocusNode> sorted = filtered.toList();
614+
// Sort all nodes from left to right.
615+
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dx.compareTo(b.rect.center.dx));
616+
return sorted;
581617
}
582618

583619
// Sorts nodes from top to bottom vertically, and removes nodes that are
584620
// either below the top of the target node if we're going up, or above the
585621
// bottom of the target node if we're going down.
586-
Iterable<FocusNode>? _sortAndFilterVertically(
622+
Iterable<FocusNode> _sortAndFilterVertically(
587623
TraversalDirection direction,
588624
Rect target,
589625
Iterable<FocusNode> nodes,
590626
) {
591-
final List<FocusNode> sorted = nodes.toList();
592-
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dy.compareTo(b.rect.center.dy));
627+
assert(direction == TraversalDirection.up || direction == TraversalDirection.down);
628+
final Iterable<FocusNode> filtered;
593629
switch (direction) {
594630
case TraversalDirection.up:
595-
return sorted.where((FocusNode node) => node.rect != target && node.rect.center.dy <= target.top);
631+
filtered = nodes.where((FocusNode node) => node.rect != target && node.rect.center.dy <= target.top);
632+
break;
596633
case TraversalDirection.down:
597-
return sorted.where((FocusNode node) => node.rect != target && node.rect.center.dy >= target.bottom);
634+
filtered = nodes.where((FocusNode node) => node.rect != target && node.rect.center.dy >= target.bottom);
635+
break;
598636
case TraversalDirection.left:
599637
case TraversalDirection.right:
600-
break;
638+
throw ArgumentError('Invalid direction $direction');
601639
}
602-
assert(direction == TraversalDirection.up || direction == TraversalDirection.down);
603-
return null;
640+
final List<FocusNode> sorted = filtered.toList();
641+
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dy.compareTo(b.rect.center.dy));
642+
return sorted;
604643
}
605644

606645
// Updates the policy data to keep the previously visited node so that we can
@@ -745,71 +784,55 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
745784
switch (direction) {
746785
case TraversalDirection.down:
747786
case TraversalDirection.up:
748-
Iterable<FocusNode>? eligibleNodes = _sortAndFilterVertically(
749-
direction,
750-
focusedChild.rect,
751-
nearestScope.traversalDescendants,
752-
);
787+
Iterable<FocusNode> eligibleNodes = _sortAndFilterVertically(direction, focusedChild.rect, nearestScope.traversalDescendants);
788+
if (eligibleNodes.isEmpty) {
789+
break;
790+
}
753791
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
754-
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes!.where((FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable);
792+
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes.where((FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable);
755793
if (filteredEligibleNodes.isNotEmpty) {
756794
eligibleNodes = filteredEligibleNodes;
757795
}
758796
}
759-
if (eligibleNodes!.isEmpty) {
760-
break;
761-
}
762-
List<FocusNode> sorted = eligibleNodes.toList();
763797
if (direction == TraversalDirection.up) {
764-
sorted = sorted.reversed.toList();
798+
eligibleNodes = eligibleNodes.toList().reversed;
765799
}
766800
// Find any nodes that intersect the band of the focused child.
767801
final Rect band = Rect.fromLTRB(focusedChild.rect.left, -double.infinity, focusedChild.rect.right, double.infinity);
768-
final Iterable<FocusNode> inBand = sorted.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
802+
final Iterable<FocusNode> inBand = eligibleNodes.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
769803
if (inBand.isNotEmpty) {
770-
// The inBand list is already sorted by horizontal distance, so pick
771-
// the closest one.
772-
found = inBand.first;
804+
found = _sortByDistancePreferVertical(focusedChild.rect.center, inBand).first;
773805
break;
774806
}
775-
// Only out-of-band targets remain, so pick the one that is closest the
776-
// to the center line horizontally.
777-
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) {
778-
return (a.rect.center.dx - focusedChild.rect.center.dx).abs().compareTo((b.rect.center.dx - focusedChild.rect.center.dx).abs());
779-
});
780-
found = sorted.first;
807+
// Only out-of-band targets are eligible, so pick the one that is
808+
// closest the to the center line horizontally.
809+
found = _sortByDistancePreferHorizontal(focusedChild.rect.center, eligibleNodes).first;
781810
break;
782811
case TraversalDirection.right:
783812
case TraversalDirection.left:
784-
Iterable<FocusNode>? eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope);
813+
Iterable<FocusNode> eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope.traversalDescendants);
814+
if (eligibleNodes.isEmpty) {
815+
break;
816+
}
785817
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
786-
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes!.where((FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable);
818+
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes.where((FocusNode node) => Scrollable.maybeOf(node.context!) == focusedScrollable);
787819
if (filteredEligibleNodes.isNotEmpty) {
788820
eligibleNodes = filteredEligibleNodes;
789821
}
790822
}
791-
if (eligibleNodes!.isEmpty) {
792-
break;
793-
}
794-
List<FocusNode> sorted = eligibleNodes.toList();
795823
if (direction == TraversalDirection.left) {
796-
sorted = sorted.reversed.toList();
824+
eligibleNodes = eligibleNodes.toList().reversed;
797825
}
798826
// Find any nodes that intersect the band of the focused child.
799827
final Rect band = Rect.fromLTRB(-double.infinity, focusedChild.rect.top, double.infinity, focusedChild.rect.bottom);
800-
final Iterable<FocusNode> inBand = sorted.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
828+
final Iterable<FocusNode> inBand = eligibleNodes.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
801829
if (inBand.isNotEmpty) {
802-
// The inBand list is already sorted by vertical distance, so pick the
803-
// closest one.
804-
found = inBand.first;
830+
found = _sortByDistancePreferHorizontal(focusedChild.rect.center, inBand).first;
805831
break;
806832
}
807-
// Only out-of-band targets remain, so pick the one that is closest the
833+
// Only out-of-band targets are eligible, so pick the one that is
808834
// to the center line vertically.
809-
mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) {
810-
return (a.rect.center.dy - focusedChild.rect.center.dy).abs().compareTo((b.rect.center.dy - focusedChild.rect.center.dy).abs());
811-
});
812-
found = sorted.first;
835+
found = _sortByDistancePreferVertical(focusedChild.rect.center, eligibleNodes).first;
813836
break;
814837
}
815838
if (found != null) {
@@ -892,8 +915,8 @@ class _ReadingOrderSortData with Diagnosticable {
892915
}
893916
if (common!.isEmpty) {
894917
// If there is no common ancestor, then arbitrarily pick the
895-
// directionality of the first group, which is the equivalent of the "first
896-
// strongly typed" item in a bidi algorithm.
918+
// directionality of the first group, which is the equivalent of the
919+
// "first strongly typed" item in a bidirectional algorithm.
897920
return list.first.directionality;
898921
}
899922
// Find the closest common ancestor. The memberAncestors list contains the

0 commit comments

Comments
 (0)