Reland: Add OrderedFocusTraversalPolicy and FocusTraversalGrou… (#50672)
This re-lands #49235 with the addition of includeSemantics flag on the Focus widget so that the FocusTraversalGroup can create a Focus widget without affecting the semantics tree.
The FocusTraversalGroup uses the Focus widget to create a grouping of descendants for traversal, but doesn't actually participate in focus (canRequestFocus is always false), so we don't want it to add a Semantics widget in that case, since that can cause semantics changes. The canRequestFocus attribute can also be used when a widget is disabled, so we do sometimes want to include Semantics even if that is false, but not in the case where it is always false, as for FocusTraversalGroup.
- Added a test to make sure that FocusTraversalGroup doesn't add any semantics information.
diff --git a/dev/manual_tests/lib/actions.dart b/dev/manual_tests/lib/actions.dart
index 1ee6150..02839ff 100644
--- a/dev/manual_tests/lib/actions.dart
+++ b/dev/manual_tests/lib/actions.dart
@@ -435,7 +435,7 @@
kUndoActionKey: () => kUndoAction,
kRedoActionKey: () => kRedoAction,
},
- child: DefaultFocusTraversal(
+ child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
diff --git a/dev/manual_tests/lib/focus.dart b/dev/manual_tests/lib/focus.dart
index 361988e..00e5c2d 100644
--- a/dev/manual_tests/lib/focus.dart
+++ b/dev/manual_tests/lib/focus.dart
@@ -142,7 +142,7 @@
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
- return DefaultFocusTraversal(
+ return FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'Scope',
diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart
index 11e645c..d4bdee6 100644
--- a/packages/flutter/lib/src/widgets/app.dart
+++ b/packages/flutter/lib/src/widgets/app.dart
@@ -1395,7 +1395,7 @@
debugLabel: '<Default WidgetsApp Shortcuts>',
child: Actions(
actions: widget.actions ?? WidgetsApp.defaultActions,
- child: DefaultFocusTraversal(
+ child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: _MediaQueryFromWindow(
child: Localizations(
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index 3921f36..2455bd9 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -232,12 +232,12 @@
/// particular direction, is determined by the [FocusTraversalPolicy] in force.
///
/// The ambient policy is determined by looking up the widget hierarchy for a
-/// [DefaultFocusTraversal] widget, and obtaining the focus traversal policy
+/// [FocusTraversalGroup] widget, and obtaining the focus traversal policy
/// from it. Different focus nodes can inherit difference policies, so part of
/// the app can go in widget order, and part can go in reading order, depending
/// upon the use case.
///
-/// Predefined policies include [WidgetOrderFocusTraversalPolicy],
+/// Predefined policies include [WidgetOrderTraversalPolicy],
/// [ReadingOrderTraversalPolicy], and [DirectionalFocusTraversalPolicyMixin],
/// but custom policies can be built based upon these policies.
///
@@ -363,8 +363,8 @@
/// events to focused nodes.
/// * [FocusTraversalPolicy], a class used to determine how to move the focus
/// to other nodes.
-/// * [DefaultFocusTraversal], a widget used to configure the default focus
-/// traversal policy for a widget subtree.
+/// * [FocusTraversalGroup], a widget used to group together and configure the
+/// focus traversal policy for a widget subtree.
class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// Creates a focus node.
///
@@ -427,8 +427,8 @@
///
/// See also:
///
- /// * [DefaultFocusTraversal], a widget that sets the traversal policy for
- /// its descendants.
+ /// * [FocusTraversalGroup], a widget used to group together and configure the
+ /// focus traversal policy for a widget subtree.
/// * [FocusTraversalPolicy], a class that can be extended to describe a
/// traversal policy.
bool get canRequestFocus {
@@ -518,7 +518,8 @@
return _descendants;
}
- /// Returns all descendants which do not have the [skipTraversal] flag set.
+ /// Returns all descendants which do not have the [skipTraversal] and do have
+ /// the [canRequestFocus] flag set.
Iterable<FocusNode> get traversalDescendants => descendants.where((FocusNode node) => !node.skipTraversal && node.canRequestFocus);
/// An [Iterable] over the ancestors of this node.
@@ -779,7 +780,7 @@
_manager?.primaryFocus?._setAsFocusedChildForScope();
}
if (oldScope != null && child.context != null && child.enclosingScope != oldScope) {
- DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
+ FocusTraversalGroup.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
}
if (child._requestFocusWhenReparented) {
child._doRequestFocus();
@@ -918,19 +919,19 @@
/// [FocusTraversalPolicy.next] method.
///
/// Returns true if it successfully found a node and requested focus.
- bool nextFocus() => DefaultFocusTraversal.of(context).next(this);
+ bool nextFocus() => FocusTraversalGroup.of(context).next(this);
/// Request to move the focus to the previous focus node, by calling the
/// [FocusTraversalPolicy.previous] method.
///
/// Returns true if it successfully found a node and requested focus.
- bool previousFocus() => DefaultFocusTraversal.of(context).previous(this);
+ bool previousFocus() => FocusTraversalGroup.of(context).previous(this);
/// Request to move the focus to the nearest focus node in the given
/// direction, by calling the [FocusTraversalPolicy.inDirection] method.
///
/// Returns true if it successfully found a node and requested focus.
- bool focusInDirection(TraversalDirection direction) => DefaultFocusTraversal.of(context).inDirection(this, direction);
+ bool focusInDirection(TraversalDirection direction) => FocusTraversalGroup.of(context).inDirection(this, direction);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart
index cb9ce51..b2318f9 100644
--- a/packages/flutter/lib/src/widgets/focus_scope.dart
+++ b/packages/flutter/lib/src/widgets/focus_scope.dart
@@ -149,8 +149,10 @@
this.debugLabel,
this.canRequestFocus,
this.skipTraversal,
+ this.includeSemantics = true,
}) : assert(child != null),
assert(autofocus != null),
+ assert(includeSemantics != null),
super(key: key);
/// A debug label for this widget.
@@ -231,6 +233,17 @@
/// still be focused explicitly.
final bool skipTraversal;
+ /// Include semantics information in this [Focus] widget.
+ ///
+ /// If true, this [Focus] widget will include a [Semantics] node that
+ /// indicates the [Semantics.focusable] and [Semantics.focused] properties.
+ ///
+ /// Is is not typical to set this to false, as that can affect the semantics
+ /// information available to accessibility systems.
+ ///
+ /// Must not be null, defaults to true.
+ final bool includeSemantics;
+
/// {@template flutter.widgets.Focus.canRequestFocus}
/// If true, this widget may request the primary focus.
///
@@ -459,13 +472,17 @@
@override
Widget build(BuildContext context) {
_focusAttachment.reparent();
- return _FocusMarker(
- node: focusNode,
- child: Semantics(
+ Widget child = widget.child;
+ if (widget.includeSemantics) {
+ child = Semantics(
focusable: _canRequestFocus,
focused: _hasPrimaryFocus,
child: widget.child,
- ),
+ );
+ }
+ return _FocusMarker(
+ node: focusNode,
+ child: child,
);
}
}
diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart
index c88ed39..22a09b8 100644
--- a/packages/flutter/lib/src/widgets/focus_traversal.dart
+++ b/packages/flutter/lib/src/widgets/focus_traversal.dart
@@ -4,6 +4,7 @@
import 'dart:ui';
+import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
@@ -11,14 +12,59 @@
import 'basic.dart';
import 'editable_text.dart';
import 'focus_manager.dart';
+import 'focus_scope.dart';
import 'framework.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
+// BuildContext/Element doesn't have a parent accessor, but it can be simulated
+// with visitAncestorElements. _getAncestor is needed because
+// context.getElementForInheritedWidgetOfExactType will return itself if it
+// happens to be of the correct type. _getAncestor should be O(count), since we
+// always return false at a specific ancestor. By default it returns the parent,
+// which is O(1).
+BuildContext _getAncestor(BuildContext context, {int count = 1}) {
+ BuildContext target;
+ context.visitAncestorElements((Element ancestor) {
+ count--;
+ if (count == 0) {
+ target = ancestor;
+ return false;
+ }
+ return true;
+ });
+ return target;
+}
+
+void _focusAndEnsureVisible(
+ FocusNode node, {
+ ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
+}) {
+ node.requestFocus();
+ Scrollable.ensureVisible(node.context, alignment: 1.0, alignmentPolicy: alignmentPolicy);
+}
+
+// A class to temporarily hold information about FocusTraversalGroups when
+// sorting their contents.
+class _FocusTraversalGroupInfo {
+ _FocusTraversalGroupInfo(
+ _FocusTraversalGroupMarker marker, {
+ FocusTraversalPolicy defaultPolicy,
+ List<FocusNode> members,
+ }) : groupNode = marker?.focusNode,
+ policy = marker?.policy ?? defaultPolicy ?? ReadingOrderTraversalPolicy(),
+ members = members ?? <FocusNode>[];
+
+ final FocusNode groupNode;
+ final FocusTraversalPolicy policy;
+ final List<FocusNode> members;
+}
+
/// A direction along either the horizontal or vertical axes.
///
-/// This is used by the [DirectionalFocusTraversalPolicyMixin] to indicate which
-/// direction to traverse in.
+/// This is used by the [DirectionalFocusTraversalPolicyMixin], and
+/// [Focus.focusInDirection] to indicate which direction to look in for the next
+/// focus.
enum TraversalDirection {
/// Indicates a direction above the currently focused widget.
up,
@@ -43,7 +89,7 @@
}
/// An object used to specify a focus traversal policy used for configuring a
-/// [DefaultFocusTraversal] widget.
+/// [FocusTraversalGroup] widget.
///
/// The focus traversal policy is what determines which widget is "next",
/// "previous", or in a direction from the currently focused [FocusNode].
@@ -51,40 +97,61 @@
/// One of the pre-defined subclasses may be used, or define a custom policy to
/// create a unique focus order.
///
+/// When defining your own, your subclass should implement [sortDescendants] to
+/// provide the order in which you would like the descendants to be traversed.
+///
/// See also:
///
/// * [FocusNode], for a description of the focus system.
-/// * [DefaultFocusTraversal], a widget that imposes a traversal policy on the
-/// [Focus] nodes below it in the widget hierarchy.
+/// * [FocusTraversalGroup], a widget that groups together and imposes a
+/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
/// * [FocusNode], which is affected by the traversal policy.
-/// * [WidgetOrderFocusTraversalPolicy], a policy that relies on the widget
+/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget
/// creation order to describe the order of traversal.
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
/// natural "reading order" for the current [Directionality].
+/// * [OrderedTraversalPolicy], a policy that describes the order
+/// explicitly using [FocusTraversalOrder] widgets.
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
-abstract class FocusTraversalPolicy {
+@immutable
+abstract class FocusTraversalPolicy extends Diagnosticable {
+ /// A const constructor so subclasses can be const.
+ const FocusTraversalPolicy();
+
/// Returns the node that should receive focus if there is no current focus
- /// in the [FocusScopeNode] that [currentNode] belongs to.
+ /// in the nearest [FocusScopeNode] that `currentNode` belongs to.
///
/// This is used by [next]/[previous]/[inDirection] to determine which node to
- /// focus if they are called, but no node is currently focused.
+ /// focus if they are called when no node is currently focused.
///
- /// It is also used by the [FocusManager] to know which node to focus
- /// initially if no nodes are focused.
+ /// The `currentNode` argument must not be null.
///
- /// If the [direction] is null, then it should find the appropriate first node
- /// for next/previous, and if direction is non-null, should find the
- /// appropriate first node in that direction.
- ///
- /// The [currentNode] argument must not be null.
- FocusNode findFirstFocus(FocusNode currentNode);
+ /// The default implementation returns the [FocusScopeNode.focusedChild], if
+ /// set, on the nearest scope of the `currentNode`, otherwise, returns the
+ /// first node from [sortDescendants], or the given `currentNode` if there are
+ /// no descendants.
+ FocusNode findFirstFocus(FocusNode currentNode) {
+ assert(currentNode != null);
+ final FocusScopeNode scope = currentNode.nearestScope;
+ FocusNode candidate = scope.focusedChild;
+ if (candidate == null && scope.descendants.isNotEmpty) {
+ final Iterable<FocusNode> sorted = _sortAllDescendants(scope);
+ candidate = sorted.isNotEmpty ? sorted.first : null;
+ }
- /// Returns the node in the given [direction] that should receive focus if
- /// there is no current focus in the scope to which the [currentNode] belongs.
+ // If we still didn't find any candidate, use the current node as a
+ // fallback.
+ candidate ??= currentNode;
+ return candidate;
+ }
+
+ /// Returns the first node in the given `direction` that should receive focus
+ /// if there is no current focus in the scope to which the `currentNode`
+ /// belongs.
///
/// This is typically used by [inDirection] to determine which node to focus
- /// if it is called, but no node is currently focused.
+ /// if it is called when no node is currently focused.
///
/// All arguments must not be null.
FocusNode findFirstFocusInDirection(FocusNode currentNode, TraversalDirection direction);
@@ -121,7 +188,7 @@
/// Returns true if it successfully found a node and requested focus.
///
/// The [currentNode] argument must not be null.
- bool next(FocusNode currentNode);
+ bool next(FocusNode currentNode) => _moveFocus(currentNode, forward: true);
/// Focuses the previous widget in the focus scope that contains the given
/// [currentNode].
@@ -133,7 +200,7 @@
/// Returns true if it successfully found a node and requested focus.
///
/// The [currentNode] argument must not be null.
- bool previous(FocusNode currentNode);
+ bool previous(FocusNode currentNode) => _moveFocus(currentNode, forward: false);
/// Focuses the next widget in the given [direction] in the focus scope that
/// contains the given [currentNode].
@@ -146,15 +213,165 @@
///
/// All arguments must not be null.
bool inDirection(FocusNode currentNode, TraversalDirection direction);
+
+ /// Sorts the given `descendants` into focus order.
+ ///
+ /// Subclasses should override this to implement a different sort for [next]
+ /// 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
+ /// to use next/previous keyboard traversal to reach that node, and if that
+ /// 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
+ /// will not be able to be determined and the focus will not change.
+ ///
+ /// This is not used for directional focus ([inDirection]), only for
+ /// determining the focus order for [next] and [previous].
+ ///
+ /// When implementing an override for this function, be sure to use
+ /// [mergeSort] instead of Dart's default list sorting algorithm when sorting
+ /// items, since the default algorithm is not stable (items deemed to be equal
+ /// can appear in arbitrary order, and change positions between sorts), whereas
+ /// [mergeSort] is stable.
+ @protected
+ Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants);
+
+ _FocusTraversalGroupMarker _getMarker(BuildContext context) {
+ return context?.getElementForInheritedWidgetOfExactType<_FocusTraversalGroupMarker>()?.widget as _FocusTraversalGroupMarker;
+ }
+
+ // Sort all descendants, taking into account the FocusTraversalGroup
+ // that they are each in, and filtering out non-traversable/focusable nodes.
+ List<FocusNode> _sortAllDescendants(FocusScopeNode scope) {
+ assert(scope != null);
+ final _FocusTraversalGroupMarker scopeGroupMarker = _getMarker(scope.context);
+ final FocusTraversalPolicy defaultPolicy = scopeGroupMarker?.policy ?? ReadingOrderTraversalPolicy();
+ // Build the sorting data structure, separating descendants into groups.
+ final Map<FocusNode, _FocusTraversalGroupInfo> groups = <FocusNode, _FocusTraversalGroupInfo>{};
+ for (final FocusNode node in scope.descendants) {
+ final _FocusTraversalGroupMarker groupMarker = _getMarker(node.context);
+ final FocusNode groupNode = groupMarker?.focusNode;
+ // Group nodes need to be added to their parent's node, or to the "null"
+ // node if no parent is found. This creates the hierarchy of group nodes
+ // and makes it so the entire group is sorted along with the other members
+ // of the parent group.
+ if (node == groupNode) {
+ // To find the parent of the group node, we need to skip over the parent
+ // of the Focus node in _FocusTraversalGroupState.build, and start
+ // looking with that node's parent, since _getMarker will return the
+ // context it was called on if it matches the type.
+ final BuildContext parentContext = _getAncestor(groupNode.context, count: 2);
+ final _FocusTraversalGroupMarker parentMarker = _getMarker(parentContext);
+ final FocusNode parentNode = parentMarker?.focusNode;
+ groups[parentNode] ??= _FocusTraversalGroupInfo(parentMarker, members: <FocusNode>[], defaultPolicy: defaultPolicy);
+ assert(!groups[parentNode].members.contains(node));
+ groups[parentNode].members.add(groupNode);
+ continue;
+ }
+ // Skip non-focusable and non-traversable nodes in the same way that
+ // FocusScopeNode.traversalDescendants would.
+ if (node.canRequestFocus && !node.skipTraversal) {
+ groups[groupNode] ??= _FocusTraversalGroupInfo(groupMarker, members: <FocusNode>[], defaultPolicy: defaultPolicy);
+ assert(!groups[groupNode].members.contains(node));
+ groups[groupNode].members.add(node);
+ }
+ }
+
+ // Sort the member lists using the individual policy sorts.
+ final Set<FocusNode> groupKeys = groups.keys.toSet();
+ for (final FocusNode key in groups.keys) {
+ final List<FocusNode> sortedMembers = groups[key].policy.sortDescendants(groups[key].members).toList();
+ groups[key].members.clear();
+ groups[key].members.addAll(sortedMembers);
+ }
+
+ // Traverse the group tree, adding the children of members in the order they
+ // appear in the member lists.
+ final List<FocusNode> sortedDescendants = <FocusNode>[];
+ void visitGroups(_FocusTraversalGroupInfo info) {
+ for (final FocusNode node in info.members) {
+ if (groupKeys.contains(node)) {
+ // This is a policy group focus node. Replace it with the members of
+ // the corresponding policy group.
+ visitGroups(groups[node]);
+ } else {
+ sortedDescendants.add(node);
+ }
+ }
+ }
+
+ visitGroups(groups[scopeGroupMarker?.focusNode]);
+ assert(
+ sortedDescendants.toSet().difference(scope.traversalDescendants.toSet()).isEmpty,
+ '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;
+ }
+
+ // Moves the focus to the next node in the FocusScopeNode nearest to the
+ // currentNode argument, either in a forward or reverse direction, depending
+ // on the value of the forward argument.
+ //
+ // This function is called by the next and previous members to move to the
+ // next or previous node, respectively.
+ //
+ // Uses findFirstFocus to find the first node if there is no
+ // FocusScopeNode.focusedChild set. If there is a focused child for the
+ // scope, then it calls sortDescendants to get a sorted list of descendants,
+ // and then finds the node after the current first focus of the scope if
+ // forward is true, and the node before it if forward is false.
+ //
+ // Returns true if a node requested focus.
+ @protected
+ bool _moveFocus(FocusNode currentNode, {@required bool forward}) {
+ assert(forward != null);
+ if (currentNode == null) {
+ return false;
+ }
+ final FocusScopeNode nearestScope = currentNode.nearestScope;
+ invalidateScopeData(nearestScope);
+ final FocusNode focusedChild = nearestScope.focusedChild;
+ if (focusedChild == null) {
+ final FocusNode firstFocus = findFirstFocus(currentNode);
+ if (firstFocus != null) {
+ _focusAndEnsureVisible(
+ firstFocus,
+ alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
+ );
+ return true;
+ }
+ }
+ final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope);
+ if (forward && focusedChild == sortedNodes.last) {
+ _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
+ return true;
+ }
+ if (!forward && focusedChild == sortedNodes.first) {
+ _focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
+ return true;
+ }
+
+ final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
+ FocusNode previousNode;
+ for (final FocusNode node in maybeFlipped) {
+ if (previousNode == focusedChild) {
+ _focusAndEnsureVisible(
+ node,
+ alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
+ );
+ return true;
+ }
+ previousNode = node;
+ }
+ return false;
+ }
}
-@protected
-void _focusAndEnsureVisible(FocusNode node, {ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit}) {
- node.requestFocus();
- Scrollable.ensureVisible(node.context, alignment: 1.0, alignmentPolicy: alignmentPolicy);
-}
-
-/// A policy data object for use by the [DirectionalFocusTraversalPolicyMixin]
+// A policy data object for use by the DirectionalFocusTraversalPolicyMixin so
+// it can keep track of the traversal history.
class _DirectionalPolicyDataEntry {
const _DirectionalPolicyDataEntry({@required this.direction, @required this.node})
: assert(direction != null),
@@ -187,17 +404,20 @@
/// For instance, if the focus moves down, down, down, and then up, up, up, it
/// will follow the same path through the widgets in both directions. However,
/// if it moves down, down, down, left, right, and then up, up, up, it may not
-/// follow the same path on the way up as it did on the way down.
+/// follow the same path on the way up as it did on the way down, since changing
+/// the axis of motion resets the history.
///
/// See also:
///
/// * [FocusNode], for a description of the focus system.
-/// * [DefaultFocusTraversal], a widget that imposes a traversal policy on the
-/// [Focus] nodes below it in the widget hierarchy.
-/// * [WidgetOrderFocusTraversalPolicy], a policy that relies on the widget
+/// * [FocusTraversalGroup], a widget that groups together and imposes a
+/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
+/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget
/// creation order to describe the order of traversal.
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
/// natural "reading order" for the current [Directionality].
+/// * [OrderedTraversalPolicy], a policy that describes the order
+/// explicitly using [FocusTraversalOrder] widgets.
mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
final Map<FocusScopeNode, _DirectionalPolicyData> _policyData = <FocusScopeNode, _DirectionalPolicyData>{};
@@ -238,10 +458,10 @@
return null;
}
- FocusNode _sortAndFindInitial(FocusNode currentNode, { bool vertical, bool first }) {
+ FocusNode _sortAndFindInitial(FocusNode currentNode, {bool vertical, bool first}) {
final Iterable<FocusNode> nodes = currentNode.nearestScope.traversalDescendants;
final List<FocusNode> sorted = nodes.toList();
- sorted.sort((FocusNode a, FocusNode b) {
+ mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) {
if (vertical) {
if (first) {
return a.rect.top.compareTo(b.rect.top);
@@ -257,8 +477,9 @@
}
});
- if (sorted.isNotEmpty)
+ if (sorted.isNotEmpty) {
return sorted.first;
+ }
return null;
}
@@ -280,7 +501,7 @@
final Iterable<FocusNode> nodes = nearestScope.traversalDescendants;
assert(!nodes.contains(nearestScope));
final List<FocusNode> sorted = nodes.toList();
- sorted.sort((FocusNode a, FocusNode b) => a.rect.center.dx.compareTo(b.rect.center.dx));
+ mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dx.compareTo(b.rect.center.dx));
Iterable<FocusNode> result;
switch (direction) {
case TraversalDirection.left:
@@ -305,7 +526,7 @@
Iterable<FocusNode> nodes,
) {
final List<FocusNode> sorted = nodes.toList();
- sorted.sort((FocusNode a, FocusNode b) => a.rect.center.dy.compareTo(b.rect.center.dy));
+ mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) => a.rect.center.dy.compareTo(b.rect.center.dy));
switch (direction) {
case TraversalDirection.up:
return sorted.where((FocusNode node) => node.rect != target && node.rect.center.dy <= target.top);
@@ -329,9 +550,9 @@
if (policyData.history.last.node.parent == null) {
// If a node has been removed from the tree, then we should stop
// referencing it and reset the scope data so that we don't try and
- // request focus on it. This can happen in slivers where the rendered node
- // has been unmounted. This has the side effect that hysteresis might not
- // be avoided when items that go off screen get unmounted.
+ // request focus on it. This can happen in slivers where the rendered
+ // node has been unmounted. This has the side effect that hysteresis
+ // might not be avoided when items that go off screen get unmounted.
invalidateScopeData(nearestScope);
return false;
}
@@ -344,14 +565,14 @@
return false;
}
ScrollPositionAlignmentPolicy alignmentPolicy;
- switch(direction) {
+ switch (direction) {
case TraversalDirection.up:
case TraversalDirection.left:
alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtStart;
break;
case TraversalDirection.right:
case TraversalDirection.down:
- alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd;
+ alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd;
break;
}
_focusAndEnsureVisible(
@@ -486,12 +707,14 @@
final Rect band = Rect.fromLTRB(focusedChild.rect.left, -double.infinity, focusedChild.rect.right, double.infinity);
final Iterable<FocusNode> inBand = sorted.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
if (inBand.isNotEmpty) {
- // The inBand list is already sorted by horizontal distance, so pick the closest one.
+ // The inBand list is already sorted by horizontal distance, so pick
+ // the closest one.
found = inBand.first;
break;
}
- // Only out-of-band targets remain, so pick the one that is closest the to the center line horizontally.
- sorted.sort((FocusNode a, FocusNode b) {
+ // Only out-of-band targets remain, so pick the one that is closest the
+ // to the center line horizontally.
+ mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) {
return (a.rect.center.dx - focusedChild.rect.center.dx).abs().compareTo((b.rect.center.dx - focusedChild.rect.center.dx).abs());
});
found = sorted.first;
@@ -516,12 +739,14 @@
final Rect band = Rect.fromLTRB(-double.infinity, focusedChild.rect.top, double.infinity, focusedChild.rect.bottom);
final Iterable<FocusNode> inBand = sorted.where((FocusNode node) => !node.rect.intersect(band).isEmpty);
if (inBand.isNotEmpty) {
- // The inBand list is already sorted by vertical distance, so pick the closest one.
+ // The inBand list is already sorted by vertical distance, so pick the
+ // closest one.
found = inBand.first;
break;
}
- // Only out-of-band targets remain, so pick the one that is closest the to the center line vertically.
- sorted.sort((FocusNode a, FocusNode b) {
+ // Only out-of-band targets remain, so pick the one that is closest the
+ // to the center line vertically.
+ mergeSort<FocusNode>(sorted, compare: (FocusNode a, FocusNode b) {
return (a.rect.center.dy - focusedChild.rect.center.dy).abs().compareTo((b.rect.center.dy - focusedChild.rect.center.dy).abs());
});
found = sorted.first;
@@ -539,10 +764,10 @@
break;
case TraversalDirection.down:
case TraversalDirection.right:
- _focusAndEnsureVisible(
- found,
- alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
- );
+ _focusAndEnsureVisible(
+ found,
+ alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
+ );
break;
}
return true;
@@ -560,115 +785,159 @@
/// See also:
///
/// * [FocusNode], for a description of the focus system.
-/// * [DefaultFocusTraversal], a widget that imposes a traversal policy on the
-/// [Focus] nodes below it in the widget hierarchy.
+/// * [FocusTraversalGroup], a widget that groups together and imposes a
+/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
/// natural "reading order" for the current [Directionality].
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
-class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
- /// Creates a const [WidgetOrderFocusTraversalPolicy].
- WidgetOrderFocusTraversalPolicy();
-
+/// * [OrderedTraversalPolicy], a policy that describes the order
+/// explicitly using [FocusTraversalOrder] widgets.
+class WidgetOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
@override
- FocusNode findFirstFocus(FocusNode currentNode) {
- assert(currentNode != null);
- final FocusScopeNode scope = currentNode.nearestScope;
- // Start with the candidate focus as the focused child of this scope, if
- // there is one. Otherwise start with this node itself. Keep going down
- // through scopes until an ultimately focusable item is found, a scope
- // doesn't have a focusedChild, or a non-scope is encountered.
- FocusNode candidate = scope.focusedChild;
- if (candidate == null) {
- if (scope.traversalChildren.isNotEmpty) {
- candidate = scope.traversalChildren.first;
- } else {
- candidate = currentNode;
- }
- }
- while (candidate is FocusScopeNode && candidate.focusedChild != null) {
- final FocusScopeNode candidateScope = candidate as FocusScopeNode;
- candidate = candidateScope.focusedChild;
- }
- return candidate;
- }
-
- // Moves the focus to the next or previous node, depending on whether forward
- // is true or not.
- bool _move(FocusNode currentNode, {@required bool forward}) {
- if (currentNode == null) {
- return false;
- }
- final FocusScopeNode nearestScope = currentNode.nearestScope;
- invalidateScopeData(nearestScope);
- final FocusNode focusedChild = nearestScope.focusedChild;
- if (focusedChild == null) {
- final FocusNode firstFocus = findFirstFocus(currentNode);
- if (firstFocus != null) {
- _focusAndEnsureVisible(
- firstFocus,
- alignmentPolicy: forward
- ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
- : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
- );
- return true;
- }
- }
- FocusNode previousNode;
- FocusNode firstNode;
- FocusNode lastNode;
- bool visit(FocusNode node) {
- for (final FocusNode visited in node.traversalChildren) {
- firstNode ??= visited;
- if (!visit(visited)) {
- return false;
- }
- if (forward) {
- if (previousNode == focusedChild) {
- _focusAndEnsureVisible(visited, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
- return false; // short circuit the traversal.
- }
- } else {
- if (previousNode != null && visited == focusedChild) {
- _focusAndEnsureVisible(previousNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
- return false; // short circuit the traversal.
- }
- }
- previousNode = visited;
- lastNode = visited;
- }
- return true; // continue traversal
- }
-
- if (visit(nearestScope)) {
- if (forward) {
- if (firstNode != null) {
- _focusAndEnsureVisible(firstNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
- return true;
- }
- } else {
- if (lastNode != null) {
- _focusAndEnsureVisible(lastNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
- return true;
- }
- }
- return false;
- }
- return true;
- }
-
- @override
- bool next(FocusNode currentNode) => _move(currentNode, forward: true);
-
- @override
- bool previous(FocusNode currentNode) => _move(currentNode, forward: false);
+ Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) => descendants;
}
-class _SortData {
- _SortData(this.node) : rect = node.rect;
+// This class exists mainly for efficiency reasons: the rect is copied out of
+// the node, because it will be accessed many times in the reading order
+// algorithm, and the FocusNode.rect accessor does coordinate transformation. If
+// not for this optimization, it could just be removed, and the node used
+// directly.
+//
+// It's also a convenient place to put some utility functions having to do with
+// the sort data.
+class _ReadingOrderSortData extends Diagnosticable {
+ _ReadingOrderSortData(this.node)
+ : assert(node != null),
+ rect = node.rect,
+ directionality = _findDirectionality(node.context);
+ final TextDirection directionality;
final Rect rect;
final FocusNode node;
+
+ // Find the directionality in force for a build context without creating a
+ // dependency.
+ static TextDirection _findDirectionality(BuildContext context) {
+ return (context.getElementForInheritedWidgetOfExactType<Directionality>()?.widget as Directionality)?.textDirection;
+ }
+
+ /// Finds the common Directional ancestor of an entire list of groups.
+ static TextDirection commonDirectionalityOf(List<_ReadingOrderSortData> list) {
+ final Iterable<Set<Directionality>> allAncestors = list.map<Set<Directionality>>((_ReadingOrderSortData member) => member.directionalAncestors.toSet());
+ Set<Directionality> common;
+ for (final Set<Directionality> ancestorSet in allAncestors) {
+ common ??= ancestorSet;
+ common = common.intersection(ancestorSet);
+ }
+ if (common.isEmpty) {
+ // If there is no common ancestor, then arbitrarily pick the
+ // directionality of the first group, which is the equivalent of the "first
+ // strongly typed" item in a bidi algorithm.
+ return list.first.directionality;
+ }
+ // Find the closest common ancestor. The memberAncestors list contains the
+ // ancestors for all members, but the first member's ancestry was
+ // added in order from nearest to furthest, so we can still use that
+ // to determine the closest one.
+ return list.first.directionalAncestors.firstWhere(common.contains).textDirection;
+ }
+
+ static void sortWithDirectionality(List<_ReadingOrderSortData> list, TextDirection directionality) {
+ mergeSort<_ReadingOrderSortData>(list, compare: (_ReadingOrderSortData a, _ReadingOrderSortData b) {
+ switch (directionality) {
+ case TextDirection.ltr:
+ return a.rect.left.compareTo(b.rect.left);
+ case TextDirection.rtl:
+ return b.rect.right.compareTo(a.rect.right);
+ }
+ assert(false, 'Unhandled directionality $directionality');
+ return 0;
+ });
+ }
+
+ /// Returns the list of Directionality ancestors, in order from nearest to
+ /// furthest.
+ Iterable<Directionality> get directionalAncestors {
+ List<Directionality> getDirectionalityAncestors(BuildContext context) {
+ final List<Directionality> result = <Directionality>[];
+ InheritedElement directionalityElement = context.getElementForInheritedWidgetOfExactType<Directionality>();
+ while (directionalityElement != null) {
+ result.add(directionalityElement.widget as Directionality);
+ directionalityElement = _getAncestor(directionalityElement)?.getElementForInheritedWidgetOfExactType<Directionality>();
+ }
+ return result;
+ }
+
+ _directionalAncestors ??= getDirectionalityAncestors(node.context);
+ return _directionalAncestors;
+ }
+
+ List<Directionality> _directionalAncestors;
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty<TextDirection>('directionality', directionality));
+ properties.add(StringProperty('name', node.debugLabel, defaultValue: null));
+ properties.add(DiagnosticsProperty<Rect>('rect', rect));
+ }
+}
+
+// A class for containing group data while sorting in reading order while taking
+// into account the ambient directionality.
+class _ReadingOrderDirectionalGroupData extends Diagnosticable {
+ _ReadingOrderDirectionalGroupData(this.members);
+
+ final List<_ReadingOrderSortData> members;
+
+ TextDirection get directionality => members.first.directionality;
+
+ Rect _rect;
+ Rect get rect {
+ if (_rect == null) {
+ for (final Rect rect in members.map<Rect>((_ReadingOrderSortData data) => data.rect)) {
+ _rect ??= rect;
+ _rect = _rect.expandToInclude(rect);
+ }
+ }
+ return _rect;
+ }
+
+ List<Directionality> get memberAncestors {
+ if (_memberAncestors == null) {
+ _memberAncestors = <Directionality>[];
+ for (final _ReadingOrderSortData member in members) {
+ _memberAncestors.addAll(member.directionalAncestors);
+ }
+ }
+ return _memberAncestors;
+ }
+
+ List<Directionality> _memberAncestors;
+
+ static void sortWithDirectionality(List<_ReadingOrderDirectionalGroupData> list, TextDirection directionality) {
+ mergeSort<_ReadingOrderDirectionalGroupData>(list, compare: (_ReadingOrderDirectionalGroupData a, _ReadingOrderDirectionalGroupData b) {
+ switch (directionality) {
+ case TextDirection.ltr:
+ return a.rect.left.compareTo(b.rect.left);
+ case TextDirection.rtl:
+ return b.rect.right.compareTo(a.rect.right);
+ }
+ assert(false, 'Unhandled directionality $directionality');
+ return 0;
+ });
+ }
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty<TextDirection>('directionality', directionality));
+ properties.add(DiagnosticsProperty<Rect>('rect', rect));
+ properties.add(IterableProperty<String>('members', members.map<String>((_ReadingOrderSortData member) {
+ return '"${member.node.debugLabel}"(${member.rect})';
+ })));
+ }
}
/// Traverses the focus order in "reading order".
@@ -682,160 +951,623 @@
/// 3. Pick the closest to the beginning of the reading order from among the
/// nodes discovered above.
///
-/// It uses the ambient directionality in the context for the enclosing scope to
-/// determine which direction is "reading order".
+/// It uses the ambient [Directionality] in the context for the enclosing scope
+/// to determine which direction is "reading order".
///
/// See also:
///
/// * [FocusNode], for a description of the focus system.
-/// * [DefaultFocusTraversal], a widget that imposes a traversal policy on the
-/// [Focus] nodes below it in the widget hierarchy.
-/// * [WidgetOrderFocusTraversalPolicy], a policy that relies on the widget
+/// * [FocusTraversalGroup], a widget that groups together and imposes a
+/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
+/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget
/// creation order to describe the order of traversal.
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
+/// * [OrderedTraversalPolicy], a policy that describes the order
+/// explicitly using [FocusTraversalOrder] widgets.
class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
- @override
- FocusNode findFirstFocus(FocusNode currentNode) {
- assert(currentNode != null);
- final FocusScopeNode scope = currentNode.nearestScope;
- FocusNode candidate = scope.focusedChild;
- if (candidate == null && scope.traversalChildren.isNotEmpty) {
- candidate = _sortByGeometry(scope).first;
+ // Collects the given candidates into groups by directionality. The candidates
+ // have already been sorted as if they all had the directionality of the
+ // nearest Directionality ancestor.
+ List<_ReadingOrderDirectionalGroupData> _collectDirectionalityGroups(Iterable<_ReadingOrderSortData> candidates) {
+ TextDirection currentDirection = candidates.first.directionality;
+ List<_ReadingOrderSortData> currentGroup = <_ReadingOrderSortData>[];
+ final List<_ReadingOrderDirectionalGroupData> result = <_ReadingOrderDirectionalGroupData>[];
+ // Split candidates into runs of the same directionality.
+ for (final _ReadingOrderSortData candidate in candidates) {
+ if (candidate.directionality == currentDirection) {
+ currentGroup.add(candidate);
+ continue;
+ }
+ currentDirection = candidate.directionality;
+ result.add(_ReadingOrderDirectionalGroupData(currentGroup));
+ currentGroup = <_ReadingOrderSortData>[candidate];
+ }
+ if (currentGroup.isNotEmpty) {
+ result.add(_ReadingOrderDirectionalGroupData(currentGroup));
+ }
+ // Sort each group separately. Each group has the same directionality.
+ for (final _ReadingOrderDirectionalGroupData bandGroup in result) {
+ if (bandGroup.members.length == 1) {
+ continue; // No need to sort one node.
+ }
+ _ReadingOrderSortData.sortWithDirectionality(bandGroup.members, bandGroup.directionality);
+ }
+ return result;
+ }
+
+ _ReadingOrderSortData _pickNext(List<_ReadingOrderSortData> candidates) {
+ // Find the topmost node by sorting on the top of the rectangles.
+ mergeSort<_ReadingOrderSortData>(candidates, compare: (_ReadingOrderSortData a, _ReadingOrderSortData b) => a.rect.top.compareTo(b.rect.top));
+ final _ReadingOrderSortData topmost = candidates.first;
+
+ // Find the candidates that are in the same horizontal band as the current one.
+ List<_ReadingOrderSortData> inBand(_ReadingOrderSortData current, Iterable<_ReadingOrderSortData> candidates) {
+ final Rect band = Rect.fromLTRB(double.negativeInfinity, current.rect.top, double.infinity, current.rect.bottom);
+ return candidates.where((_ReadingOrderSortData item) {
+ return !item.rect.intersect(band).isEmpty;
+ }).toList();
}
- // If we still didn't find any candidate, use the current node as a
- // fallback.
- candidate ??= currentNode;
- candidate ??= FocusManager.instance.rootScope;
- return candidate;
+ final List<_ReadingOrderSortData> inBandOfTop = inBand(topmost, candidates);
+ // It has to have at least topmost in it if the topmost is not degenerate.
+ assert(topmost.rect.isEmpty || inBandOfTop.isNotEmpty);
+
+ // The topmost rect in is in a band by itself, so just return that one.
+ if (inBandOfTop.length <= 1) {
+ return topmost;
+ }
+
+ // Now that we know there are others in the same band as the topmost, then pick
+ // the one at the beginning, depending on the text direction in force.
+
+ // Find out the directionality of the nearest common Directionality
+ // ancestor for all nodes. This provides a base directionality to use for
+ // the ordering of the groups.
+ final TextDirection nearestCommonDirectionality = _ReadingOrderSortData.commonDirectionalityOf(inBandOfTop);
+
+ // Do an initial common-directionality-based sort to get consistent geometric
+ // ordering for grouping into directionality groups. It has to use the
+ // common directionality to be able to group into sane groups for the
+ // given directionality, since rectangles can overlap and give different
+ // results for different directionalities.
+ _ReadingOrderSortData.sortWithDirectionality(inBandOfTop, nearestCommonDirectionality);
+
+ // Collect the top band into internally sorted groups with shared directionality.
+ final List<_ReadingOrderDirectionalGroupData> bandGroups = _collectDirectionalityGroups(inBandOfTop);
+ if (bandGroups.length == 1) {
+ // There's only one directionality group, so just send back the first
+ // one in that group, since it's already sorted.
+ return bandGroups.first.members.first;
+ }
+
+ // Sort the groups based on the common directionality and bounding boxes.
+ _ReadingOrderDirectionalGroupData.sortWithDirectionality(bandGroups, nearestCommonDirectionality);
+ return bandGroups.first.members.first;
}
// Sorts the list of nodes based on their geometry into the desired reading
// order based on the directionality of the context for each node.
- Iterable<FocusNode> _sortByGeometry(FocusScopeNode scope) {
- final Iterable<FocusNode> nodes = scope.traversalDescendants;
- if (nodes.length <= 1) {
- return nodes;
+ @override
+ Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) {
+ assert(descendants != null);
+ if (descendants.length <= 1) {
+ return descendants;
}
- Iterable<_SortData> inBand(_SortData current, Iterable<_SortData> candidates) {
- final Rect wide = Rect.fromLTRB(double.negativeInfinity, current.rect.top, double.infinity, current.rect.bottom);
- return candidates.where((_SortData item) {
- return !item.rect.intersect(wide).isEmpty;
- });
- }
-
- final TextDirection textDirection = scope.context == null ? TextDirection.ltr : Directionality.of(scope.context);
- _SortData pickFirst(List<_SortData> candidates) {
- int compareBeginningSide(_SortData a, _SortData b) {
- return textDirection == TextDirection.ltr ? a.rect.left.compareTo(b.rect.left) : -a.rect.right.compareTo(b.rect.right);
- }
-
- int compareTopSide(_SortData a, _SortData b) {
- return a.rect.top.compareTo(b.rect.top);
- }
-
- // Get the topmost
- candidates.sort(compareTopSide);
- final _SortData topmost = candidates.first;
- // If there are any others in the band of the topmost, then pick the
- // leftmost one.
- final List<_SortData> inBandOfTop = inBand(topmost, candidates).toList();
- inBandOfTop.sort(compareBeginningSide);
- if (inBandOfTop.isNotEmpty) {
- return inBandOfTop.first;
- }
- return topmost;
- }
-
- final List<_SortData> data = <_SortData>[
- for (final FocusNode node in nodes) _SortData(node),
+ final List<_ReadingOrderSortData> data = <_ReadingOrderSortData>[
+ for (final FocusNode node in descendants) _ReadingOrderSortData(node),
];
- // Pick the initial widget as the one that is leftmost in the band of the
- // topmost, or the topmost, if there are no others in its band.
- final List<_SortData> sortedList = <_SortData>[];
- final List<_SortData> unplaced = data.toList();
- _SortData current = pickFirst(unplaced);
- sortedList.add(current);
+ final List<FocusNode> sortedList = <FocusNode>[];
+ final List<_ReadingOrderSortData> unplaced = data;
+
+ // Pick the initial widget as the one that is at the beginning of the band
+ // of the topmost, or the topmost, if there are no others in its band.
+ _ReadingOrderSortData current = _pickNext(unplaced);
+ sortedList.add(current.node);
unplaced.remove(current);
+ // Go through each node, picking the next one after eliminating the previous
+ // one, since removing the previously picked node will expose a new band in
+ // which to choose candidates.
while (unplaced.isNotEmpty) {
- final _SortData next = pickFirst(unplaced);
+ final _ReadingOrderSortData next = _pickNext(unplaced);
current = next;
- sortedList.add(current);
+ sortedList.add(current.node);
unplaced.remove(current);
}
- return sortedList.map((_SortData item) => item.node);
+ return sortedList;
}
-
- // Moves the focus forward or backward in reading order, depending on the
- // value of the forward argument.
- bool _move(FocusNode currentNode, {@required bool forward}) {
- final FocusScopeNode nearestScope = currentNode.nearestScope;
- invalidateScopeData(nearestScope);
- final FocusNode focusedChild = nearestScope.focusedChild;
- if (focusedChild == null) {
- final FocusNode firstFocus = findFirstFocus(currentNode);
- if (firstFocus != null) {
- _focusAndEnsureVisible(
- firstFocus,
- alignmentPolicy: forward
- ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
- : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
- );
- return true;
- }
- }
- final List<FocusNode> sortedNodes = _sortByGeometry(nearestScope).toList();
- if (forward && focusedChild == sortedNodes.last) {
- _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
- return true;
- }
- if (!forward && focusedChild == sortedNodes.first) {
- _focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
- return true;
- }
-
- final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
- FocusNode previousNode;
- for (final FocusNode node in maybeFlipped) {
- if (previousNode == focusedChild) {
- _focusAndEnsureVisible(
- node,
- alignmentPolicy: forward
- ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
- : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
- );
- return true;
- }
- previousNode = node;
- }
- return false;
- }
-
- @override
- bool next(FocusNode currentNode) => _move(currentNode, forward: true);
-
- @override
- bool previous(FocusNode currentNode) => _move(currentNode, forward: false);
}
-/// A widget that describes the inherited focus policy for focus traversal.
+/// Base class for all sort orders for [OrderedTraversalPolicy] traversal.
///
-/// By default, traverses in widget order using
-/// [ReadingOrderFocusTraversalPolicy].
+/// {@template flutter.widgets.focusorder.comparable}
+/// Only orders of the same type are comparable. If a set of widgets in the same
+/// [FocusTraversalGroup] contains orders that are not comparable with each other, it
+/// will assert, since the ordering between such keys is undefined. To avoid
+/// collisions, use a [FocusTraversalGroup] to group similarly ordered widgets
+/// together.
+///
+/// When overriding, [doCompare] must be overridden instead of [compareTo],
+/// which calls [doCompare] to do the actual comparison.
+/// {@endtemplate}
+///
+/// See also:
+///
+/// * [FocusTraversalGroup], a widget that groups together and imposes a
+/// traversal policy on the [Focus] nodes below it in the widget hierarchy.
+/// * [FocusTraversalOrder], a widget that assigns an order to a widget subtree
+/// for the [OrderedFocusTraversalPolicy] to use.
+/// * [NumericFocusOrder], for a focus order that describes its order with a
+/// `double`.
+/// * [LexicalFocusOrder], a focus order that assigns a string-based lexical
+/// traversal order to a [FocusTraversalOrder] widget.
+@immutable
+abstract class FocusOrder extends Diagnosticable implements Comparable<FocusOrder> {
+ /// Abstract const constructor. This constructor enables subclasses to provide
+ /// const constructors so that they can be used in const expressions.
+ const FocusOrder();
+
+ /// Compares this object to another [Comparable].
+ ///
+ /// When overriding [FocusOrder], implement [doCompare] instead of this
+ /// function to do the actual comparison.
+ ///
+ /// Returns a value like a [Comparator] when comparing `this` to [other].
+ /// That is, it returns a negative integer if `this` is ordered before [other],
+ /// a positive integer if `this` is ordered after [other],
+ /// and zero if `this` and [other] are ordered together.
+ ///
+ /// The [other] argument must be a value that is comparable to this object.
+ @override
+ @nonVirtual
+ int compareTo(FocusOrder other) {
+ assert(
+ runtimeType == other.runtimeType,
+ "The sorting algorithm must not compare incomparable keys, since they don't "
+ 'know how to order themselves relative to each other. Comparing $this with $other');
+ return doCompare(other);
+ }
+
+ /// The subclass implementation called by [compareTo] to compare orders.
+ ///
+ /// The argument is guaranteed to be of the same [runtimeType] as this object.
+ ///
+ /// The method should return a negative number if this object comes earlier in
+ /// the sort order than the `other` argument; and a positive number if it
+ /// comes later in the sort order than `other`. Returning zero causes the
+ /// system to fall back to the secondary sort order defined by
+ /// [OrderedTraversalPolicy.secondary]
+ @protected
+ int doCompare(covariant FocusOrder other);
+}
+
+/// Can be given to a [FocusTraversalOrder] widget to assign a numerical order
+/// to a widget subtree that is using a [OrderedTraversalPolicy] to define the
+/// order in which widgets should be traversed with the keyboard.
+///
+/// {@macro flutter.widgets.focusorder.comparable}
+///
+/// See also:
+///
+/// * [FocusTraversalOrder], a widget that assigns an order to a widget subtree
+/// for the [OrderedFocusTraversalPolicy] to use.
+class NumericFocusOrder extends FocusOrder {
+ /// Const constructor. This constructor enables subclasses to provide
+ /// const constructors so that they can be used in const expressions.
+ const NumericFocusOrder(this.order) : assert(order != null);
+
+ /// The numerical order to assign to the widget subtree using
+ /// [FocusTraversalOrder].
+ ///
+ /// Determines the placement of this widget in a sequence of widgets that defines
+ /// the order in which this node is traversed by the focus policy.
+ ///
+ /// Lower values will be traversed first.
+ final double order;
+
+ @override
+ int doCompare(NumericFocusOrder other) => order.compareTo(other.order);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DoubleProperty('order', order));
+ }
+}
+
+/// Can be given to a [FocusTraversalOrder] widget to use a String to assign a
+/// lexical order to a widget subtree that is using a
+/// [OrderedTraversalPolicy] to define the order in which widgets should be
+/// traversed with the keyboard.
+///
+/// This sorts strings using Dart's default string comparison, which is not
+/// locale specific.
+///
+/// {@macro flutter.widgets.focusorder.comparable}
+///
+/// See also:
+///
+/// * [FocusTraversalOrder], a widget that assigns an order to a widget subtree
+/// for the [OrderedFocusTraversalPolicy] to use.
+class LexicalFocusOrder extends FocusOrder {
+ /// Const constructor. This constructor enables subclasses to provide
+ /// const constructors so that they can be used in const expressions.
+ const LexicalFocusOrder(this.order) : assert(order != null);
+
+ /// The String that defines the lexical order to assign to the widget subtree
+ /// using [FocusTraversalOrder].
+ ///
+ /// Determines the placement of this widget in a sequence of widgets that defines
+ /// the order in which this node is traversed by the focus policy.
+ ///
+ /// Lower lexical values will be traversed first (e.g. 'a' comes before 'z').
+ final String order;
+
+ @override
+ int doCompare(LexicalFocusOrder other) => order.compareTo(other.order);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(StringProperty('order', order));
+ }
+}
+
+// Used to help sort the focus nodes in an OrderedFocusTraversalPolicy.
+class _OrderedFocusInfo {
+ const _OrderedFocusInfo({@required this.node, @required this.order})
+ : assert(node != null),
+ assert(order != null);
+
+ final FocusNode node;
+ final FocusOrder order;
+}
+
+/// A [FocusTraversalPolicy] that orders nodes by an explicit order that resides
+/// in the nearest [FocusTraversalOrder] widget ancestor.
+///
+/// {@macro flutter.widgets.focusorder.comparable}
+///
+/// {@tool dartpad --template=stateless_widget_scaffold_center}
+/// This sample shows how to assign a traversal order to a widget. In the
+/// example, the focus order goes from bottom right (the "One" button) to top
+/// left (the "Six" button).
+///
+/// ```dart preamble
+/// class DemoButton extends StatelessWidget {
+/// const DemoButton({this.name, this.autofocus = false, this.order});
+///
+/// final String name;
+/// final bool autofocus;
+/// final double order;
+///
+/// void _handleOnPressed() {
+/// print('Button $name pressed.');
+/// debugDumpFocusTree();
+/// }
+///
+/// @override
+/// Widget build(BuildContext context) {
+/// return FocusTraversalOrder(
+/// order: NumericFocusOrder(order),
+/// child: FlatButton(
+/// autofocus: autofocus,
+/// focusColor: Colors.red,
+/// onPressed: () => _handleOnPressed(),
+/// child: Text(name),
+/// ),
+/// );
+/// }
+/// }
+/// ```
+///
+/// ```dart
+/// Widget build(BuildContext context) {
+/// return FocusTraversalGroup(
+/// policy: OrderedTraversalPolicy(),
+/// child: Column(
+/// mainAxisAlignment: MainAxisAlignment.center,
+/// children: <Widget>[
+/// Row(
+/// mainAxisAlignment: MainAxisAlignment.center,
+/// children: const <Widget>[
+/// DemoButton(name: 'Six', order: 6),
+/// ],
+/// ),
+/// Row(
+/// mainAxisAlignment: MainAxisAlignment.center,
+/// children: const <Widget>[
+/// DemoButton(name: 'Five', order: 5),
+/// DemoButton(name: 'Four', order: 4),
+/// ],
+/// ),
+/// Row(
+/// mainAxisAlignment: MainAxisAlignment.center,
+/// children: const <Widget>[
+/// DemoButton(name: 'Three', order: 3),
+/// DemoButton(name: 'Two', order: 2),
+/// DemoButton(name: 'One', order: 1, autofocus: true),
+/// ],
+/// ),
+/// ],
+/// ),
+/// );
+/// }
+/// {@end-tool}
+///
+/// See also:
+///
+/// * [WidgetOrderFocusTraversalPolicy], a policy that relies on the widget
+/// creation order to describe the order of traversal.
+/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
+/// natural "reading order" for the current [Directionality].
+/// * [NumericFocusOrder], a focus order that assigns a numeric traversal order
+/// to a [FocusTraversalOrder] widget.
+/// * [LexicalFocusOrder], a focus order that assigns a string-based lexical
+/// traversal order to a [FocusTraversalOrder] widget.
+/// * [FocusOrder], an abstract base class for all types of focus traversal
+/// orderings.
+class OrderedTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
+ /// Constructs a traversal policy that orders widgets for keyboard traversal
+ /// based on an explicit order.
+ ///
+ /// If [secondary] is null, it will default to [ReadingOrderTraversalPolicy].
+ OrderedTraversalPolicy({this.secondary});
+
+ /// This is the policy that is used when a node doesn't have an order
+ /// assigned, or when multiple nodes have orders which are identical.
+ ///
+ /// If not set, this defaults to [ReadingOrderTraversalPolicy].
+ ///
+ /// This policy determines the secondary sorting order of nodes which evaluate
+ /// as having an identical order (including those with no order specified).
+ ///
+ /// Nodes with no order specified will be sorted after nodes with an explicit
+ /// order.
+ final FocusTraversalPolicy secondary;
+
+ @override
+ Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants) {
+ final FocusTraversalPolicy secondaryPolicy = secondary ?? ReadingOrderTraversalPolicy();
+ final Iterable<FocusNode> sortedDescendants = secondaryPolicy.sortDescendants(descendants);
+ final List<FocusNode> unordered = <FocusNode>[];
+ final List<_OrderedFocusInfo> ordered = <_OrderedFocusInfo>[];
+ for (final FocusNode node in sortedDescendants) {
+ final FocusOrder order = FocusTraversalOrder.of(node.context, nullOk: true);
+ if (order != null) {
+ ordered.add(_OrderedFocusInfo(node: node, order: order));
+ } else {
+ unordered.add(node);
+ }
+ }
+ mergeSort<_OrderedFocusInfo>(ordered, compare: (_OrderedFocusInfo a, _OrderedFocusInfo b) {
+ assert(
+ a.order.runtimeType == b.order.runtimeType,
+ 'When sorting nodes for determining focus order, the order (${a.order}) of '
+ "node ${a.node}, isn't the same type as the order (${b.order}) of ${b.node}. "
+ "Incompatible order types can't be compared. Use a FocusTraversalGroup to group "
+ 'similar orders together.',
+ );
+ return a.order.compareTo(b.order);
+ });
+ return ordered.map<FocusNode>((_OrderedFocusInfo info) => info.node).followedBy(unordered);
+ }
+}
+
+/// An inherited widget that describes the order in which its child subtree
+/// should be traversed.
+///
+/// {@macro flutter.widgets.focusorder.comparable}
+///
+/// The order for a widget is determined by the [FocusOrder] returned by
+/// [FocusTraversalOrder.of] for a particular context.
+class FocusTraversalOrder extends InheritedWidget {
+ /// A const constructor so that subclasses can be const.
+ const FocusTraversalOrder({Key key, this.order, Widget child}) : super(key: key, child: child);
+
+ /// The order for the widget descendants of this [FocusTraversalOrder].
+ final FocusOrder order;
+
+ /// Finds the [FocusOrder] in the nearest ancestor [FocusTraversalOrder] widget.
+ ///
+ /// It does not create a rebuild dependency because changing the traversal
+ /// order doesn't change the widget tree, so nothing needs to be rebuilt as a
+ /// result of an order change.
+ static FocusOrder of(BuildContext context, {bool nullOk = false}) {
+ assert(context != null);
+ assert(nullOk != null);
+ final FocusTraversalOrder marker = context.getElementForInheritedWidgetOfExactType<FocusTraversalOrder>()?.widget as FocusTraversalOrder;
+ final FocusOrder order = marker?.order;
+ if (order == null && !nullOk) {
+ throw FlutterError('FocusTraversalOrder.of() was called with a context that '
+ 'does not contain a TraversalOrder widget. No TraversalOrder widget '
+ 'ancestor could be found starting from the context that was passed to '
+ 'FocusTraversalOrder.of().\n'
+ 'The context used was:\n'
+ ' $context');
+ }
+ return order;
+ }
+
+ // Since the order of traversal doesn't affect display of anything, we don't
+ // need to force a rebuild of anything that depends upon it.
+ @override
+ bool updateShouldNotify(InheritedWidget oldWidget) => false;
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty<FocusOrder>('order', order));
+ }
+}
+
+/// A widget that describes the inherited focus policy for focus traversal for
+/// its descendants, grouping them into a separate traversal group.
+///
+/// A traversal group is treated as one entity when sorted by the traversal
+/// algorithm, so it can be used to segregate different parts of the widget tree
+/// that need to be sorted using different algorithms and/or sort orders when
+/// using an [OrderedTraversalPolicy].
+///
+/// Within the group, it will use the given [policy] to order the elements. The
+/// group itself will be ordered using the parent group's policy.
+///
+/// By default, traverses in reading order using [ReadingOrderTraversalPolicy].
///
/// See also:
///
/// * [FocusNode], for a description of the focus system.
-/// * [WidgetOrderFocusTraversalPolicy], a policy that relies on the widget
+/// * [WidgetOrderTraversalPolicy], a policy that relies on the widget
/// creation order to describe the order of traversal.
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
/// natural "reading order" for the current [Directionality].
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
+class FocusTraversalGroup extends StatefulWidget {
+ /// Creates a [FocusTraversalGroup] object.
+ ///
+ /// The [child] argument must not be null.
+ FocusTraversalGroup({
+ Key key,
+ FocusTraversalPolicy policy,
+ @required this.child,
+ }) : policy = policy ?? ReadingOrderTraversalPolicy(),
+ super(key: key);
+
+ /// The child widget of this [FocusTraversalGroup].
+ ///
+ /// {@macro flutter.widgets.child}
+ final Widget child;
+
+ /// The policy used to move the focus from one focus node to another when
+ /// traversing them using a keyboard.
+ ///
+ /// If not specified, traverses in reading order using
+ /// [ReadingOrderTraversalPolicy].
+ ///
+ /// See also:
+ ///
+ /// * [FocusTraversalPolicy] for the API used to impose traversal order
+ /// policy.
+ /// * [WidgetOrderTraversalPolicy] for a traversal policy that traverses
+ /// nodes in the order they are added to the widget tree.
+ /// * [ReadingOrderTraversalPolicy] for a traversal policy that traverses
+ /// nodes in the reading order defined in the widget tree, and then top to
+ /// bottom.
+ final FocusTraversalPolicy policy;
+
+ /// Returns the focus policy set by the [FocusTraversalGroup] that most
+ /// tightly encloses the given [BuildContext].
+ ///
+ /// It does not create a rebuild dependency because changing the traversal
+ /// order doesn't change the widget tree, so nothing needs to be rebuilt as a
+ /// result of an order change.
+ ///
+ /// Will assert if no [FocusTraversalGroup] ancestor is found, and `nullOk` is false.
+ ///
+ /// If `nullOk` is true, then it will return null if it doesn't find a
+ /// [FocusTraversalGroup] ancestor.
+ static FocusTraversalPolicy of(BuildContext context, {bool nullOk = false}) {
+ assert(context != null);
+ final _FocusTraversalGroupMarker inherited = context?.dependOnInheritedWidgetOfExactType<_FocusTraversalGroupMarker>();
+ assert(() {
+ if (nullOk) {
+ return true;
+ }
+ if (inherited == null) {
+ throw FlutterError(
+ 'Unable to find a FocusTraversalGroup widget in the context.\n'
+ 'FocusTraversalGroup.of() was called with a context that does not contain a '
+ 'FocusTraversalGroup.\n'
+ 'No FocusTraversalGroup ancestor could be found starting from the context that was '
+ 'passed to FocusTraversalGroup.of(). This can happen because there is not a '
+ 'WidgetsApp or MaterialApp widget (those widgets introduce a FocusTraversalGroup), '
+ 'or it can happen if the context comes from a widget above those widgets.\n'
+ 'The context used was:\n'
+ ' $context',
+ );
+ }
+ return true;
+ }());
+ return inherited?.policy;
+ }
+
+ @override
+ _FocusTraversalGroupState createState() => _FocusTraversalGroupState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties.add(DiagnosticsProperty<FocusTraversalPolicy>('policy', policy));
+ }
+}
+
+class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
+ // The internal focus node used to collect the children of this node into a
+ // group, and to provide a context for the traversal algorithm to sort the
+ // group with.
+ FocusNode focusNode;
+
+ @override
+ void initState() {
+ super.initState();
+ focusNode = FocusNode(
+ canRequestFocus: false,
+ skipTraversal: true,
+ debugLabel: 'FocusTraversalGroup',
+ );
+ }
+
+ @override
+ void dispose() {
+ focusNode?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _FocusTraversalGroupMarker(
+ policy: widget.policy,
+ focusNode: focusNode,
+ child: Focus(
+ focusNode: focusNode,
+ canRequestFocus: false,
+ skipTraversal: true,
+ includeSemantics: false,
+ child: widget.child,
+ ),
+ );
+ }
+}
+
+// A "marker" inherited widget to make the group faster to find.
+class _FocusTraversalGroupMarker extends InheritedWidget {
+ const _FocusTraversalGroupMarker({
+ @required this.policy,
+ @required this.focusNode,
+ Widget child,
+ }) : assert(policy != null),
+ assert(focusNode != null),
+ super(child: child);
+
+ final FocusTraversalPolicy policy;
+ final FocusNode focusNode;
+
+ @override
+ bool updateShouldNotify(InheritedWidget oldWidget) => false;
+}
+
+/// A deprecated widget that describes the inherited focus policy for focus
+/// traversal for its descendants.
+///
+/// _This widget has been deprecated: use [FocusTraversalGroup] instead._
+@Deprecated(
+ 'Use FocusTraversalGroup as a replacement for DefaultFocusTraversal. Be aware that FocusTraversalGroup does add an (unfocusable) Focus widget to the hierarchy that DefaultFocusTraversal does not. Use FocusTraversalGroup.of(context) as a replacement for DefaultFocusTraversal.of(context). '
+ 'This feature was deprecated after v1.14.3.'
+)
class DefaultFocusTraversal extends InheritedWidget {
/// Creates a [DefaultFocusTraversal] object.
///
@@ -846,7 +1578,10 @@
@required Widget child,
}) : super(key: key, child: child);
- /// The policy used to move the focus from one focus node to another.
+ /// The policy used to move the focus from one focus node to another when
+ /// traversing them using a keyboard.
+ ///
+ /// _This widget has been deprecated: use [FocusTraversalGroup] instead._
///
/// If not specified, traverses in reading order using
/// [ReadingOrderTraversalPolicy].
@@ -855,7 +1590,7 @@
///
/// * [FocusTraversalPolicy] for the API used to impose traversal order
/// policy.
- /// * [WidgetOrderFocusTraversalPolicy] for a traversal policy that traverses
+ /// * [WidgetOrderTraversalPolicy] for a traversal policy that traverses
/// nodes in the order they are added to the widget tree.
/// * [ReadingOrderTraversalPolicy] for a traversal policy that traverses
/// nodes in the reading order defined in the widget tree, and then top to
@@ -865,24 +1600,37 @@
/// Returns the [FocusTraversalPolicy] that most tightly encloses the given
/// [BuildContext].
///
+ /// _This method has been deprecated: use `FocusTraversalGroup.of(context)` instead._
+ ///
+ /// It does not create a rebuild dependency because changing the traversal
+ /// order doesn't change the widget tree, so nothing needs to be rebuilt as a
+ /// result of an order change.
+ ///
/// The [context] argument must not be null.
- static FocusTraversalPolicy of(BuildContext context, { bool nullOk = false }) {
- assert(context != null);
- final DefaultFocusTraversal inherited = context.dependOnInheritedWidgetOfExactType<DefaultFocusTraversal>();
+ static FocusTraversalPolicy of(BuildContext context, {bool nullOk = false}) {
+ final DefaultFocusTraversal inherited = context.getElementForInheritedWidgetOfExactType<DefaultFocusTraversal>()?.widget as DefaultFocusTraversal;
assert(() {
if (nullOk) {
return true;
}
+ if (context == null) {
+ throw FlutterError(
+ 'The context given to DefaultFocusTraversal.of was null, so '
+ 'consequently no FocusTraversalGroup ancestor can be found.',
+ );
+ }
if (inherited == null) {
- throw FlutterError('Unable to find a DefaultFocusTraversal widget in the context.\n'
- 'DefaultFocusTraversal.of() was called with a context that does not contain a '
- 'DefaultFocusTraversal.\n'
- 'No DefaultFocusTraversal ancestor could be found starting from the context that was '
- 'passed to DefaultFocusTraversal.of(). This can happen because there is not a '
- 'WidgetsApp or MaterialApp widget (those widgets introduce a DefaultFocusTraversal), '
- 'or it can happen if the context comes from a widget above those widgets.\n'
- 'The context used was:\n'
- ' $context');
+ throw FlutterError(
+ 'Unable to find a DefaultFocusTraversal widget in the context.\n'
+ 'DefaultFocusTraversal.of() was called with a context that does not contain a '
+ 'DefaultFocusTraversal.\n'
+ 'No DefaultFocusTraversal ancestor could be found starting from the context that was '
+ 'passed to DefaultFocusTraversal.of(). This can happen because there is not a '
+ 'WidgetsApp or MaterialApp widget (those widgets introduce a DefaultFocusTraversal), '
+ 'or it can happen if the context comes from a widget above those widgets.\n'
+ 'The context used was:\n'
+ ' $context',
+ );
}
return true;
}());
@@ -890,7 +1638,7 @@
}
@override
- bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy;
+ bool updateShouldNotify(DefaultFocusTraversal oldWidget) => false;
}
// A base class for all of the default actions that request focus for a node.
@@ -988,7 +1736,8 @@
/// Creates a [DirectionalFocusIntent] with a fixed [key], and the given
/// [direction].
const DirectionalFocusIntent(this.direction, {this.ignoreTextFields = true})
- : assert(ignoreTextFields != null), super(DirectionalFocusAction.key);
+ : assert(ignoreTextFields != null),
+ super(DirectionalFocusAction.key);
/// The direction in which to look for the next focusable node when the
/// associated [DirectionalFocusAction] is invoked.
diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart
index 892927f..78ed8e1 100644
--- a/packages/flutter/lib/src/widgets/framework.dart
+++ b/packages/flutter/lib/src/widgets/framework.dart
@@ -2191,6 +2191,8 @@
/// Obtains the element corresponding to the nearest widget of the given type [T],
/// which must be the type of a concrete [InheritedWidget] subclass.
///
+ /// Returns null if no such element is found.
+ ///
/// Calling this method is O(1) with a small constant factor.
///
/// This method does not establish a relationship with the target in the way
diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart
index dc4c85d..3b60092 100644
--- a/packages/flutter/test/material/checkbox_test.dart
+++ b/packages/flutter/test/material/checkbox_test.dart
@@ -68,7 +68,7 @@
),
));
- expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isEnabled: true,
@@ -83,7 +83,7 @@
),
));
- expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ expect(tester.getSemantics(find.byType(Focus)), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
@@ -99,7 +99,7 @@
),
));
- expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ expect(tester.getSemantics(find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_CheckboxRenderObjectWidget')), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
));
@@ -111,7 +111,7 @@
),
));
- expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ expect(tester.getSemantics(find.byWidgetPredicate((Widget widget) => widget.runtimeType.toString() == '_CheckboxRenderObjectWidget')), matchesSemantics(
hasCheckedState: true,
hasEnabledState: true,
isChecked: true,
diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart
index ce29aa4..1b1d7ab 100644
--- a/packages/flutter/test/material/debug_test.dart
+++ b/packages/flutter/test/material/debug_test.dart
@@ -116,7 +116,7 @@
' The ancestors of this widget were:\n'
' Semantics\n'
' Builder\n'
- ' RepaintBoundary-[GlobalKey#2d465]\n'
+ ' RepaintBoundary-[GlobalKey#00000]\n'
' IgnorePointer\n'
' AnimatedBuilder\n'
' FadeTransition\n'
@@ -131,19 +131,19 @@
' PageStorage\n'
' Offstage\n'
' _ModalScopeStatus\n'
- ' _ModalScope<dynamic>-[LabeledGlobalKey<_ModalScopeState<dynamic>>#969b7]\n'
+ ' _ModalScope<dynamic>-[LabeledGlobalKey<_ModalScopeState<dynamic>>#00000]\n'
' _EffectiveTickerMode\n'
' TickerMode\n'
- ' _OverlayEntryWidget-[LabeledGlobalKey<_OverlayEntryWidgetState>#545d0]\n'
+ ' _OverlayEntryWidget-[LabeledGlobalKey<_OverlayEntryWidgetState>#00000]\n'
' _Theatre\n'
- ' Overlay-[LabeledGlobalKey<OverlayState>#31a52]\n'
+ ' Overlay-[LabeledGlobalKey<OverlayState>#00000]\n'
' _FocusMarker\n'
' Semantics\n'
' FocusScope\n'
' AbsorbPointer\n'
' _PointerListener\n'
' Listener\n'
- ' Navigator-[GlobalObjectKey<NavigatorState> _WidgetsAppState#10579]\n'
+ ' Navigator-[GlobalObjectKey<NavigatorState> _WidgetsAppState#00000]\n'
' IconTheme\n'
' IconTheme\n'
' _InheritedCupertinoTheme\n'
@@ -158,19 +158,22 @@
' CheckedModeBanner\n'
' Title\n'
' Directionality\n'
- ' _LocalizationsScope-[GlobalKey#a51e3]\n'
+ ' _LocalizationsScope-[GlobalKey#00000]\n'
' Semantics\n'
' Localizations\n'
' MediaQuery\n'
' _MediaQueryFromWindow\n'
- ' DefaultFocusTraversal\n'
+ ' _FocusMarker\n'
+ ' Focus\n'
+ ' _FocusTraversalGroupMarker\n'
+ ' FocusTraversalGroup\n'
' Actions\n'
' _ShortcutsMarker\n'
' Semantics\n'
' _FocusMarker\n'
' Focus\n'
' Shortcuts\n'
- ' WidgetsApp-[GlobalObjectKey _MaterialAppState#38e79]\n'
+ ' WidgetsApp-[GlobalObjectKey _MaterialAppState#00000]\n'
' ScrollConfiguration\n'
' MaterialApp\n'
' [root]\n'
diff --git a/packages/flutter/test/material/icon_button_test.dart b/packages/flutter/test/material/icon_button_test.dart
index 094c950..39b011a 100644
--- a/packages/flutter/test/material/icon_button_test.dart
+++ b/packages/flutter/test/material/icon_button_test.dart
@@ -570,7 +570,7 @@
}
Widget wrap({ Widget child }) {
- return DefaultFocusTraversal(
+ return FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: Directionality(
textDirection: TextDirection.ltr,
diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart
index d97280c..101b87c 100644
--- a/packages/flutter/test/widgets/focus_scope_test.dart
+++ b/packages/flutter/test/widgets/focus_scope_test.dart
@@ -598,7 +598,7 @@
// This checks both FocusScopes that have their own nodes, as well as those
// that use external nodes.
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
child: Column(
children: <Widget>[
FocusScope(
@@ -661,7 +661,7 @@
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
child: Column(
children: <Widget>[
FocusScope(
@@ -711,7 +711,7 @@
expect(find.text('b'), findsOneWidget);
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
child: Column(
children: <Widget>[
FocusScope(
@@ -746,7 +746,7 @@
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
child: Column(
children: <Widget>[
FocusScope(
@@ -794,7 +794,7 @@
expect(find.text('b'), findsOneWidget);
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
child: Column(
children: <Widget>[
FocusScope(
diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart
index 4f286d8..6517a68 100644
--- a/packages/flutter/test/widgets/focus_traversal_test.dart
+++ b/packages/flutter/test/widgets/focus_traversal_test.dart
@@ -11,16 +11,18 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
+import 'semantics_tester.dart';
+
void main() {
- group(WidgetOrderFocusTraversalPolicy, () {
+ group(WidgetOrderTraversalPolicy, () {
testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2');
final GlobalKey key3 = GlobalKey(debugLabel: '3');
final GlobalKey key4 = GlobalKey(debugLabel: '4');
final GlobalKey key5 = GlobalKey(debugLabel: '5');
- await tester.pumpWidget(DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ await tester.pumpWidget(FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
key: key1,
child: Column(
@@ -64,8 +66,8 @@
bool focus3;
bool focus5;
await tester.pumpWidget(
- DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'key1',
key: key1,
@@ -177,8 +179,8 @@
final GlobalKey key5 = GlobalKey(debugLabel: '5');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
await tester.pumpWidget(
- DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
key: key1,
child: Column(
@@ -250,8 +252,8 @@
final FocusNode testNode2 = FocusNode(debugLabel: 'Second Focus Node');
await tester.pumpWidget(
MaterialApp(
- home: DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ home: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: Center(
child: Builder(builder: (BuildContext context) {
return MaterialButton(
@@ -317,7 +319,7 @@
final GlobalKey key3 = GlobalKey(debugLabel: '3');
final GlobalKey key4 = GlobalKey(debugLabel: '4');
final GlobalKey key5 = GlobalKey(debugLabel: '5');
- await tester.pumpWidget(DefaultFocusTraversal(
+ await tester.pumpWidget(FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
key: key1,
@@ -364,7 +366,7 @@
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
- child: DefaultFocusTraversal(
+ child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'key1',
@@ -473,7 +475,7 @@
final GlobalKey key5 = GlobalKey(debugLabel: '5');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
await tester.pumpWidget(
- DefaultFocusTraversal(
+ FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
key: key1,
@@ -538,6 +540,579 @@
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
});
+
+ testWidgets('Focus order is correct in the presence of different directionalities.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+ final FocusScopeNode scopeNode = FocusScopeNode();
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node $index'));
+ Widget buildTest(TextDirection topDirection) {
+ return Directionality(
+ textDirection: topDirection,
+ child: FocusTraversalGroup(
+ policy: ReadingOrderTraversalPolicy(),
+ child: FocusScope(
+ node: scopeNode,
+ child: Column(
+ children: <Widget>[
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Row(children: <Widget>[
+ Focus(
+ focusNode: nodes[0],
+ child: Container(width: 10, height: 10),
+ ),
+ Focus(
+ focusNode: nodes[1],
+ child: Container(width: 10, height: 10),
+ ),
+ Focus(
+ focusNode: nodes[2],
+ child: Container(width: 10, height: 10),
+ ),
+ ]),
+ ),
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Row(children: <Widget>[
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: Focus(
+ focusNode: nodes[3],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: Focus(
+ focusNode: nodes[4],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Focus(
+ focusNode: nodes[5],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ]),
+ ),
+ Row(children: <Widget>[
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Focus(
+ focusNode: nodes[6],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: Focus(
+ focusNode: nodes[7],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: Focus(
+ focusNode: nodes[8],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Focus(
+ focusNode: nodes[9],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ]),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+ await tester.pumpWidget(buildTest(TextDirection.rtl));
+
+ // The last four *are* correct: the Row is sensitive to the directionality
+ // too, so it swaps the positions of 7 and 8.
+ final List<int> order = <int>[];
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(<int>[0, 1, 2, 4, 3, 5, 6, 7, 8, 9]));
+
+ await tester.pumpWidget(buildTest(TextDirection.ltr));
+
+ order.clear();
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(<int>[0, 1, 2, 4, 3, 5, 6, 8, 7, 9]));
+ });
+
+ testWidgets('Focus order is reading order regardless of widget order, even when overlapping.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node $index'));
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: FocusTraversalGroup(
+ policy: ReadingOrderTraversalPolicy(),
+ child: Stack(
+ alignment: const Alignment(-1, -1),
+ children: List<Widget>.generate(nodeCount, (int index) {
+ // Boxes that all have the same upper left origin corner.
+ return Focus(
+ focusNode: nodes[index],
+ child: Container(width: 10.0 * (index + 1), height: 10.0 * (index + 1)),
+ );
+ }),
+ ),
+ ),
+ ),
+ );
+
+ final List<int> order = <int>[];
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(<int>[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]));
+
+ // Concentric boxes.
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: FocusTraversalGroup(
+ policy: ReadingOrderTraversalPolicy(),
+ child: Stack(
+ alignment: const Alignment(0, 0),
+ children: List<Widget>.generate(nodeCount, (int index) {
+ return Focus(
+ focusNode: nodes[index],
+ child: Container(width: 10.0 * (index + 1), height: 10.0 * (index + 1)),
+ );
+ }),
+ ),
+ ),
+ ),
+ );
+
+ order.clear();
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(<int>[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]));
+
+ // Stacked (vertically) and centered (horizontally, on each other)
+ // widgets, not overlapping.
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: FocusTraversalGroup(
+ policy: ReadingOrderTraversalPolicy(),
+ child: Stack(
+ alignment: const Alignment(0, 0),
+ children: List<Widget>.generate(nodeCount, (int index) {
+ return Positioned(
+ top: 5.0 * index * (index + 1),
+ left: 5.0 * (9 - index),
+ child: Focus(
+ focusNode: nodes[index],
+ child: Container(
+ decoration: BoxDecoration(border: Border.all()),
+ width: 10.0 * (index + 1),
+ height: 10.0 * (index + 1),
+ ),
+ ),
+ );
+ }),
+ ),
+ ),
+ ),
+ );
+
+ order.clear();
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(<int>[1, 2, 3, 4, 5, 6, 7, 8, 9, 0]));
+ });
+ });
+
+ group(OrderedTraversalPolicy, () {
+ testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async {
+ final GlobalKey key1 = GlobalKey(debugLabel: '1');
+ final GlobalKey key2 = GlobalKey(debugLabel: '2');
+ await tester.pumpWidget(FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: ReadingOrderTraversalPolicy()),
+ child: FocusScope(
+ child: Column(
+ children: <Widget>[
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(2),
+ child: Focus(
+ child: Container(key: key1, width: 100, height: 100),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(1),
+ child: Focus(
+ child: Container(key: key2, width: 100, height: 100),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ));
+
+ final Element firstChild = tester.element(find.byKey(key1));
+ final Element secondChild = tester.element(find.byKey(key2));
+ final FocusNode firstFocusNode = Focus.of(firstChild);
+ final FocusNode secondFocusNode = Focus.of(secondChild);
+ final FocusNode scope = Focus.of(firstChild).enclosingScope;
+ secondFocusNode.nextFocus();
+
+ await tester.pump();
+
+ expect(firstFocusNode.hasFocus, isFalse);
+ expect(secondFocusNode.hasFocus, isTrue);
+ expect(scope.hasFocus, isTrue);
+ });
+
+ testWidgets('Fall back to the secondary sort if no FocusTraversalOrder exists.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node $index'));
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.rtl,
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: FocusScope(
+ child: Row(
+ children: List<Widget>.generate(
+ nodeCount,
+ (int index) => Focus(
+ focusNode: nodes[index],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Because it should be using widget order, this shouldn't be affected by
+ // the directionality.
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ expect(nodes[i].hasPrimaryFocus, isTrue, reason: "node $i doesn't have focus, but should");
+ }
+
+ // Now check backwards.
+ for (int i = nodeCount - 1; i > 0; --i) {
+ nodes.first.previousFocus();
+ await tester.pump();
+ expect(nodes[i - 1].hasPrimaryFocus, isTrue, reason: "node ${i - 1} doesn't have focus, but should");
+ }
+ });
+
+ testWidgets('Move focus to next/previous node using numerical order.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node $index'));
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: FocusScope(
+ child: Row(
+ children: List<Widget>.generate(
+ nodeCount,
+ (int index) => FocusTraversalOrder(
+ order: NumericFocusOrder(nodeCount - index.toDouble()),
+ child: Focus(
+ focusNode: nodes[index],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // The orders are assigned to be backwards from normal, so should go backwards.
+ for (int i = nodeCount - 1; i >= 0; --i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ expect(nodes[i].hasPrimaryFocus, isTrue, reason: "node $i doesn't have focus, but should");
+ }
+
+ // Now check backwards.
+ for (int i = 1; i < nodeCount; ++i) {
+ nodes.first.previousFocus();
+ await tester.pump();
+ expect(nodes[i].hasPrimaryFocus, isTrue, reason: "node $i doesn't have focus, but should");
+ }
+ });
+
+ testWidgets('Move focus to next/previous node using lexical order.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+
+ /// Generate ['J' ... 'A'];
+ final List<String> keys = List<String>.generate(nodeCount, (int index) => String.fromCharCode('A'.codeUnits[0] + nodeCount - index - 1));
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node ${keys[index]}'));
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: FocusScope(
+ child: Row(
+ children: List<Widget>.generate(
+ nodeCount,
+ (int index) => FocusTraversalOrder(
+ order: LexicalFocusOrder(keys[index]),
+ child: Focus(
+ focusNode: nodes[index],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // The orders are assigned to be backwards from normal, so should go backwards.
+ for (int i = nodeCount - 1; i >= 0; --i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ expect(nodes[i].hasPrimaryFocus, isTrue, reason: "node $i doesn't have focus, but should");
+ }
+
+ // Now check backwards.
+ for (int i = 1; i < nodeCount; ++i) {
+ nodes.first.previousFocus();
+ await tester.pump();
+ expect(nodes[i].hasPrimaryFocus, isTrue, reason: "node $i doesn't have focus, but should");
+ }
+ });
+
+ testWidgets('Focus order is correct in the presence of FocusTraversalPolicyGroups.', (WidgetTester tester) async {
+ const int nodeCount = 10;
+ final FocusScopeNode scopeNode = FocusScopeNode();
+ final List<FocusNode> nodes = List<FocusNode>.generate(nodeCount, (int index) => FocusNode(debugLabel: 'Node $index'));
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
+ child: FocusScope(
+ node: scopeNode,
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: Row(
+ children: <Widget>[
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(0),
+ child: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
+ child: Row(children: <Widget>[
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(9),
+ child: Focus(
+ focusNode: nodes[9],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(8),
+ child: Focus(
+ focusNode: nodes[8],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(7),
+ child: Focus(
+ focusNode: nodes[7],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ]),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(1),
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: Row(children: <Widget>[
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(4),
+ child: Focus(
+ focusNode: nodes[4],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(5),
+ child: Focus(
+ focusNode: nodes[5],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(6),
+ child: Focus(
+ focusNode: nodes[6],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ]),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const NumericFocusOrder(2),
+ child: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: Row(children: <Widget>[
+ FocusTraversalOrder(
+ order: const LexicalFocusOrder('D'),
+ child: Focus(
+ focusNode: nodes[3],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const LexicalFocusOrder('C'),
+ child: Focus(
+ focusNode: nodes[2],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const LexicalFocusOrder('B'),
+ child: Focus(
+ focusNode: nodes[1],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ FocusTraversalOrder(
+ order: const LexicalFocusOrder('A'),
+ child: Focus(
+ focusNode: nodes[0],
+ child: Container(width: 10, height: 10),
+ ),
+ ),
+ ]),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final List<int> expectedOrder = <int>[9, 8, 7, 4, 5, 6, 0, 1, 2, 3];
+ final List<int> order = <int>[];
+ for (int i = 0; i < nodeCount; ++i) {
+ nodes.first.nextFocus();
+ await tester.pump();
+ order.add(nodes.indexOf(primaryFocus));
+ }
+ expect(order, orderedEquals(expectedOrder));
+ });
+
+ testWidgets('Find the initial focus when a route is pushed or popped.', (WidgetTester tester) async {
+ final GlobalKey key1 = GlobalKey(debugLabel: '1');
+ final GlobalKey key2 = GlobalKey(debugLabel: '2');
+ final FocusNode testNode1 = FocusNode(debugLabel: 'First Focus Node');
+ final FocusNode testNode2 = FocusNode(debugLabel: 'Second Focus Node');
+ await tester.pumpWidget(
+ MaterialApp(
+ home: FocusTraversalGroup(
+ policy: OrderedTraversalPolicy(secondary: WidgetOrderTraversalPolicy()),
+ child: Center(
+ child: Builder(builder: (BuildContext context) {
+ return FocusTraversalOrder(
+ order: const NumericFocusOrder(0),
+ child: MaterialButton(
+ key: key1,
+ focusNode: testNode1,
+ autofocus: true,
+ onPressed: () {
+ Navigator.of(context).push<void>(
+ MaterialPageRoute<void>(
+ builder: (BuildContext context) {
+ return Center(
+ child: FocusTraversalOrder(
+ order: const NumericFocusOrder(0),
+ child: MaterialButton(
+ key: key2,
+ focusNode: testNode2,
+ autofocus: true,
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ child: const Text('Go Back'),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ },
+ child: const Text('Go Forward'),
+ ),
+ );
+ }),
+ ),
+ ),
+ ),
+ );
+
+ final Element firstChild = tester.element(find.text('Go Forward'));
+ final FocusNode firstFocusNode = Focus.of(firstChild);
+ final FocusNode scope = Focus.of(firstChild).enclosingScope;
+ await tester.pump();
+
+ expect(firstFocusNode.hasFocus, isTrue);
+ expect(scope.hasFocus, isTrue);
+
+ await tester.tap(find.text('Go Forward'));
+ await tester.pumpAndSettle();
+
+ final Element secondChild = tester.element(find.text('Go Back'));
+ final FocusNode secondFocusNode = Focus.of(secondChild);
+
+ expect(firstFocusNode.hasFocus, isFalse);
+ expect(secondFocusNode.hasFocus, isTrue);
+
+ await tester.tap(find.text('Go Back'));
+ await tester.pumpAndSettle();
+
+ expect(firstFocusNode.hasFocus, isTrue);
+ expect(scope.hasFocus, isTrue);
+ });
});
group(DirectionalFocusTraversalPolicyMixin, () {
@@ -553,8 +1128,8 @@
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
- child: DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ child: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'Scope',
child: Column(
@@ -706,8 +1281,8 @@
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
- child: DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ child: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'Scope',
child: Column(
@@ -827,8 +1402,8 @@
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
- child: DefaultFocusTraversal(
- policy: WidgetOrderFocusTraversalPolicy(),
+ child: FocusTraversalGroup(
+ policy: WidgetOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'scope',
child: Column(
@@ -871,7 +1446,7 @@
await tester.pump();
- final FocusTraversalPolicy policy = DefaultFocusTraversal.of(upperLeftKey.currentContext);
+ final FocusTraversalPolicy policy = FocusTraversalGroup.of(upperLeftKey.currentContext);
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.up), equals(lowerLeftNode));
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.down), equals(upperLeftNode));
@@ -885,7 +1460,7 @@
final FocusNode focusBottom = FocusNode(debugLabel: 'bottom');
final FocusTraversalPolicy policy = ReadingOrderTraversalPolicy();
- await tester.pumpWidget(DefaultFocusTraversal(
+ await tester.pumpWidget(FocusTraversalGroup(
policy: policy,
child: FocusScope(
debugLabel: 'Scope',
@@ -909,7 +1484,7 @@
expect(focusBottom.hasFocus, isTrue);
// Remove center focus node.
- await tester.pumpWidget(DefaultFocusTraversal(
+ await tester.pumpWidget(FocusTraversalGroup(
policy: policy,
child: FocusScope(
debugLabel: 'Scope',
@@ -1380,6 +1955,14 @@
expect(events.length, 2);
});
});
+ group(FocusTraversalGroup, () {
+ testWidgets("Focus traversal group doesn't introduce a Semantics node", (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ await tester.pumpWidget(FocusTraversalGroup(child: Container()));
+ final TestSemantics expectedSemantics = TestSemantics.root();
+ expect(semantics, hasSemantics(expectedSemantics));
+ });
+ });
}
class TestRoute extends PageRouteBuilder<void> {