diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 43a5d2bca2..e984639e77 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -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 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, + ), ), ); } diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index b46fb7f2cb..0d9078af51 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -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) { diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart index d0fc3c1701..6e4d53b6a7 100644 --- a/packages/flutter/lib/src/widgets/focus_scope.dart +++ b/packages/flutter/lib/src/widgets/focus_scope.dart @@ -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 { /// 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 { /// 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]. /// diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart new file mode 100644 index 0000000000..537f790acd --- /dev/null +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -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 _policyData = {}; + + @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 nodes = currentNode.nearestScope.descendants; + final List 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 _sortAndFilterHorizontally( + TraversalDirection direction, + Rect target, + FocusNode nearestScope, + ) { + assert(direction == TraversalDirection.left || direction == TraversalDirection.right); + final Iterable nodes = nearestScope.descendants; + assert(!nodes.contains(nearestScope)); + final List sorted = nodes.toList(); + sorted.sort((FocusNode a, FocusNode b) => a.rect.center.dx.compareTo(b.rect.center.dx)); + Iterable 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 _sortAndFilterVertically( + TraversalDirection direction, + Rect target, + Iterable nodes, + ) { + final List 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 eligibleNodes = _sortAndFilterVertically( + direction, + focusedChild.rect, + nearestScope.descendants, + ); + if (eligibleNodes.isEmpty) { + break; + } + List 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 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 eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope); + if (eligibleNodes.isEmpty) { + break; + } + List 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 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 _sortByGeometry(FocusNode scope) { + final Iterable 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 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 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; +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index bcd5ed5bc2..04a30fb808 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 3ab750ab59..9136afc7b8 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.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( diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 5e573717ea..8f45c3342c 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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. diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart index 0edf360152..e9bf411262 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -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: [ - FocusScope( - key: scopeKeyA, - node: parentFocusScope, - child: Column( - children: [ - TestFocus( - debugLabel: 'Child A', - key: keyA, - name: 'a', - ), - ], + DefaultFocusTraversal( + child: Column( + children: [ + FocusScope( + key: scopeKeyA, + node: parentFocusScope, + child: Column( + children: [ + TestFocus( + debugLabel: 'Child A', + key: keyA, + name: 'a', + ), + ], + ), ), - ), - FocusScope( - key: scopeKeyB, - child: Column( - children: [ - TestFocus( - debugLabel: 'Child B', - key: keyB, - name: 'b', - ), - ], + FocusScope( + key: scopeKeyB, + child: Column( + children: [ + 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 keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey scopeKeyA = GlobalKey(); + final GlobalKey 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: [ + FocusScope( + key: scopeKeyA, + node: parentFocusScope1, + child: Column( + children: [ + TestFocus( + debugLabel: 'Child A', + key: keyA, + name: 'a', + ), + ], + ), + ), + FocusScope( + key: scopeKeyB, + node: parentFocusScope2, + child: Column( + children: [ + 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: [ + FocusScope( + key: scopeKeyB, + node: parentFocusScope2, + child: Column( + children: [ + 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 keyA = GlobalKey(); final GlobalKey keyB = GlobalKey(); @@ -655,33 +743,35 @@ void main() { final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2'); await tester.pumpWidget( - Column( - children: [ - FocusScope( - node: parentFocusScope1, - child: Column( - children: [ - TestFocus( - debugLabel: 'Child A', - key: keyA, - name: 'a', - ), - ], + DefaultFocusTraversal( + child: Column( + children: [ + FocusScope( + node: parentFocusScope1, + child: Column( + children: [ + TestFocus( + debugLabel: 'Child A', + key: keyA, + name: 'a', + ), + ], + ), ), - ), - FocusScope( - node: parentFocusScope2, - child: Column( - children: [ - TestFocus( - debugLabel: 'Child B', - key: keyB, - name: 'b', - ), - ], + FocusScope( + node: parentFocusScope2, + child: Column( + children: [ + 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: [ - FocusScope( - node: parentFocusScope2, - child: Column( - children: [ - TestFocus( - key: keyB, - name: 'b', - autofocus: true, - ), - ], + DefaultFocusTraversal( + child: Column( + children: [ + FocusScope( + node: parentFocusScope2, + child: Column( + children: [ + TestFocus( + key: keyB, + name: 'b', + autofocus: true, + ), + ], + ), ), - ), - ], + ], + ), ), ); await tester.pump(); diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart new file mode 100644 index 0000000000..18384563c3 --- /dev/null +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -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: [ + FocusScope( + debugLabel: 'key2', + key: key2, + onFocusChange: (bool focus) => focus2 = focus, + child: Column( + children: [ + 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: [ + FocusScope( + key: key2, + child: Column( + children: [ + 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: [ + FocusScope( + debugLabel: 'key2', + key: key2, + onFocusChange: (bool focus) => focus2 = focus, + child: Row( + children: [ + 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: [ + FocusScope( + key: key2, + child: Column( + children: [ + 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: [ + Row( + children: [ + 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: [ + 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 keys = [ + 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 focus = List.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: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + makeFocus(0), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + makeFocus(1), + makeFocus(2), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + makeFocus(3), + makeFocus(4), + makeFocus(5), + ], + ), + ], + ), + ), + ), + ), + ); + + void clear() { + focus = List.generate(keys.length, (int _) => null); + } + + final List nodes = keys.map((GlobalKey key) => Focus.of(tester.element(find.byKey(key)))).toList(); + final FocusNode scope = nodes[0].enclosingScope; + nodes[4].requestFocus(); + + void expectState(List 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([null, null, null, null, true, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + + expectState([null, null, true, null, false, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + + expectState([true, null, false, null, null, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + + expectState([false, null, true, null, null, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expectState([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([null, null, null, true, false, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + + expectState([null, true, null, false, null, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + + expectState([true, false, null, null, null, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + + expectState([false, true, null, null, null, null]); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expectState([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: [ + Row( + children: [ + Focus( + debugLabel: 'upperLeft', + child: Container(width: 100, height: 100, key: upperLeftKey), + ), + Focus( + debugLabel: 'upperRight', + child: Container(width: 100, height: 100, key: upperRightKey), + ), + ], + ), + Row( + children: [ + 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)); + }); + }); +}