Unverified Commit 82acf02d authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Make it possible to remove nodes from traversal sort. (#58621)

parent a82a51b0
...@@ -167,7 +167,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -167,7 +167,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
final FocusScopeNode scope = currentNode.nearestScope; final FocusScopeNode scope = currentNode.nearestScope;
FocusNode candidate = scope.focusedChild; FocusNode candidate = scope.focusedChild;
if (candidate == null && scope.descendants.isNotEmpty) { if (candidate == null && scope.descendants.isNotEmpty) {
final Iterable<FocusNode> sorted = _sortAllDescendants(scope); final Iterable<FocusNode> sorted = _sortAllDescendants(scope, currentNode);
if (sorted.isEmpty) { if (sorted.isEmpty) {
candidate = null; candidate = null;
} else { } else {
...@@ -254,10 +254,15 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -254,10 +254,15 @@ abstract class FocusTraversalPolicy with Diagnosticable {
/// Subclasses should override this to implement a different sort for [next] /// Subclasses should override this to implement a different sort for [next]
/// and [previous] to use in their ordering. If the returned iterable omits a /// and [previous] to use in their ordering. If the returned iterable omits a
/// node that is a descendant of the given scope, then the user will be unable /// node that is a descendant of the given scope, then the user will be unable
/// to use next/previous keyboard traversal to reach that node, and if that /// to use next/previous keyboard traversal to reach that node.
/// node is used as the originator of a call to next/previous (i.e. supplied ///
/// as the argument to [next] or [previous]), then the next or previous node /// The node used to initiate the traversal (the one passed to [next] or
/// will not be able to be determined and the focus will not change. /// [previous]) is passed as `currentNode`.
///
/// Having the current node in the list is what allows the algorithm to
/// determine which nodes are adjacent to the current node. If the
/// `currentNode` is removed from the list, then the focus will be unchanged
/// when [next] or [previous] are called, and they will return false.
/// ///
/// This is not used for directional focus ([inDirection]), only for /// This is not used for directional focus ([inDirection]), only for
/// determining the focus order for [next] and [previous]. /// determining the focus order for [next] and [previous].
...@@ -268,7 +273,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -268,7 +273,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
/// can appear in arbitrary order, and change positions between sorts), whereas /// can appear in arbitrary order, and change positions between sorts), whereas
/// [mergeSort] is stable. /// [mergeSort] is stable.
@protected @protected
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants); Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode);
_FocusTraversalGroupMarker _getMarker(BuildContext context) { _FocusTraversalGroupMarker _getMarker(BuildContext context) {
return context?.getElementForInheritedWidgetOfExactType<_FocusTraversalGroupMarker>()?.widget as _FocusTraversalGroupMarker; return context?.getElementForInheritedWidgetOfExactType<_FocusTraversalGroupMarker>()?.widget as _FocusTraversalGroupMarker;
...@@ -276,7 +281,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -276,7 +281,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
// Sort all descendants, taking into account the FocusTraversalGroup // Sort all descendants, taking into account the FocusTraversalGroup
// that they are each in, and filtering out non-traversable/focusable nodes. // that they are each in, and filtering out non-traversable/focusable nodes.
List<FocusNode> _sortAllDescendants(FocusScopeNode scope) { List<FocusNode> _sortAllDescendants(FocusScopeNode scope, FocusNode currentNode) {
assert(scope != null); assert(scope != null);
final _FocusTraversalGroupMarker scopeGroupMarker = _getMarker(scope.context); final _FocusTraversalGroupMarker scopeGroupMarker = _getMarker(scope.context);
final FocusTraversalPolicy defaultPolicy = scopeGroupMarker?.policy ?? ReadingOrderTraversalPolicy(); final FocusTraversalPolicy defaultPolicy = scopeGroupMarker?.policy ?? ReadingOrderTraversalPolicy();
...@@ -314,7 +319,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -314,7 +319,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
// Sort the member lists using the individual policy sorts. // Sort the member lists using the individual policy sorts.
final Set<FocusNode> groupKeys = groups.keys.toSet(); final Set<FocusNode> groupKeys = groups.keys.toSet();
for (final FocusNode key in groups.keys) { for (final FocusNode key in groups.keys) {
final List<FocusNode> sortedMembers = groups[key].policy.sortDescendants(groups[key].members).toList(); final List<FocusNode> sortedMembers = groups[key].policy.sortDescendants(groups[key].members, currentNode).toList();
groups[key].members.clear(); groups[key].members.clear();
groups[key].members.addAll(sortedMembers); groups[key].members.addAll(sortedMembers);
} }
...@@ -336,13 +341,9 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -336,13 +341,9 @@ abstract class FocusTraversalPolicy with Diagnosticable {
visitGroups(groups[scopeGroupMarker?.focusNode]); visitGroups(groups[scopeGroupMarker?.focusNode]);
assert( assert(
sortedDescendants.toSet().difference(scope.traversalDescendants.toSet()).isEmpty, sortedDescendants.length <= scope.traversalDescendants.length && sortedDescendants.toSet().difference(scope.traversalDescendants.toSet()).isEmpty,
'sorted descendants contains more nodes than it should: (${sortedDescendants.toSet().difference(scope.traversalDescendants.toSet())})' 'sorted descendants contains more nodes than it should: (${sortedDescendants.toSet().difference(scope.traversalDescendants.toSet())})'
); );
assert(
scope.traversalDescendants.toSet().difference(sortedDescendants.toSet()).isEmpty,
'sorted descendants are missing some nodes: (${scope.traversalDescendants.toSet().difference(sortedDescendants.toSet())})'
);
return sortedDescendants; return sortedDescendants;
} }
...@@ -379,7 +380,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -379,7 +380,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
return true; return true;
} }
} }
final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope); final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope, currentNode);
if (forward && focusedChild == sortedNodes.last) { if (forward && focusedChild == sortedNodes.last) {
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true; return true;
...@@ -830,7 +831,7 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -830,7 +831,7 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
/// explicitly using [FocusTraversalOrder] widgets. /// explicitly using [FocusTraversalOrder] widgets.
class WidgetOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin { class WidgetOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
@override @override
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) => descendants; Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) => descendants;
} }
// This class exists mainly for efficiency reasons: the rect is copied out of // This class exists mainly for efficiency reasons: the rect is copied out of
...@@ -1084,7 +1085,7 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF ...@@ -1084,7 +1085,7 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF
// Sorts the list of nodes based on their geometry into the desired reading // Sorts the list of nodes based on their geometry into the desired reading
// order based on the directionality of the context for each node. // order based on the directionality of the context for each node.
@override @override
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) { Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
assert(descendants != null); assert(descendants != null);
if (descendants.length <= 1) { if (descendants.length <= 1) {
return descendants; return descendants;
...@@ -1368,9 +1369,9 @@ class OrderedTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusT ...@@ -1368,9 +1369,9 @@ class OrderedTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusT
final FocusTraversalPolicy secondary; final FocusTraversalPolicy secondary;
@override @override
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) { Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
final FocusTraversalPolicy secondaryPolicy = secondary ?? ReadingOrderTraversalPolicy(); final FocusTraversalPolicy secondaryPolicy = secondary ?? ReadingOrderTraversalPolicy();
final Iterable<FocusNode> sortedDescendants = secondaryPolicy.sortDescendants(descendants); final Iterable<FocusNode> sortedDescendants = secondaryPolicy.sortDescendants(descendants, currentNode);
final List<FocusNode> unordered = <FocusNode>[]; final List<FocusNode> unordered = <FocusNode>[];
final List<_OrderedFocusInfo> ordered = <_OrderedFocusInfo>[]; final List<_OrderedFocusInfo> ordered = <_OrderedFocusInfo>[];
for (final FocusNode node in sortedDescendants) { for (final FocusNode node in sortedDescendants) {
......
...@@ -275,6 +275,7 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable { ...@@ -275,6 +275,7 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
Map<LogicalKeySet, Intent> get shortcuts => _shortcuts; Map<LogicalKeySet, Intent> get shortcuts => _shortcuts;
Map<LogicalKeySet, Intent> _shortcuts; Map<LogicalKeySet, Intent> _shortcuts;
set shortcuts(Map<LogicalKeySet, Intent> value) { set shortcuts(Map<LogicalKeySet, Intent> value) {
assert(value != null);
if (!mapEquals<LogicalKeySet, Intent>(_shortcuts, value)) { if (!mapEquals<LogicalKeySet, Intent>(_shortcuts, value)) {
_shortcuts = value; _shortcuts = value;
notifyListeners(); notifyListeners();
...@@ -349,16 +350,18 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable { ...@@ -349,16 +350,18 @@ class ShortcutManager extends ChangeNotifier with Diagnosticable {
/// invoked. /// invoked.
/// * [Action], a class for defining an invocation of a user action. /// * [Action], a class for defining an invocation of a user action.
class Shortcuts extends StatefulWidget { class Shortcuts extends StatefulWidget {
/// Creates a ActionManager object. /// Creates a const [Shortcuts] widget.
/// ///
/// The [child] argument must not be null. /// The [child] and [shortcuts] arguments are required and must not be null.
const Shortcuts({ const Shortcuts({
Key key, Key key,
this.manager, this.manager,
this.shortcuts, @required this.shortcuts,
this.child, @required this.child,
this.debugLabel, this.debugLabel,
}) : super(key: key); }) : assert(shortcuts != null),
assert(child != null),
super(key: key);
/// The [ShortcutManager] that will manage the mapping between key /// The [ShortcutManager] that will manage the mapping between key
/// combinations and [Action]s. /// combinations and [Action]s.
......
...@@ -13,6 +13,18 @@ import 'package:flutter/widgets.dart'; ...@@ -13,6 +13,18 @@ import 'package:flutter/widgets.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
/// Used to test removal of nodes while sorting.
class SkipAllButFirstAndLastPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
@override
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
return <FocusNode>[
descendants.first,
if (currentNode != descendants.first && currentNode != descendants.last) currentNode,
descendants.last,
];
}
}
void main() { void main() {
group(WidgetOrderTraversalPolicy, () { group(WidgetOrderTraversalPolicy, () {
testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async { testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async {
...@@ -288,6 +300,40 @@ void main() { ...@@ -288,6 +300,40 @@ void main() {
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Move focus to next/previous node while skipping nodes in policy', (WidgetTester tester) async {
final List<FocusNode> nodes =
List<FocusNode>.generate(7, (int index) => FocusNode(debugLabel: 'Node $index'));
await tester.pumpWidget(
FocusTraversalGroup(
policy: SkipAllButFirstAndLastPolicy(),
child: Column(
children: List<Widget>.generate(
nodes.length,
(int index) => Focus(
focusNode: nodes[index],
child: const SizedBox(),
),
),
),
),
);
nodes[2].requestFocus();
await tester.pump();
expect(nodes[2].hasPrimaryFocus, isTrue);
primaryFocus.nextFocus();
await tester.pump();
expect(nodes[6].hasPrimaryFocus, isTrue);
primaryFocus.previousFocus();
await tester.pump();
expect(nodes[0].hasPrimaryFocus, isTrue);
});
testWidgets('Find the initial focus when a route is pushed or popped.', (WidgetTester tester) async { testWidgets('Find the initial focus when a route is pushed or popped.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -1299,7 +1345,7 @@ void main() { ...@@ -1299,7 +1345,7 @@ void main() {
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Directional focus avoids hysterisis.', (WidgetTester tester) async { testWidgets('Directional focus avoids hysteresis.', (WidgetTester tester) async {
final List<GlobalKey> keys = <GlobalKey>[ final List<GlobalKey> keys = <GlobalKey>[
GlobalKey(debugLabel: 'row 1:1'), GlobalKey(debugLabel: 'row 1:1'),
GlobalKey(debugLabel: 'row 2:1'), GlobalKey(debugLabel: 'row 2:1'),
......
...@@ -191,18 +191,24 @@ void main() { ...@@ -191,18 +191,24 @@ void main() {
LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyB,
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties.where((DiagnosticsNode node) {
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) }).map((DiagnosticsNode node) => node.toString()).toList();
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description.length, equals(1)); expect(description.length, equals(1));
expect(description[0], equals('keys: Key A + Key B')); expect(description[0], equals('keys: Key A + Key B'));
}); });
}); });
group(Shortcuts, () { group(Shortcuts, () {
testWidgets('Default constructed Shortcuts has empty shortcuts', (WidgetTester tester) async {
final ShortcutManager manager = ShortcutManager();
expect(manager.shortcuts, isNotNull);
expect(manager.shortcuts, isEmpty);
const Shortcuts shortcuts = Shortcuts(shortcuts: <LogicalKeySet, Intent>{}, child: SizedBox());
await tester.pumpWidget(shortcuts);
expect(shortcuts.shortcuts, isNotNull);
expect(shortcuts.shortcuts, isEmpty);
});
testWidgets('ShortcutManager handles shortcuts', (WidgetTester tester) async { testWidgets('ShortcutManager handles shortcuts', (WidgetTester tester) async {
final GlobalKey containerKey = GlobalKey(); final GlobalKey containerKey = GlobalKey();
final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[]; final List<LogicalKeyboardKey> pressedKeys = <LogicalKeyboardKey>[];
...@@ -317,21 +323,23 @@ void main() { ...@@ -317,21 +323,23 @@ void main() {
test('Shortcuts diagnostics work.', () { test('Shortcuts diagnostics work.', () {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
Shortcuts(shortcuts: <LogicalKeySet, Intent>{LogicalKeySet( Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(
LogicalKeyboardKey.shift, LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyA, LogicalKeyboardKey.keyA,
) : const ActivateIntent(), ): const ActivateIntent(),
LogicalKeySet( LogicalKeySet(
LogicalKeyboardKey.shift, LogicalKeyboardKey.shift,
LogicalKeyboardKey.arrowRight, LogicalKeyboardKey.arrowRight,
) : const DirectionalFocusIntent(TraversalDirection.right)}).debugFillProperties(builder); ): const DirectionalFocusIntent(TraversalDirection.right)
},
child: const SizedBox(),
).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties.where((DiagnosticsNode node) {
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) }).map((DiagnosticsNode node) => node.toString()).toList();
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description.length, equals(1)); expect(description.length, equals(1));
expect( expect(
...@@ -350,14 +358,12 @@ void main() { ...@@ -350,14 +358,12 @@ void main() {
LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyB,
): const ActivateIntent(), ): const ActivateIntent(),
}, },
child: const SizedBox(),
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties.where((DiagnosticsNode node) {
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) }).map((DiagnosticsNode node) => node.toString()).toList();
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description.length, equals(1)); expect(description.length, equals(1));
expect(description[0], equals('shortcuts: <Debug Label>')); expect(description[0], equals('shortcuts: <Debug Label>'));
...@@ -373,14 +379,12 @@ void main() { ...@@ -373,14 +379,12 @@ void main() {
LogicalKeyboardKey.keyB, LogicalKeyboardKey.keyB,
): const ActivateIntent(), ): const ActivateIntent(),
}, },
child: const SizedBox(),
).debugFillProperties(builder); ).debugFillProperties(builder);
final List<String> description = builder.properties final List<String> description = builder.properties.where((DiagnosticsNode node) {
.where((DiagnosticsNode node) {
return !node.isFiltered(DiagnosticLevel.info); return !node.isFiltered(DiagnosticLevel.info);
}) }).map((DiagnosticsNode node) => node.toString()).toList();
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description.length, equals(2)); expect(description.length, equals(2));
expect(description[0], equalsIgnoringHashCodes('manager: ShortcutManager#00000(shortcuts: {})')); expect(description[0], equalsIgnoringHashCodes('manager: ShortcutManager#00000(shortcuts: {})'));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment