Skip to content

Commit a05f21f

Browse files
authored
Refine the directional traversal algorithm for out of band widgets (flutter#122556)
Refine the directional traversal algorithm for out of band widgets
1 parent 1306d7f commit a05f21f

File tree

2 files changed

+254
-18
lines changed

2 files changed

+254
-18
lines changed

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

+82-18
Original file line numberDiff line numberDiff line change
@@ -496,29 +496,43 @@ class _DirectionalPolicyData {
496496
/// only want to implement new next/previous policies.
497497
///
498498
/// Since hysteresis in the navigation order is undesirable, this implementation
499-
/// maintains a stack of previous locations that have been visited on the
500-
/// policy data for the affected [FocusScopeNode]. If the previous direction
501-
/// was the opposite of the current direction, then the this policy will request
502-
/// focus on the previously focused node. Change to another direction other than
503-
/// the current one or its opposite will clear the stack.
499+
/// maintains a stack of previous locations that have been visited on the policy
500+
/// data for the affected [FocusScopeNode]. If the previous direction was the
501+
/// opposite of the current direction, then the this policy will request focus
502+
/// on the previously focused node. Change to another direction other than the
503+
/// current one or its opposite will clear the stack.
504504
///
505505
/// For instance, if the focus moves down, down, down, and then up, up, up, it
506506
/// will follow the same path through the widgets in both directions. However,
507507
/// if it moves down, down, down, left, right, and then up, up, up, it may not
508508
/// follow the same path on the way up as it did on the way down, since changing
509509
/// the axis of motion resets the history.
510510
///
511+
/// This class implements an algorithm that considers an infinite band extending
512+
/// along the direction of movement, the width or height (depending on
513+
/// direction) of the currently focused widget, and finds the closest widget in
514+
/// that band along the direction of movement. If nothing is found in that band,
515+
/// then it picks the widget with an edge closest to the band in the
516+
/// perpendicular direction. If two out-of-band widgets are the same distance
517+
/// from the band, then it picks the one closest along the direction of
518+
/// movement.
519+
///
520+
/// The goal of this algorithm is to pick a widget that (to the user) doesn't
521+
/// appear to traverse along the wrong axis, as it might if it only sorted
522+
/// widgets by distance along one axis, but also jumps to the next logical
523+
/// widget in a direction without skipping over widgets.
524+
///
511525
/// See also:
512526
///
513-
/// * [FocusNode], for a description of the focus system.
514-
/// * [FocusTraversalGroup], a widget that groups together and imposes a
515-
/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
516-
/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget
517-
/// creation order to describe the order of traversal.
518-
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
519-
/// natural "reading order" for the current [Directionality].
520-
/// * [OrderedTraversalPolicy], a policy that describes the order
521-
/// explicitly using [FocusTraversalOrder] widgets.
527+
/// * [FocusNode], for a description of the focus system.
528+
/// * [FocusTraversalGroup], a widget that groups together and imposes a
529+
/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
530+
/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget creation
531+
/// order to describe the order of traversal.
532+
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
533+
/// natural "reading order" for the current [Directionality].
534+
/// * [OrderedTraversalPolicy], a policy that describes the order explicitly
535+
/// using [FocusTraversalOrder] widgets.
522536
mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
523537
final Map<FocusScopeNode, _DirectionalPolicyData> _policyData = <FocusScopeNode, _DirectionalPolicyData>{};
524538

@@ -622,6 +636,54 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
622636
return sorted;
623637
}
624638

639+
static int _verticalCompareClosestEdge(Offset target, Rect a, Rect b) {
640+
// Find which edge is closest to the target for each.
641+
final double aCoord = (a.top - target.dy).abs() < (a.bottom - target.dy).abs() ? a.top : a.bottom;
642+
final double bCoord = (b.top - target.dy).abs() < (b.bottom - target.dy).abs() ? b.top : b.bottom;
643+
return (aCoord - target.dy).abs().compareTo((bCoord - target.dy).abs());
644+
}
645+
646+
static int _horizontalCompareClosestEdge(Offset target, Rect a, Rect b) {
647+
// Find which edge is closest to the target for each.
648+
final double aCoord = (a.left - target.dx).abs() < (a.right - target.dx).abs() ? a.left : a.right;
649+
final double bCoord = (b.left - target.dx).abs() < (b.right - target.dx).abs() ? b.left : b.right;
650+
return (aCoord - target.dx).abs().compareTo((bCoord - target.dx).abs());
651+
}
652+
653+
// Sort the ones that have edges that are closest horizontally first, and if
654+
// two are the same horizontal distance, pick the one that is closest
655+
// vertically.
656+
static Iterable<FocusNode> _sortClosestEdgesByDistancePreferHorizontal(Offset target, Iterable<FocusNode> nodes) {
657+
final List<FocusNode> sorted = nodes.toList();
658+
mergeSort<FocusNode>(sorted, compare: (FocusNode nodeA, FocusNode nodeB) {
659+
final int horizontal = _horizontalCompareClosestEdge(target, nodeA.rect, nodeB.rect);
660+
if (horizontal == 0) {
661+
// If they're the same distance horizontally, pick the closest one
662+
// vertically.
663+
return _verticalCompare(target, nodeA.rect.center, nodeB.rect.center);
664+
}
665+
return horizontal;
666+
});
667+
return sorted;
668+
}
669+
670+
// Sort the ones that have edges that are closest vertically first, and if
671+
// two are the same vertical distance, pick the one that is closest
672+
// horizontally.
673+
static Iterable<FocusNode> _sortClosestEdgesByDistancePreferVertical(Offset target, Iterable<FocusNode> nodes) {
674+
final List<FocusNode> sorted = nodes.toList();
675+
mergeSort<FocusNode>(sorted, compare: (FocusNode nodeA, FocusNode nodeB) {
676+
final int vertical = _verticalCompareClosestEdge(target, nodeA.rect, nodeB.rect);
677+
if (vertical == 0) {
678+
// If they're the same distance vertically, pick the closest one
679+
// horizontally.
680+
return _horizontalCompare(target, nodeA.rect.center, nodeB.rect.center);
681+
}
682+
return vertical;
683+
});
684+
return sorted;
685+
}
686+
625687
// Sorts nodes from left to right horizontally, and removes nodes that are
626688
// either to the right of the left side of the target node if we're going
627689
// left, or to the left of the right side of the target node if we're going
@@ -843,8 +905,9 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
843905
break;
844906
}
845907
// Only out-of-band targets are eligible, so pick the one that is
846-
// closest the to the center line horizontally.
847-
found = _sortByDistancePreferHorizontal(focusedChild.rect.center, eligibleNodes).first;
908+
// closest to the center line horizontally, and if any are the same
909+
// distance horizontally, pick the closest one of those vertically.
910+
found = _sortClosestEdgesByDistancePreferHorizontal(focusedChild.rect.center, eligibleNodes).first;
848911
break;
849912
case TraversalDirection.right:
850913
case TraversalDirection.left:
@@ -869,8 +932,9 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
869932
break;
870933
}
871934
// Only out-of-band targets are eligible, so pick the one that is
872-
// to the center line vertically.
873-
found = _sortByDistancePreferVertical(focusedChild.rect.center, eligibleNodes).first;
935+
// closest to the center line vertically, and if any are the same
936+
// distance vertically, pick the closest one of those horizontally.
937+
found = _sortClosestEdgesByDistancePreferVertical(focusedChild.rect.center, eligibleNodes).first;
874938
break;
875939
}
876940
if (found != null) {

packages/flutter/test/widgets/focus_traversal_test.dart

+172
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,178 @@ void main() {
15851585
clear();
15861586
});
15871587

1588+
testWidgets('Closest vertical is picked when only out of band items are considered', (WidgetTester tester) async {
1589+
const int rows = 4;
1590+
List<bool?> focus = List<bool?>.generate(rows, (int _) => null);
1591+
final List<FocusNode> nodes = List<FocusNode>.generate(rows, (int index) => FocusNode(debugLabel: 'Node $index'));
1592+
1593+
Widget makeFocus(int row) {
1594+
return Padding(
1595+
padding: EdgeInsetsDirectional.only(end: row != 0 ? 110.0 : 0),
1596+
child: Focus(
1597+
focusNode: nodes[row],
1598+
onFocusChange: (bool isFocused) => focus[row] = isFocused,
1599+
child: Container(
1600+
width: row == 1 ? 150 : 100,
1601+
height: 100,
1602+
color: Colors.primaries[row],
1603+
child: Text('[$row]'),
1604+
),
1605+
),
1606+
);
1607+
}
1608+
1609+
/// Layout is:
1610+
/// [0]
1611+
/// [ 1]
1612+
/// [ 2]
1613+
/// [ 3]
1614+
///
1615+
/// The important feature is that nothing is in the vertical band defined
1616+
/// by widget [0]. We want it to traverse to 1, 2, 3 in order, even though
1617+
/// the center of [2] is horizontally closer to the vertical axis of [0]'s
1618+
/// center than [1]'s.
1619+
await tester.pumpWidget(
1620+
Directionality(
1621+
textDirection: TextDirection.ltr,
1622+
child: FocusTraversalGroup(
1623+
policy: WidgetOrderTraversalPolicy(),
1624+
child: FocusScope(
1625+
debugLabel: 'Scope',
1626+
child: Column(
1627+
crossAxisAlignment: CrossAxisAlignment.end,
1628+
children: <Widget>[
1629+
makeFocus(0),
1630+
makeFocus(1),
1631+
makeFocus(2),
1632+
makeFocus(3),
1633+
],
1634+
),
1635+
),
1636+
),
1637+
),
1638+
);
1639+
1640+
void clear() {
1641+
focus = List<bool?>.generate(focus.length, (int _) => null);
1642+
}
1643+
1644+
final FocusNode scope = nodes[0].enclosingScope!;
1645+
1646+
// Go down the column and make sure that the focus goes to the next
1647+
// closest one.
1648+
nodes[0].requestFocus();
1649+
await tester.pump();
1650+
expect(focus, orderedEquals(<bool?>[true, null, null, null]));
1651+
clear();
1652+
1653+
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
1654+
await tester.pump();
1655+
expect(focus, orderedEquals(<bool?>[false, true, null, null]));
1656+
clear();
1657+
1658+
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
1659+
await tester.pump();
1660+
expect(focus, orderedEquals(<bool?>[null, false, true, null]));
1661+
clear();
1662+
1663+
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
1664+
await tester.pump();
1665+
expect(focus, orderedEquals(<bool?>[null, null, false, true]));
1666+
clear();
1667+
1668+
expect(scope.focusInDirection(TraversalDirection.down), isFalse);
1669+
await tester.pump();
1670+
expect(focus, orderedEquals(<bool?>[null, null, null, null]));
1671+
clear();
1672+
});
1673+
1674+
testWidgets('Closest horizontal is picked when only out of band items are considered', (WidgetTester tester) async {
1675+
const int cols = 4;
1676+
List<bool?> focus = List<bool?>.generate(cols, (int _) => null);
1677+
final List<FocusNode> nodes = List<FocusNode>.generate(cols, (int index) => FocusNode(debugLabel: 'Node $index'));
1678+
1679+
Widget makeFocus(int col) {
1680+
return Padding(
1681+
padding: EdgeInsetsDirectional.only(top: col != 0 ? 110.0 : 0),
1682+
child: Focus(
1683+
focusNode: nodes[col],
1684+
onFocusChange: (bool isFocused) => focus[col] = isFocused,
1685+
child: Container(
1686+
width: 100,
1687+
height: col == 1 ? 150 : 100,
1688+
color: Colors.primaries[col],
1689+
child: Text('[$col]'),
1690+
),
1691+
),
1692+
);
1693+
}
1694+
1695+
/// Layout is:
1696+
/// [0]
1697+
/// [ ][2][3]
1698+
/// [1]
1699+
/// ([ ] is part of [1], [1] is just taller than [2] and [3]).
1700+
///
1701+
/// The important feature is that nothing is in the horizontal band
1702+
/// defined by widget [0]. We want it to traverse to 1, 2, 3 in order,
1703+
/// even though the center of [2] is vertically closer to the horizontal
1704+
/// axis of [0]'s center than [1]'s.
1705+
await tester.pumpWidget(
1706+
Directionality(
1707+
textDirection: TextDirection.ltr,
1708+
child: FocusTraversalGroup(
1709+
policy: WidgetOrderTraversalPolicy(),
1710+
child: FocusScope(
1711+
debugLabel: 'Scope',
1712+
child: Row(
1713+
crossAxisAlignment: CrossAxisAlignment.start,
1714+
children: <Widget>[
1715+
makeFocus(0),
1716+
makeFocus(1),
1717+
makeFocus(2),
1718+
makeFocus(3),
1719+
],
1720+
),
1721+
),
1722+
),
1723+
),
1724+
);
1725+
1726+
void clear() {
1727+
focus = List<bool?>.generate(focus.length, (int _) => null);
1728+
}
1729+
1730+
final FocusNode scope = nodes[0].enclosingScope!;
1731+
1732+
// Go down the row and make sure that the focus goes to the next
1733+
// closest one.
1734+
nodes[0].requestFocus();
1735+
await tester.pump();
1736+
expect(focus, orderedEquals(<bool?>[true, null, null, null]));
1737+
clear();
1738+
1739+
expect(scope.focusInDirection(TraversalDirection.right), isTrue);
1740+
await tester.pump();
1741+
expect(focus, orderedEquals(<bool?>[false, true, null, null]));
1742+
clear();
1743+
1744+
expect(scope.focusInDirection(TraversalDirection.right), isTrue);
1745+
await tester.pump();
1746+
expect(focus, orderedEquals(<bool?>[null, false, true, null]));
1747+
clear();
1748+
1749+
expect(scope.focusInDirection(TraversalDirection.right), isTrue);
1750+
await tester.pump();
1751+
expect(focus, orderedEquals(<bool?>[null, null, false, true]));
1752+
clear();
1753+
1754+
expect(scope.focusInDirection(TraversalDirection.right), isFalse);
1755+
await tester.pump();
1756+
expect(focus, orderedEquals(<bool?>[null, null, null, null]));
1757+
clear();
1758+
});
1759+
15881760
testWidgets('Can find first focus in all directions.', (WidgetTester tester) async {
15891761
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
15901762
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');

0 commit comments

Comments
 (0)