Unverified Commit d2fe0862 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Implements FocusTraversalPolicy and DefaultFocusTraversal features. (#30076)

This implements a DefaultFocusTraversal widget to describe the focus traversal policy for its children, defined by a FocusTraversalPolicy object from which custom policies may be created. Pre-defined policies include widget-order traversal, "reading order" traversal and directional traversal.
parent 9c77e8e8
...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
import 'banner.dart'; import 'banner.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
import 'localizations.dart'; import 'localizations.dart';
import 'media_query.dart'; import 'media_query.dart';
...@@ -1190,13 +1191,16 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv ...@@ -1190,13 +1191,16 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
assert(_debugCheckLocalizations(appLocale)); assert(_debugCheckLocalizations(appLocale));
return MediaQuery( return DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window), data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
child: Localizations( child: Localizations(
locale: appLocale, locale: appLocale,
delegates: _localizationsDelegates.toList(), delegates: _localizationsDelegates.toList(),
child: title, child: title,
), ),
),
); );
} }
} }
...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_scope.dart'; import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
/// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey] /// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey]
...@@ -191,6 +192,30 @@ class FocusAttachment { ...@@ -191,6 +192,30 @@ class FocusAttachment {
/// [FocusManager.rootScope], the event is discarded. /// [FocusManager.rootScope], the event is discarded.
/// {@endtemplate} /// {@endtemplate}
/// ///
/// ## Focus Traversal
///
/// The term _traversal_, sometimes called _tab traversal_, refers to moving the
/// focus from one widget to the next in a particular order (also sometimes
/// referred to as the _tab order_, since the TAB key is often bound to the
/// action to move to the next widget).
///
/// To give focus to the logical _next_ or _previous_ widget in the UI, call the
/// [nextFocus] or [previousFocus] methods. To give the focus to a widget in a
/// particular direction, call the [focusInDirection] method.
///
/// The policy for what the _next_ or _previous_ widget is, or the widget in a
/// 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
/// 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],
/// [ReadingOrderTraversalPolicy], and [DirectionalFocusTraversalPolicyMixin],
/// but custom policies can be built based upon these policies.
///
/// {@tool snippet --template=stateless_widget_scaffold} /// {@tool snippet --template=stateless_widget_scaffold}
/// This example shows how a FocusNode should be managed if not using the /// This example shows how a FocusNode should be managed if not using the
/// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar /// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar
...@@ -304,6 +329,10 @@ class FocusAttachment { ...@@ -304,6 +329,10 @@ class FocusAttachment {
/// widget tree. /// widget tree.
/// * [FocusManager], a singleton that manages the focus and distributes key /// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes. /// 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.
class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// Creates a focus node. /// Creates a focus node.
/// ///
...@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
assert(_manager == null || child != _manager.rootScope, "Reparenting the root node isn't allowed."); assert(_manager == null || child != _manager.rootScope, "Reparenting the root node isn't allowed.");
assert(!ancestors.contains(child), 'The supplied child is already an ancestor of this node. Loops are not allowed.'); assert(!ancestors.contains(child), 'The supplied child is already an ancestor of this node. Loops are not allowed.');
final FocusScopeNode oldScope = child.enclosingScope;
final bool hadFocus = child.hasFocus; final bool hadFocus = child.hasFocus;
child._parent?._removeChild(child); child._parent?._removeChild(child);
_children.add(child); _children.add(child);
...@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
// Update the focus chain for the current focus without changing it. // Update the focus chain for the current focus without changing it.
_manager?._currentFocus?._setAsFocusedChild(); _manager?._currentFocus?._setAsFocusedChild();
} }
if (oldScope != null && child.context != null && child.enclosingScope != oldScope) {
DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
}
} }
/// Called by the _host_ [StatefulWidget] to attach a [FocusNode] to the /// Called by the _host_ [StatefulWidget] to attach a [FocusNode] to the
...@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
assert(node.ancestors.contains(this), assert(node.ancestors.contains(this),
'Focus was requested for a node that is not a descendant of the scope from which it was requested.'); 'Focus was requested for a node that is not a descendant of the scope from which it was requested.');
node._doRequestFocus(isFromPolicy: false); node._doRequestFocus();
return; return;
} }
_doRequestFocus(isFromPolicy: false); _doRequestFocus();
} }
// Note that this is overridden in FocusScopeNode. // Note that this is overridden in FocusScopeNode.
void _doRequestFocus({@required bool isFromPolicy}) { void _doRequestFocus() {
assert(isFromPolicy != null);
_setAsFocusedChild(); _setAsFocusedChild();
if (hasPrimaryFocus) { if (hasPrimaryFocus) {
return; return;
...@@ -691,6 +723,24 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -691,6 +723,24 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
} }
/// Request to move the focus to the next focus node, by calling the
/// [FocusTraversalPolicy.next] method.
///
/// Returns true if it successfully found a node and requested focus.
bool nextFocus() => DefaultFocusTraversal.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);
/// 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);
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
...@@ -782,7 +832,7 @@ class FocusScopeNode extends FocusNode { ...@@ -782,7 +832,7 @@ class FocusScopeNode extends FocusNode {
} }
assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.'); assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.');
if (hasFocus) { if (hasFocus) {
scope._doRequestFocus(isFromPolicy: false); scope._doRequestFocus();
} else { } else {
scope._setAsFocusedChild(); scope._setAsFocusedChild();
} }
...@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode { ...@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode {
} }
assert(node.ancestors.contains(this), assert(node.ancestors.contains(this),
'Autofocus was requested for a node that is not a descendant of the scope from which it was requested.'); 'Autofocus was requested for a node that is not a descendant of the scope from which it was requested.');
node._doRequestFocus(isFromPolicy: false); node._doRequestFocus();
} }
} }
@override @override
void _doRequestFocus({@required bool isFromPolicy}) { void _doRequestFocus() {
assert(isFromPolicy != null);
// Start with the primary focus as the focused child of this scope, if there // Start with the primary focus as the focused child of this scope, if there
// is one. Otherwise start with this node itself. // is one. Otherwise start with this node itself.
FocusNode primaryFocus = focusedChild ?? this; FocusNode primaryFocus = focusedChild ?? this;
...@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode { ...@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode {
_setAsFocusedChild(); _setAsFocusedChild();
_markAsDirty(newFocus: primaryFocus); _markAsDirty(newFocus: primaryFocus);
} else { } else {
// We found a FocusScope at the leaf, so ask it to focus itself instead of
// this scope. That will cause this scope to return true from hasFocus,
// but false from hasPrimaryFocus.
primaryFocus.requestFocus(); primaryFocus.requestFocus();
} }
} }
...@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin {
_haveScheduledUpdate = false; _haveScheduledUpdate = false;
final FocusNode previousFocus = _currentFocus; final FocusNode previousFocus = _currentFocus;
if (_currentFocus == null && _nextFocus == null) { if (_currentFocus == null && _nextFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet,
// then pick a first one using widget order as a default.
_nextFocus = rootScope; _nextFocus = rootScope;
} }
if (_nextFocus != null && _nextFocus != _currentFocus) { if (_nextFocus != null && _nextFocus != _currentFocus) {
......
...@@ -132,6 +132,10 @@ import 'inherited_notifier.dart'; ...@@ -132,6 +132,10 @@ import 'inherited_notifier.dart';
/// traversal. /// traversal.
/// * [FocusManager], a singleton that manages the primary focus and /// * [FocusManager], a singleton that manages the primary focus and
/// distributes key events to focused nodes. /// distributes key events to focused nodes.
/// * [FocusTraversalPolicy], an object 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.
class Focus extends StatefulWidget { class Focus extends StatefulWidget {
/// Creates a widget that manages a [FocusNode]. /// Creates a widget that manages a [FocusNode].
/// ///
...@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> { ...@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> {
/// more information about the details of what node management entails if not /// more information about the details of what node management entails if not
/// using a [FocusScope] widget. /// using a [FocusScope] widget.
/// ///
/// A [DefaultTraversalPolicy] widget provides the [FocusTraversalPolicy] for
/// the [FocusScopeNode]s owned by its descendant widgets. Each [FocusScopeNode]
/// has [FocusNode] descendants. The traversal policy defines what "previous
/// focus", "next focus", and "move focus in this direction" means for them.
///
/// [FocusScopeNode]s remember the last [FocusNode] that was focused within
/// their descendants, and can move that focus to the next/previous node, or a
/// node in a particular direction when the [FocusNode.nextFocus],
/// [FocusNode.previousFocus], or [FocusNode.focusInDirection] are called on a
/// [FocusNode] or [FocusScopeNode].
///
/// To move the focus, use methods on [FocusScopeNode]. For instance, to move
/// the focus to the next node, call `Focus.of(context).nextFocus()`.
///
/// See also: /// See also:
/// ///
/// * [FocusScopeNode], which represents a scope node in the focus hierarchy. /// * [FocusScopeNode], which represents a scope node in the focus hierarchy.
...@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> { ...@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> {
/// managing focus without having to manage the node. /// managing focus without having to manage the node.
/// * [FocusManager], a singleton that manages the focus and distributes key /// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes. /// events to focused nodes.
/// * [FocusTraversalPolicy], an object 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.
class FocusScope extends Focus { class FocusScope extends Focus {
/// Creates a widget that manages a [FocusScopeNode]. /// Creates a widget that manages a [FocusScopeNode].
/// ///
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'basic.dart';
import 'focus_manager.dart';
import 'framework.dart';
/// A direction along either the horizontal or vertical axes.
///
/// This is used by the [DirectionalFocusTraversalPolicyMixin] to indicate which
/// direction to traverse in.
enum TraversalDirection {
/// Indicates a direction above the currently focused widget.
up,
/// Indicates a direction to the right of the currently focused widget.
///
/// This direction is unaffected by the [Directionality] of the current
/// context.
right,
/// Indicates a direction below the currently focused widget.
down,
/// Indicates a direction to the left of the currently focused widget.
///
/// This direction is unaffected by the [Directionality] of the current
/// context.
left,
// TODO(gspencer): Add diagonal traversal directions used by TV remotes and
// game controllers when we support them.
}
/// An object used to specify a focus traversal policy used for configuring a
/// [DefaultFocusTraversal] widget.
///
/// The focus traversal policy is what determines which widget is "next",
/// "previous", or in a direction from the currently focused [FocusNode].
///
/// One of the pre-defined subclasses may be used, or define a custom policy to
/// create a unique focus 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.
/// * [FocusNode], which is affected by the traversal policy.
/// * [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].
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
abstract class FocusTraversalPolicy {
/// Returns the node that should receive focus if there is no current focus
/// in the [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.
///
/// It is also used by the [FocusManager] to know which node to focus
/// initially if no nodes are focused.
///
/// 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);
/// 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.
///
/// This is typically used by [inDirection] to determine which node to focus
/// if it is called, but no node is currently focused.
///
/// All arguments must not be null.
FocusNode findFirstFocusInDirection(FocusNode currentNode, TraversalDirection direction);
/// Clears the data associated with the given [FocusScopeNode] for this object.
///
/// This is used to indicate that the focus policy has changed its mode, and
/// so any cached policy data should be invalidated. For example, changing the
/// direction in which focus is moving, or changing from directional to
/// next/previous navigation modes.
///
/// The default implementation does nothing.
@mustCallSuper
@protected
void invalidateScopeData(FocusScopeNode node) {}
/// This is called whenever the given [node] is reparented into a new scope,
/// so that the policy has a chance to update or invalidate any cached data
/// that it maintains per scope about the node.
///
/// The [oldScope] is the previous scope that this node belonged to, if any.
///
/// The default implementation does nothing.
@mustCallSuper
void changedScope({FocusNode node, FocusScopeNode oldScope}) {}
/// Focuses the next widget in the focus scope that contains the given
/// [currentNode].
///
/// This should determine what the next node to receive focus should be by
/// inspecting the node tree, and then calling [FocusNode.requestFocus] on
/// the node that has been selected.
///
/// Returns true if it successfully found a node and requested focus.
///
/// The [currentNode] argument must not be null.
bool next(FocusNode currentNode);
/// Focuses the previous widget in the focus scope that contains the given
/// [currentNode].
///
/// This should determine what the previous node to receive focus should be by
/// inspecting the node tree, and then calling [FocusNode.requestFocus] on
/// the node that has been selected.
///
/// Returns true if it successfully found a node and requested focus.
///
/// The [currentNode] argument must not be null.
bool previous(FocusNode currentNode);
/// Focuses the next widget in the given [direction] in the focus scope that
/// contains the given [currentNode].
///
/// This should determine what the next node to receive focus in the given
/// [direction] should be by inspecting the node tree, and then calling
/// [FocusNode.requestFocus] on the node that has been selected.
///
/// Returns true if it successfully found a node and requested focus.
///
/// All arguments must not be null.
bool inDirection(FocusNode currentNode, TraversalDirection direction);
}
/// A policy data object for use by the [DirectionalFocusTraversalPolicyMixin]
class _DirectionalPolicyDataEntry {
const _DirectionalPolicyDataEntry({@required this.direction, @required this.node})
: assert(direction != null),
assert(node != null);
final TraversalDirection direction;
final FocusNode node;
}
class _DirectionalPolicyData {
const _DirectionalPolicyData({@required this.history}) : assert(history != null);
/// A queue of entries that describe the path taken to the current node.
final List<_DirectionalPolicyDataEntry> history;
}
/// A mixin class that provides an implementation for finding a node in a
/// particular direction.
///
/// This can be mixed in to other [FocusTraversalPolicy] implementations that
/// only want to implement new next/previous policies.
///
/// Since hysteresis in the navigation order is undesirable, this implementation
/// maintains a stack of previous locations that have been visited on the
/// [policyData] for the affected [FocusScopeNode]. If the previous direction
/// was the opposite of the current direction, then the this policy will request
/// focus on the previously focused node. Change to another direction other than
/// the current one or its opposite will clear the stack.
///
/// 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.
///
/// 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
/// creation order to describe the order of traversal.
/// * [ReadingOrderTraversalPolicy], a policy that describes the order as the
/// natural "reading order" for the current [Directionality].
mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
final Map<FocusScopeNode, _DirectionalPolicyData> _policyData = <FocusScopeNode, _DirectionalPolicyData>{};
@override
void invalidateScopeData(FocusScopeNode node) {
super.invalidateScopeData(node);
_policyData.remove(node);
}
@override
void changedScope({FocusNode node, FocusScopeNode oldScope}) {
super.changedScope(node: node, oldScope: oldScope);
if (oldScope != null) {
_policyData[oldScope]?.history?.removeWhere((_DirectionalPolicyDataEntry entry) {
return entry.node == node;
});
}
}
@override
FocusNode findFirstFocusInDirection(FocusNode currentNode, TraversalDirection direction) {
assert(direction != null);
assert(currentNode != null);
switch (direction) {
case TraversalDirection.up:
// Find the bottom-most node so we can go up from there.
return _sortAndFindInitial(currentNode, vertical: true, first: false);
case TraversalDirection.down:
// Find the top-most node so we can go down from there.
return _sortAndFindInitial(currentNode, vertical: true, first: true);
case TraversalDirection.left:
// Find the right-most node so we can go left from there.
return _sortAndFindInitial(currentNode, vertical: false, first: false);
case TraversalDirection.right:
// Find the left-most node so we can go right from there.
return _sortAndFindInitial(currentNode, vertical: false, first: true);
}
return null;
}
FocusNode _sortAndFindInitial(FocusNode currentNode, { bool vertical, bool first }) {
final Iterable<FocusNode> nodes = currentNode.nearestScope.descendants;
final List<FocusNode> sorted = nodes.toList();
sorted.sort((FocusNode a, FocusNode b) {
if (vertical) {
if (first) {
return a.rect.top.compareTo(b.rect.top);
} else {
return b.rect.bottom.compareTo(a.rect.bottom);
}
} else {
if (first) {
return a.rect.left.compareTo(b.rect.left);
} else {
return b.rect.right.compareTo(a.rect.right);
}
}
});
return sorted.first;
}
// Sorts nodes from left to right horizontally, and removes nodes that are
// either to the right of the left side of the target node if we're going
// left, or to the left of the right side of the target node if we're going
// right.
//
// This doesn't need to take into account directionality because it is
// typically intending to actually go left or right, not in a reading
// direction.
Iterable<FocusNode> _sortAndFilterHorizontally(
TraversalDirection direction,
Rect target,
FocusNode nearestScope,
) {
assert(direction == TraversalDirection.left || direction == TraversalDirection.right);
final Iterable<FocusNode> nodes = nearestScope.descendants;
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));
Iterable<FocusNode> result;
switch (direction) {
case TraversalDirection.left:
result = sorted.where((FocusNode node) => node.rect != target && node.rect.center.dx <= target.left);
break;
case TraversalDirection.right:
result = sorted.where((FocusNode node) => node.rect != target && node.rect.center.dx >= target.right);
break;
case TraversalDirection.up:
case TraversalDirection.down:
break;
}
return result;
}
// Sorts nodes from top to bottom vertically, and removes nodes that are
// either below the top of the target node if we're going up, or above the
// bottom of the target node if we're going down.
Iterable<FocusNode> _sortAndFilterVertically(
TraversalDirection direction,
Rect target,
Iterable<FocusNode> nodes,
) {
final List<FocusNode> sorted = nodes.toList();
sorted.sort((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);
case TraversalDirection.down:
return sorted.where((FocusNode node) => node.rect != target && node.rect.center.dy >= target.bottom);
case TraversalDirection.left:
case TraversalDirection.right:
break;
}
assert(direction == TraversalDirection.up || direction == TraversalDirection.down);
return null;
}
// Updates the policy data to keep the previously visited node so that we can
// avoid hysteresis when we change directions in navigation.
//
// Returns true if focus was requested on a previous node.
bool _popPolicyDataIfNeeded(TraversalDirection direction, FocusScopeNode nearestScope, FocusNode focusedChild) {
final _DirectionalPolicyData policyData = _policyData[nearestScope];
if (policyData != null && policyData.history.isNotEmpty && policyData.history.first.direction != direction) {
switch (direction) {
case TraversalDirection.down:
case TraversalDirection.up:
switch (policyData.history.first.direction) {
case TraversalDirection.left:
case TraversalDirection.right:
// Reset the policy data if we change directions.
invalidateScopeData(nearestScope);
break;
case TraversalDirection.up:
case TraversalDirection.down:
policyData.history.removeLast().node.requestFocus();
return true;
}
break;
case TraversalDirection.left:
case TraversalDirection.right:
switch (policyData.history.first.direction) {
case TraversalDirection.left:
case TraversalDirection.right:
policyData.history.removeLast().node.requestFocus();
return true;
case TraversalDirection.up:
case TraversalDirection.down:
// Reset the policy data if we change directions.
invalidateScopeData(nearestScope);
break;
}
}
}
if (policyData != null && policyData.history.isEmpty) {
// Reset the policy data if we change directions.
invalidateScopeData(nearestScope);
}
return false;
}
void _pushPolicyData(TraversalDirection direction, FocusScopeNode nearestScope, FocusNode focusedChild) {
final _DirectionalPolicyData policyData = _policyData[nearestScope];
if (policyData != null && policyData is! _DirectionalPolicyData) {
return;
}
final _DirectionalPolicyDataEntry newEntry = _DirectionalPolicyDataEntry(node: focusedChild, direction: direction);
if (policyData != null) {
policyData.history.add(newEntry);
} else {
_policyData[nearestScope] = _DirectionalPolicyData(history: <_DirectionalPolicyDataEntry>[newEntry]);
}
}
/// Focuses the next widget in the given [direction] in the [FocusScope] that
/// contains the [currentNode].
///
/// This determines what the next node to receive focus in the given
/// [direction] will be by inspecting the node tree, and then calling
/// [FocusNode.requestFocus] on it.
///
/// Returns true if it successfully found a node and requested focus.
///
/// Maintains a stack of previous locations that have been visited on the
/// [policyData] for the affected [FocusScopeNode]. If the previous direction
/// was the opposite of the current direction, then the this policy will
/// request focus on the previously focused node. Change to another direction
/// other than the current one or its opposite will clear the stack.
///
/// If this function returns true when called by a subclass, then the subclass
/// should return true and not request focus from any node.
@mustCallSuper
@override
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
final FocusScopeNode nearestScope = currentNode.nearestScope;
final FocusNode focusedChild = nearestScope.focusedChild;
if (focusedChild == null) {
final FocusNode firstFocus = findFirstFocusInDirection(currentNode, direction);
(firstFocus ?? currentNode).requestFocus();
return true;
}
if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild)) {
return true;
}
FocusNode found;
switch (direction) {
case TraversalDirection.down:
case TraversalDirection.up:
final Iterable<FocusNode> eligibleNodes = _sortAndFilterVertically(
direction,
focusedChild.rect,
nearestScope.descendants,
);
if (eligibleNodes.isEmpty) {
break;
}
List<FocusNode> sorted = eligibleNodes.toList();
if (direction == TraversalDirection.up) {
sorted = sorted.reversed.toList();
}
// Find any nodes that intersect the band of the focused child.
final Rect band = Rect.fromLTRB(focusedChild.rect.left, -double.infinity, focusedChild.rect.right, double.infinity);
final Iterable<FocusNode> inBand = 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.
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) {
return (a.rect.center.dx - focusedChild.rect.center.dx).abs().compareTo((b.rect.center.dx - focusedChild.rect.center.dx).abs());
});
found = sorted.first;
break;
case TraversalDirection.right:
case TraversalDirection.left:
final Iterable<FocusNode> eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope);
if (eligibleNodes.isEmpty) {
break;
}
List<FocusNode> sorted = eligibleNodes.toList();
if (direction == TraversalDirection.left) {
sorted = sorted.reversed.toList();
}
// Find any nodes that intersect the band of the focused child.
final Rect band = Rect.fromLTRB(-double.infinity, focusedChild.rect.top, double.infinity, focusedChild.rect.bottom);
final Iterable<FocusNode> inBand = 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.
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) {
return (a.rect.center.dy - focusedChild.rect.center.dy).abs().compareTo((b.rect.center.dy - focusedChild.rect.center.dy).abs());
});
found = sorted.first;
break;
}
if (found != null) {
_pushPolicyData(direction, nearestScope, focusedChild);
found.requestFocus();
return true;
}
return false;
}
}
/// A [FocusTraversalPolicy] that traverses the focus order in widget hierarchy
/// order.
///
/// This policy is used when the order desired is the order in which widgets are
/// created in the widget hierarchy.
///
/// 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.
/// * [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();
@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.children.isNotEmpty) {
candidate = scope.children.first;
} else {
candidate = currentNode;
}
}
while (candidate is FocusScopeNode && candidate.focusedChild != null) {
final FocusScopeNode candidateScope = candidate;
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 node, {@required bool forward}) {
if (node == null) {
return false;
}
final FocusScopeNode nearestScope = node.nearestScope;
invalidateScopeData(nearestScope);
final FocusNode focusedChild = nearestScope.focusedChild;
if (focusedChild == null) {
findFirstFocus(node).requestFocus();
return true;
}
FocusNode previousNode;
FocusNode firstNode;
FocusNode lastNode;
bool visit(FocusNode node) {
for (FocusNode visited in node.children) {
firstNode ??= visited;
if (!visit(visited)) {
return false;
}
if (forward) {
if (previousNode == focusedChild) {
visited.requestFocus();
return false; // short circuit the traversal.
}
} else {
if (previousNode != null && visited == focusedChild) {
previousNode.requestFocus();
return false; // short circuit the traversal.
}
}
previousNode = visited;
lastNode = visited;
}
return true; // continue traversal
}
if (visit(nearestScope)) {
if (forward) {
if (firstNode != null) {
firstNode.requestFocus();
return true;
}
} else {
if (lastNode != null) {
lastNode.requestFocus();
return true;
}
}
return false;
}
return true;
}
@override
bool next(FocusNode currentNode) => _move(currentNode, forward: true);
@override
bool previous(FocusNode currentNode) => _move(currentNode, forward: false);
}
class _SortData {
_SortData(this.node) : rect = node.rect;
final Rect rect;
final FocusNode node;
}
/// Traverses the focus order in "reading order".
///
/// By default, reading order traversal goes in the reading direction, and then
/// down, using this algorithm:
///
/// 1. Find the node rectangle that has the highest `top` on the screen.
/// 2. Find any other nodes that intersect the infinite horizontal band defined
/// by the highest rectangle's top and bottom edges.
/// 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".
///
/// 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
/// creation order to describe the order of traversal.
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin {
@override
FocusNode findFirstFocus(FocusNode currentNode) {
assert(currentNode != null);
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;
while (candidate == null) {
if (candidate.nearestScope.children.isNotEmpty) {
candidate = _sortByGeometry(scope).first;
}
if (candidate is FocusScopeNode) {
scope = candidate;
candidate = scope.focusedChild;
continue;
}
}
if (candidate == null) {
if (scope.children.isNotEmpty) {
candidate = _sortByGeometry(scope).first;
} else {
candidate = currentNode;
}
}
while (candidate is FocusScopeNode && candidate.focusedChild != null) {
final FocusScopeNode candidateScope = candidate;
candidate = candidateScope.focusedChild;
}
return candidate;
}
// 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(FocusNode scope) {
final Iterable<FocusNode> nodes = scope.descendants;
if (nodes.length <= 1) {
return nodes;
}
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 (FocusNode node in nodes) {
data.add(_SortData(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);
unplaced.remove(current);
while (unplaced.isNotEmpty) {
final _SortData next = pickFirst(unplaced);
current = next;
sortedList.add(current);
unplaced.remove(current);
}
return sortedList.map((_SortData item) => item.node);
}
// 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) {
findFirstFocus(currentNode).requestFocus();
return true;
}
final List<FocusNode> sortedNodes = _sortByGeometry(nearestScope).toList();
if (forward && focusedChild == sortedNodes.last) {
sortedNodes.first.requestFocus();
return true;
}
if (!forward && focusedChild == sortedNodes.first) {
sortedNodes.last.requestFocus();
return true;
}
final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
FocusNode previousNode;
for (FocusNode node in maybeFlipped) {
if (previousNode == focusedChild) {
node.requestFocus();
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 an inherited focus policy for focus traversal.
///
/// By default, traverses in widget order using
/// [ReadingOrderFocusTraversalPolicy].
///
/// See also:
///
/// * [FocusNode], for a description of the focus system.
/// * [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].
/// * [DirectionalFocusTraversalPolicyMixin] a mixin class that implements
/// focus traversal in a direction.
class DefaultFocusTraversal extends InheritedWidget {
/// Creates a FocusTraversal object.
///
/// The [child] argument must not be null.
const DefaultFocusTraversal({
Key key,
this.policy,
@required Widget child,
}) : super(key: key, child: child);
/// The policy used to move the focus from one focus node to another.
///
/// If not specified, traverses in reading order using
/// [ReadingOrderTraversalPolicy].
///
/// See also:
///
/// * [FocusTraversalPolicy] for the API used to impose traversal order
/// policy.
/// * [WidgetOrderFocusTraversalPolicy] 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 [DefaultFocusTraversal] that most tightly encloses the given
/// [BuildContext].
///
/// The [context] argument must not be null.
static FocusTraversalPolicy of(BuildContext context, { bool nullOk = false }) {
assert(context != null);
final DefaultFocusTraversal inherited = context.inheritFromWidgetOfExactType(DefaultFocusTraversal);
assert(() {
if (nullOk) {
return true;
}
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');
}
return true;
}());
return inherited?.policy ?? ReadingOrderTraversalPolicy();
}
@override
bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy;
}
...@@ -35,6 +35,7 @@ export 'src/widgets/editable_text.dart'; ...@@ -35,6 +35,7 @@ export 'src/widgets/editable_text.dart';
export 'src/widgets/fade_in_image.dart'; export 'src/widgets/fade_in_image.dart';
export 'src/widgets/focus_manager.dart'; export 'src/widgets/focus_manager.dart';
export 'src/widgets/focus_scope.dart'; export 'src/widgets/focus_scope.dart';
export 'src/widgets/focus_traversal.dart';
export 'src/widgets/form.dart'; export 'src/widgets/form.dart';
export 'src/widgets/framework.dart'; export 'src/widgets/framework.dart';
export 'src/widgets/gesture_detector.dart'; export 'src/widgets/gesture_detector.dart';
......
...@@ -4057,7 +4057,7 @@ void main() { ...@@ -4057,7 +4057,7 @@ void main() {
}); });
testWidgets('TextField loses focus when disabled', (WidgetTester tester) async { testWidgets('TextField loses focus when disabled', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode(debugLabel: 'TextField Focus Node');
await tester.pumpWidget( await tester.pumpWidget(
boilerplate( boilerplate(
......
...@@ -744,6 +744,7 @@ void main() { ...@@ -744,6 +744,7 @@ void main() {
TextEditingController currentController = controller1; TextEditingController currentController = controller1;
StateSetter setState; StateSetter setState;
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Focus Node');
Widget builder() { Widget builder() {
return StatefulBuilder( return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) { builder: (BuildContext context, StateSetter setter) {
...@@ -757,7 +758,7 @@ void main() { ...@@ -757,7 +758,7 @@ void main() {
child: EditableText( child: EditableText(
backgroundCursorColor: Colors.grey, backgroundCursorColor: Colors.grey,
controller: currentController, controller: currentController,
focusNode: FocusNode(), focusNode: focusNode,
style: Typography(platform: TargetPlatform.android) style: Typography(platform: TargetPlatform.android)
.black .black
.subhead, .subhead,
...@@ -775,6 +776,8 @@ void main() { ...@@ -775,6 +776,8 @@ void main() {
} }
await tester.pumpWidget(builder()); await tester.pumpWidget(builder());
await tester.pump(); // An extra pump to allow focus request to go through.
await tester.showKeyboard(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText));
// Verify TextInput.setEditingState is fired with updated text when controller is replaced. // Verify TextInput.setEditingState is fired with updated text when controller is replaced.
......
...@@ -596,7 +596,8 @@ void main() { ...@@ -596,7 +596,8 @@ void main() {
// This checks both FocusScopes that have their own nodes, as well as those // This checks both FocusScopes that have their own nodes, as well as those
// that use external nodes. // that use external nodes.
await tester.pumpWidget( await tester.pumpWidget(
Column( DefaultFocusTraversal(
child: Column(
children: <Widget>[ children: <Widget>[
FocusScope( FocusScope(
key: scopeKeyA, key: scopeKeyA,
...@@ -625,6 +626,7 @@ void main() { ...@@ -625,6 +626,7 @@ void main() {
), ),
], ],
), ),
),
); );
FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode); FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode);
...@@ -647,7 +649,93 @@ void main() { ...@@ -647,7 +649,93 @@ void main() {
expect(WidgetsBinding.instance.focusManager.rootScope.children, isEmpty); expect(WidgetsBinding.instance.focusManager.rootScope.children, isEmpty);
}); });
// Arguably, this isn't correct behavior, but it is what happens now. // By "pinned", it means kept in the tree by a GlobalKey.
testWidgets("Removing pinned focused scope doesn't move focus to focused widget within next FocusScope", (WidgetTester tester) async {
final GlobalKey<TestFocusState> keyA = GlobalKey();
final GlobalKey<TestFocusState> keyB = GlobalKey();
final GlobalKey<TestFocusState> scopeKeyA = GlobalKey();
final GlobalKey<TestFocusState> scopeKeyB = GlobalKey();
final FocusScopeNode parentFocusScope1 = FocusScopeNode(debugLabel: 'Parent Scope 1');
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget(
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
key: scopeKeyA,
node: parentFocusScope1,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
),
),
FocusScope(
key: scopeKeyB,
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
),
),
],
),
),
);
FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode);
FocusScope.of(keyA.currentContext).requestFocus(keyA.currentState.focusNode);
final FocusScopeNode bScope = FocusScope.of(keyB.currentContext);
final FocusScopeNode aScope = FocusScope.of(keyA.currentContext);
WidgetsBinding.instance.focusManager.rootScope.setFirstFocus(bScope);
WidgetsBinding.instance.focusManager.rootScope.setFirstFocus(aScope);
await tester.pumpAndSettle();
expect(FocusScope.of(keyA.currentContext).isFirstFocus, isTrue);
expect(keyA.currentState.focusNode.hasFocus, isTrue);
expect(find.text('A FOCUSED'), findsOneWidget);
expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget);
await tester.pumpWidget(
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
key: scopeKeyB,
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
key: keyB,
name: 'b',
autofocus: true,
),
],
),
),
],
),
),
);
await tester.pump();
expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget);
});
testWidgets("Removing unpinned focused scope doesn't move focus to focused widget within next FocusScope", (WidgetTester tester) async { testWidgets("Removing unpinned focused scope doesn't move focus to focused widget within next FocusScope", (WidgetTester tester) async {
final GlobalKey<TestFocusState> keyA = GlobalKey(); final GlobalKey<TestFocusState> keyA = GlobalKey();
final GlobalKey<TestFocusState> keyB = GlobalKey(); final GlobalKey<TestFocusState> keyB = GlobalKey();
...@@ -655,7 +743,8 @@ void main() { ...@@ -655,7 +743,8 @@ void main() {
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2'); final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget( await tester.pumpWidget(
Column( DefaultFocusTraversal(
child: Column(
children: <Widget>[ children: <Widget>[
FocusScope( FocusScope(
node: parentFocusScope1, node: parentFocusScope1,
...@@ -683,6 +772,7 @@ void main() { ...@@ -683,6 +772,7 @@ void main() {
), ),
], ],
), ),
),
); );
FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode); FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode);
...@@ -700,12 +790,9 @@ void main() { ...@@ -700,12 +790,9 @@ void main() {
expect(keyB.currentState.focusNode.hasFocus, isFalse); expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget); expect(find.text('b'), findsOneWidget);
// If the FocusScope widgets are not pinned with GlobalKeys, then the first
// one remains and gets its guts replaced with the parentFocusScope2 and the
// "B" test widget, and in the process, the focus manager loses track of the
// focus.
await tester.pumpWidget( await tester.pumpWidget(
Column( DefaultFocusTraversal(
child: Column(
children: <Widget>[ children: <Widget>[
FocusScope( FocusScope(
node: parentFocusScope2, node: parentFocusScope2,
...@@ -721,6 +808,7 @@ void main() { ...@@ -721,6 +808,7 @@ void main() {
), ),
], ],
), ),
),
); );
await tester.pump(); await tester.pump();
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
void main() {
group(WidgetOrderFocusTraversalPolicy, () {
testWidgets('Move focus to next node.', (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');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
bool focus1;
bool focus2;
bool focus3;
bool focus5;
await tester.pumpWidget(
DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: FocusScope(
debugLabel: 'key1',
key: key1,
onFocusChange: (bool focus) => focus1 = focus,
child: Column(
children: <Widget>[
FocusScope(
debugLabel: 'key2',
key: key2,
onFocusChange: (bool focus) => focus2 = focus,
child: Column(
children: <Widget>[
Focus(
debugLabel: 'key3',
key: key3,
onFocusChange: (bool focus) => focus3 = focus,
child: Container(key: key4),
),
Focus(
debugLabel: 'key5',
key: key5,
onFocusChange: (bool focus) => focus5 = focus,
child: Container(key: key6),
),
],
),
),
],
),
),
),
);
final Element firstChild = tester.element(find.byKey(key4));
final Element secondChild = tester.element(find.byKey(key6));
final FocusNode firstFocusNode = Focus.of(firstChild);
final FocusNode secondFocusNode = Focus.of(secondChild);
final FocusNode scope = Focus.of(firstChild).enclosingScope;
firstFocusNode.requestFocus();
await tester.pump();
expect(focus1, isTrue);
expect(focus2, isTrue);
expect(focus3, isTrue);
expect(focus5, isNull);
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
focus1 = null;
focus2 = null;
focus3 = null;
focus5 = null;
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isFalse);
expect(focus5, isTrue);
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
focus1 = null;
focus2 = null;
focus3 = null;
focus5 = null;
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isTrue);
expect(focus5, isFalse);
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
focus1 = null;
focus2 = null;
focus3 = null;
focus5 = null;
// Tests that can still move back to original node.
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isFalse);
expect(focus5, isTrue);
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
});
testWidgets('Move focus to previous node.', (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');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
await tester.pumpWidget(
DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: FocusScope(
key: key1,
child: Column(
children: <Widget>[
FocusScope(
key: key2,
child: Column(
children: <Widget>[
Focus(
key: key3,
child: Container(key: key4),
),
Focus(
key: key5,
child: Container(key: key6),
),
],
),
),
],
),
),
),
);
final Element firstChild = tester.element(find.byKey(key4));
final Element secondChild = tester.element(find.byKey(key6));
final FocusNode firstFocusNode = Focus.of(firstChild);
final FocusNode secondFocusNode = Focus.of(secondChild);
final FocusNode scope = Focus.of(firstChild).enclosingScope;
secondFocusNode.requestFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
// Tests that can still move back to original node.
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
});
});
group(ReadingOrderTraversalPolicy, () {
testWidgets('Move reading focus to next node.', (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');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
bool focus1;
bool focus2;
bool focus3;
bool focus5;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
debugLabel: 'key1',
key: key1,
onFocusChange: (bool focus) => focus1 = focus,
child: Column(
children: <Widget>[
FocusScope(
debugLabel: 'key2',
key: key2,
onFocusChange: (bool focus) => focus2 = focus,
child: Row(
children: <Widget>[
Focus(
debugLabel: 'key3',
key: key3,
onFocusChange: (bool focus) => focus3 = focus,
child: Container(key: key4),
),
Focus(
debugLabel: 'key5',
key: key5,
onFocusChange: (bool focus) => focus5 = focus,
child: Container(key: key6),
),
],
),
),
],
),
),
),
),
);
void clear() {
focus1 = null;
focus2 = null;
focus3 = null;
focus5 = null;
}
final Element firstChild = tester.element(find.byKey(key4));
final Element secondChild = tester.element(find.byKey(key6));
final FocusNode firstFocusNode = Focus.of(firstChild);
final FocusNode secondFocusNode = Focus.of(secondChild);
final FocusNode scope = Focus.of(firstChild).enclosingScope;
firstFocusNode.requestFocus();
await tester.pump();
expect(focus1, isTrue);
expect(focus2, isTrue);
expect(focus3, isTrue);
expect(focus5, isNull);
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
clear();
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isFalse);
expect(focus5, isTrue);
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
clear();
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isTrue);
expect(focus5, isFalse);
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
clear();
// Tests that can still move back to original node.
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(focus1, isNull);
expect(focus2, isNull);
expect(focus3, isFalse);
expect(focus5, isTrue);
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
});
testWidgets('Move reading focus to previous node.', (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');
final GlobalKey key6 = GlobalKey(debugLabel: '6');
await tester.pumpWidget(
DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: FocusScope(
key: key1,
child: Column(
children: <Widget>[
FocusScope(
key: key2,
child: Column(
children: <Widget>[
Focus(
key: key3,
child: Container(key: key4),
),
Focus(
key: key5,
child: Container(key: key6),
),
],
),
),
],
),
),
),
);
final Element firstChild = tester.element(find.byKey(key4));
final Element secondChild = tester.element(find.byKey(key6));
final FocusNode firstFocusNode = Focus.of(firstChild);
final FocusNode secondFocusNode = Focus.of(secondChild);
final FocusNode scope = Focus.of(firstChild).enclosingScope;
secondFocusNode.requestFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
Focus.of(firstChild).previousFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isFalse);
expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
// Tests that can still move back to original node.
Focus.of(firstChild).nextFocus();
await tester.pump();
expect(firstFocusNode.hasFocus, isTrue);
expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
});
});
group(DirectionalFocusTraversalPolicyMixin, () {
testWidgets('Move focus in all directions.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
final GlobalKey lowerLeftKey = GlobalKey(debugLabel: 'lowerLeftKey');
final GlobalKey lowerRightKey = GlobalKey(debugLabel: 'lowerRightKey');
bool focusUpperLeft;
bool focusUpperRight;
bool focusLowerLeft;
bool focusLowerRight;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: FocusScope(
debugLabel: 'Scope',
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Focus(
debugLabel: 'upperLeft',
onFocusChange: (bool focus) => focusUpperLeft = focus,
child: Container(width: 100, height: 100, key: upperLeftKey),
),
Focus(
debugLabel: 'upperRight',
onFocusChange: (bool focus) => focusUpperRight = focus,
child: Container(width: 100, height: 100, key: upperRightKey),
),
],
),
Row(
children: <Widget>[
Focus(
debugLabel: 'lowerLeft',
onFocusChange: (bool focus) => focusLowerLeft = focus,
child: Container(width: 100, height: 100, key: lowerLeftKey),
),
Focus(
debugLabel: 'lowerRight',
onFocusChange: (bool focus) => focusLowerRight = focus,
child: Container(width: 100, height: 100, key: lowerRightKey),
),
],
),
],
),
),
),
),
);
void clear() {
focusUpperLeft = null;
focusUpperRight = null;
focusLowerLeft = null;
focusLowerRight = null;
}
final FocusNode upperLeftNode = Focus.of(tester.element(find.byKey(upperLeftKey)));
final FocusNode upperRightNode = Focus.of(tester.element(find.byKey(upperRightKey)));
final FocusNode lowerLeftNode = Focus.of(tester.element(find.byKey(lowerLeftKey)));
final FocusNode lowerRightNode = Focus.of(tester.element(find.byKey(lowerRightKey)));
final FocusNode scope = upperLeftNode.enclosingScope;
upperLeftNode.requestFocus();
await tester.pump();
expect(focusUpperLeft, isTrue);
expect(focusUpperRight, isNull);
expect(focusLowerLeft, isNull);
expect(focusLowerRight, isNull);
expect(upperLeftNode.hasFocus, isTrue);
expect(upperRightNode.hasFocus, isFalse);
expect(lowerLeftNode.hasFocus, isFalse);
expect(lowerRightNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
clear();
expect(scope.focusInDirection(TraversalDirection.right), isTrue);
await tester.pump();
expect(focusUpperLeft, isFalse);
expect(focusUpperRight, isTrue);
expect(focusLowerLeft, isNull);
expect(focusLowerRight, isNull);
expect(upperLeftNode.hasFocus, isFalse);
expect(upperRightNode.hasFocus, isTrue);
expect(lowerLeftNode.hasFocus, isFalse);
expect(lowerRightNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
clear();
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
await tester.pump();
expect(focusUpperLeft, isNull);
expect(focusUpperRight, isFalse);
expect(focusLowerLeft, isNull);
expect(focusLowerRight, isTrue);
expect(upperLeftNode.hasFocus, isFalse);
expect(upperRightNode.hasFocus, isFalse);
expect(lowerLeftNode.hasFocus, isFalse);
expect(lowerRightNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue);
clear();
expect(scope.focusInDirection(TraversalDirection.left), isTrue);
await tester.pump();
expect(focusUpperLeft, isNull);
expect(focusUpperRight, isNull);
expect(focusLowerLeft, isTrue);
expect(focusLowerRight, isFalse);
expect(upperLeftNode.hasFocus, isFalse);
expect(upperRightNode.hasFocus, isFalse);
expect(lowerLeftNode.hasFocus, isTrue);
expect(lowerRightNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
clear();
expect(scope.focusInDirection(TraversalDirection.up), isTrue);
await tester.pump();
expect(focusUpperLeft, isTrue);
expect(focusUpperRight, isNull);
expect(focusLowerLeft, isFalse);
expect(focusLowerRight, isNull);
expect(upperLeftNode.hasFocus, isTrue);
expect(upperRightNode.hasFocus, isFalse);
expect(lowerLeftNode.hasFocus, isFalse);
expect(lowerRightNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue);
});
testWidgets('Directional focus avoids hysterisis.', (WidgetTester tester) async {
final List<GlobalKey> keys = <GlobalKey>[
GlobalKey(debugLabel: 'row 1:1'),
GlobalKey(debugLabel: 'row 2:1'),
GlobalKey(debugLabel: 'row 2:2'),
GlobalKey(debugLabel: 'row 3:1'),
GlobalKey(debugLabel: 'row 3:2'),
GlobalKey(debugLabel: 'row 3:3'),
];
List<bool> focus = List<bool>.generate(keys.length, (int _) => null);
Focus makeFocus(int index) {
return Focus(
debugLabel: keys[index].toString(),
onFocusChange: (bool isFocused) => focus[index] = isFocused,
child: Container(width: 100, height: 100, key: keys[index]),
);
}
/// Layout is:
/// keys[0]
/// keys[1] keys[2]
/// keys[3] keys[4] keys[5]
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: FocusScope(
debugLabel: 'Scope',
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
makeFocus(0),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
makeFocus(1),
makeFocus(2),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
makeFocus(3),
makeFocus(4),
makeFocus(5),
],
),
],
),
),
),
),
);
void clear() {
focus = List<bool>.generate(keys.length, (int _) => null);
}
final List<FocusNode> nodes = keys.map<FocusNode>((GlobalKey key) => Focus.of(tester.element(find.byKey(key)))).toList();
final FocusNode scope = nodes[0].enclosingScope;
nodes[4].requestFocus();
void expectState(List<bool> states) {
for (int index = 0; index < states.length; ++index) {
expect(focus[index], states[index] == null ? isNull : (states[index] ? isTrue : isFalse));
if (states[index] == null) {
expect(nodes[index].hasFocus, isFalse);
} else {
expect(nodes[index].hasFocus, states[index]);
}
expect(scope.hasFocus, isTrue);
}
}
// Test to make sure that we follow the same path backwards and forwards.
await tester.pump();
expectState(<bool>[null, null, null, null, true, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.up), isTrue);
await tester.pump();
expectState(<bool>[null, null, true, null, false, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.up), isTrue);
await tester.pump();
expectState(<bool>[true, null, false, null, null, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
await tester.pump();
expectState(<bool>[false, null, true, null, null, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
await tester.pump();
expectState(<bool>[null, null, false, null, true, null]);
clear();
// Make sure that moving in a different axis clears the history.
expect(scope.focusInDirection(TraversalDirection.left), isTrue);
await tester.pump();
expectState(<bool>[null, null, null, true, false, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.up), isTrue);
await tester.pump();
expectState(<bool>[null, true, null, false, null, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.up), isTrue);
await tester.pump();
expectState(<bool>[true, false, null, null, null, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
await tester.pump();
expectState(<bool>[false, true, null, null, null, null]);
clear();
expect(scope.focusInDirection(TraversalDirection.down), isTrue);
await tester.pump();
expectState(<bool>[null, false, null, true, null, null]);
clear();
});
testWidgets('Can find first focus in all directions.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
final GlobalKey lowerLeftKey = GlobalKey(debugLabel: 'lowerLeftKey');
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: FocusScope(
debugLabel: 'scope',
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Focus(
debugLabel: 'upperLeft',
child: Container(width: 100, height: 100, key: upperLeftKey),
),
Focus(
debugLabel: 'upperRight',
child: Container(width: 100, height: 100, key: upperRightKey),
),
],
),
Row(
children: <Widget>[
Focus(
debugLabel: 'lowerLeft',
child: Container(width: 100, height: 100, key: lowerLeftKey),
),
Focus(
debugLabel: 'lowerRight',
child: Container(width: 100, height: 100),
),
],
),
],
),
),
),
),
);
final FocusNode upperLeftNode = Focus.of(tester.element(find.byKey(upperLeftKey)));
final FocusNode upperRightNode = Focus.of(tester.element(find.byKey(upperRightKey)));
final FocusNode lowerLeftNode = Focus.of(tester.element(find.byKey(lowerLeftKey)));
final FocusNode scope = upperLeftNode.enclosingScope;
await tester.pump();
final FocusTraversalPolicy policy = DefaultFocusTraversal.of(upperLeftKey.currentContext);
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.up), equals(lowerLeftNode));
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.down), equals(upperLeftNode));
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.left), equals(upperRightNode));
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.right), equals(upperLeftNode));
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment