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.
This commit is contained in:
@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
|
||||
import 'banner.dart';
|
||||
import 'basic.dart';
|
||||
import 'binding.dart';
|
||||
import 'focus_traversal.dart';
|
||||
import 'framework.dart';
|
||||
import 'localizations.dart';
|
||||
import 'media_query.dart';
|
||||
@@ -1190,12 +1191,15 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
|
||||
|
||||
assert(_debugCheckLocalizations(appLocale));
|
||||
|
||||
return MediaQuery(
|
||||
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
|
||||
child: Localizations(
|
||||
locale: appLocale,
|
||||
delegates: _localizationsDelegates.toList(),
|
||||
child: title,
|
||||
return DefaultFocusTraversal(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
|
||||
child: Localizations(
|
||||
locale: appLocale,
|
||||
delegates: _localizationsDelegates.toList(),
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import 'binding.dart';
|
||||
import 'focus_scope.dart';
|
||||
import 'focus_traversal.dart';
|
||||
import 'framework.dart';
|
||||
|
||||
/// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey]
|
||||
@@ -191,6 +192,30 @@ class FocusAttachment {
|
||||
/// [FocusManager.rootScope], the event is discarded.
|
||||
/// {@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}
|
||||
/// This example shows how a FocusNode should be managed if not using the
|
||||
/// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar
|
||||
@@ -304,6 +329,10 @@ class FocusAttachment {
|
||||
/// widget tree.
|
||||
/// * [FocusManager], a singleton that manages the focus and distributes key
|
||||
/// 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 {
|
||||
/// Creates a focus node.
|
||||
///
|
||||
@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
|
||||
}
|
||||
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.');
|
||||
final FocusScopeNode oldScope = child.enclosingScope;
|
||||
final bool hadFocus = child.hasFocus;
|
||||
child._parent?._removeChild(child);
|
||||
_children.add(child);
|
||||
@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
|
||||
// Update the focus chain for the current focus without changing it.
|
||||
_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
|
||||
@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
|
||||
}
|
||||
assert(node.ancestors.contains(this),
|
||||
'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;
|
||||
}
|
||||
_doRequestFocus(isFromPolicy: false);
|
||||
_doRequestFocus();
|
||||
}
|
||||
|
||||
// Note that this is overridden in FocusScopeNode.
|
||||
void _doRequestFocus({@required bool isFromPolicy}) {
|
||||
assert(isFromPolicy != null);
|
||||
void _doRequestFocus() {
|
||||
_setAsFocusedChild();
|
||||
if (hasPrimaryFocus) {
|
||||
return;
|
||||
@@ -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
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
@@ -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.');
|
||||
if (hasFocus) {
|
||||
scope._doRequestFocus(isFromPolicy: false);
|
||||
scope._doRequestFocus();
|
||||
} else {
|
||||
scope._setAsFocusedChild();
|
||||
}
|
||||
@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode {
|
||||
}
|
||||
assert(node.ancestors.contains(this),
|
||||
'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
|
||||
void _doRequestFocus({@required bool isFromPolicy}) {
|
||||
assert(isFromPolicy != null);
|
||||
void _doRequestFocus() {
|
||||
// Start with the primary focus as the focused child of this scope, if there
|
||||
// is one. Otherwise start with this node itself.
|
||||
FocusNode primaryFocus = focusedChild ?? this;
|
||||
@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode {
|
||||
_setAsFocusedChild();
|
||||
_markAsDirty(newFocus: primaryFocus);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin {
|
||||
_haveScheduledUpdate = false;
|
||||
final FocusNode previousFocus = _currentFocus;
|
||||
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;
|
||||
}
|
||||
if (_nextFocus != null && _nextFocus != _currentFocus) {
|
||||
|
||||
@@ -132,6 +132,10 @@ import 'inherited_notifier.dart';
|
||||
/// traversal.
|
||||
/// * [FocusManager], a singleton that manages the primary focus and
|
||||
/// 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 {
|
||||
/// Creates a widget that manages a [FocusNode].
|
||||
///
|
||||
@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> {
|
||||
/// more information about the details of what node management entails if not
|
||||
/// 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:
|
||||
///
|
||||
/// * [FocusScopeNode], which represents a scope node in the focus hierarchy.
|
||||
@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> {
|
||||
/// managing focus without having to manage the node.
|
||||
/// * [FocusManager], a singleton that manages the focus and 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 FocusScope extends Focus {
|
||||
/// Creates a widget that manages a [FocusScopeNode].
|
||||
///
|
||||
|
||||
795
packages/flutter/lib/src/widgets/focus_traversal.dart
Normal file
795
packages/flutter/lib/src/widgets/focus_traversal.dart
Normal file
@@ -0,0 +1,795 @@
|
||||
// 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';
|
||||
export 'src/widgets/fade_in_image.dart';
|
||||
export 'src/widgets/focus_manager.dart';
|
||||
export 'src/widgets/focus_scope.dart';
|
||||
export 'src/widgets/focus_traversal.dart';
|
||||
export 'src/widgets/form.dart';
|
||||
export 'src/widgets/framework.dart';
|
||||
export 'src/widgets/gesture_detector.dart';
|
||||
|
||||
@@ -4057,7 +4057,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('TextField loses focus when disabled', (WidgetTester tester) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final FocusNode focusNode = FocusNode(debugLabel: 'TextField Focus Node');
|
||||
|
||||
await tester.pumpWidget(
|
||||
boilerplate(
|
||||
|
||||
@@ -744,6 +744,7 @@ void main() {
|
||||
TextEditingController currentController = controller1;
|
||||
StateSetter setState;
|
||||
|
||||
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Focus Node');
|
||||
Widget builder() {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setter) {
|
||||
@@ -757,7 +758,7 @@ void main() {
|
||||
child: EditableText(
|
||||
backgroundCursorColor: Colors.grey,
|
||||
controller: currentController,
|
||||
focusNode: FocusNode(),
|
||||
focusNode: focusNode,
|
||||
style: Typography(platform: TargetPlatform.android)
|
||||
.black
|
||||
.subhead,
|
||||
@@ -775,6 +776,8 @@ void main() {
|
||||
}
|
||||
|
||||
await tester.pumpWidget(builder());
|
||||
await tester.pump(); // An extra pump to allow focus request to go through.
|
||||
|
||||
await tester.showKeyboard(find.byType(EditableText));
|
||||
|
||||
// Verify TextInput.setEditingState is fired with updated text when controller is replaced.
|
||||
|
||||
@@ -596,34 +596,36 @@ void main() {
|
||||
// This checks both FocusScopes that have their own nodes, as well as those
|
||||
// that use external nodes.
|
||||
await tester.pumpWidget(
|
||||
Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
key: scopeKeyA,
|
||||
node: parentFocusScope,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child A',
|
||||
key: keyA,
|
||||
name: 'a',
|
||||
),
|
||||
],
|
||||
DefaultFocusTraversal(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
key: scopeKeyA,
|
||||
node: parentFocusScope,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child A',
|
||||
key: keyA,
|
||||
name: 'a',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
FocusScope(
|
||||
key: scopeKeyB,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child B',
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
),
|
||||
],
|
||||
FocusScope(
|
||||
key: scopeKeyB,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child B',
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -647,7 +649,93 @@ void main() {
|
||||
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 {
|
||||
final GlobalKey<TestFocusState> keyA = GlobalKey();
|
||||
final GlobalKey<TestFocusState> keyB = GlobalKey();
|
||||
@@ -655,33 +743,35 @@ void main() {
|
||||
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
|
||||
|
||||
await tester.pumpWidget(
|
||||
Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
node: parentFocusScope1,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child A',
|
||||
key: keyA,
|
||||
name: 'a',
|
||||
),
|
||||
],
|
||||
DefaultFocusTraversal(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
node: parentFocusScope1,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child A',
|
||||
key: keyA,
|
||||
name: 'a',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
FocusScope(
|
||||
node: parentFocusScope2,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child B',
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
),
|
||||
],
|
||||
FocusScope(
|
||||
node: parentFocusScope2,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
debugLabel: 'Child B',
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -700,26 +790,24 @@ void main() {
|
||||
expect(keyB.currentState.focusNode.hasFocus, isFalse);
|
||||
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(
|
||||
Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
node: parentFocusScope2,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
DefaultFocusTraversal(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
FocusScope(
|
||||
node: parentFocusScope2,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TestFocus(
|
||||
key: keyB,
|
||||
name: 'b',
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
727
packages/flutter/test/widgets/focus_traversal_test.dart
Normal file
727
packages/flutter/test/widgets/focus_traversal_test.dart
Normal file
@@ -0,0 +1,727 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user