Change Focus.unfocus to take a disposition for where the focus… (#50831)
When Focus.unfocus is called, the caller usually just thinks about wanting to remove focus from the node, but really, unfocus is a request to automatically pass the focus to another (hopefully useful) node.
This PR removes the focusPrevious flag from unfocus, and replaces it with a disposition enum that indicates where the focus should go from here.
The other value of the UnfocusDisposition enum is UnfocusDisposition.scope.
UnfocusDisposition.previouslyFocusedChild is closest to what focusPrevious used to do: focus the nearest enclosing scope and use its focusedChild field to walk down the tree, finding the leaf focusedChild. This PR modifies it slightly so that it walks up to the nearest focusable enclosing scope before trying to focus the children. This change addresses #48903
A new mode: UnfocusDisposition.scope will focus the nearest focusable enclosing scope of this node without trying to use the FocusScopeNode.focusedChild value to descend to the leaf focused child. This is useful as a default for both text field finalization and for what happens when canRequestFocus is set to false. It allows the scope to stay focused so that nextFocus/previousFocus still work as expected, but removes the focus from primary focus.
In addition to those changes, unfocus called on a FocuScope that wasn't the primary focus used to unfocus the primary focus instead. I removed that behavior, since it was buggy: if the primary focus was inside of a child scope, and you called unfocus on the parent scope, then the child scope could have focused another of its children instead, leaving the scope that you called unfocus on with hasFocus returning true still. If you want to remove the focus from the primary focus instead of the scope, that's easy enough to do: just call primaryFocus.unfocus().
Fixes #48903
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index f133dbc..fc17b24 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -85,7 +85,7 @@
assert(_focusDebug('Detaching node:', <String>[_node.toString(), 'With enclosing scope ${_node.enclosingScope}']));
if (isAttached) {
if (_node.hasPrimaryFocus || (_node._manager != null && _node._manager._markedForFocus == _node)) {
- _node.unfocus(focusPrevious: true);
+ _node.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
}
// This node is no longer in the tree, so shouldn't send notifications anymore.
_node._manager?._markDetached(_node);
@@ -93,7 +93,6 @@
_node._attachment = null;
assert(!_node.hasPrimaryFocus);
assert(_node._manager?._markedForFocus != _node);
- assert(_node._manager?._markedForUnfocus != _node);
}
assert(!isAttached);
}
@@ -132,6 +131,43 @@
}
}
+/// Describe what should happen after [FocusNode.unfocus] is called.
+///
+/// See also:
+///
+/// * [FocusNode.unfocus], which takes this as its `disposition` parameter.
+enum UnfocusDisposition {
+ /// Focus the nearest focusable enclosing scope of this node, but do not
+ /// descend to locate the leaf [FocusScopeNode.focusedChild] the way
+ /// [previouslyFocusedChild] does.
+ ///
+ /// Focusing the scope in this way clears the [FocusScopeNode.focusedChild]
+ /// history for the enclosing scope when it receives focus. Because of this,
+ /// calling a traversal method like [FocusNode.nextFocus] after unfocusing
+ /// will cause the [FocusTraversalPolicy] to pick the node it thinks should be
+ /// first in the scope.
+ ///
+ /// This is the default disposition for [FocusNode.unfocus].
+ scope,
+
+ /// Focus the previously focused child of the nearest focusable enclosing
+ /// scope of this node.
+ ///
+ /// If there is no previously focused child, then this is equivalent to
+ /// using the [scope] disposition.
+ ///
+ /// Unfocusing with this disposition will cause [FocusNode.unfocus] to walk up
+ /// the tree to the nearest focusable enclosing scope, then start to walk down
+ /// the tree, looking for a focused child at its
+ /// [FocusScopeNode.focusedChild].
+ ///
+ /// If the [FocusScopeNode.focusedChild] is a scope, then look for its
+ /// [FocusScopeNode.focusedChild], and so on, finding the leaf
+ /// [FocusScopeNode.focusedChild] that is not a scope, or, failing that, a
+ /// leaf scope that has no focused child.
+ previouslyFocusedChild,
+}
+
/// An object that can be used by a stateful widget to obtain the keyboard focus
/// and to handle keyboard events.
///
@@ -437,12 +473,13 @@
final FocusScopeNode scope = enclosingScope;
return _canRequestFocus && (scope == null || scope.canRequestFocus);
}
+
bool _canRequestFocus;
@mustCallSuper
set canRequestFocus(bool value) {
if (value != _canRequestFocus) {
if (!value) {
- unfocus(focusPrevious: true);
+ unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
}
_canRequestFocus = value;
_manager?._markPropertiesChanged(this);
@@ -562,15 +599,7 @@
///
/// * [Focus.isAt], which is a static method that will return the focus
/// state of the nearest ancestor [Focus] widget's focus node.
- bool get hasFocus {
- if (_manager?.primaryFocus == null || _manager?._markedForUnfocus == this) {
- return false;
- }
- if (hasPrimaryFocus) {
- return true;
- }
- return _manager.primaryFocus.ancestors.contains(this);
- }
+ bool get hasFocus => hasPrimaryFocus || (_manager?.primaryFocus?.ancestors?.contains(this) ?? false);
/// Returns true if this node currently has the application-wide input focus.
///
@@ -646,43 +675,157 @@
return globalOffset & object.semanticBounds.size;
}
- /// Removes focus from a node that has the primary focus, and cancels any
- /// outstanding requests to focus it.
+ /// Removes the focus on this node by moving the primary focus to another node.
///
- /// Calling [requestFocus] sends a request to the [FocusManager] to make that
- /// node the primary focus, which schedules a microtask to resolve the latest
- /// request into an update of the focus state on the tree. Calling [unfocus]
- /// cancels a request that has been requested, but not yet acted upon.
+ /// This method removes focus from a node that has the primary focus, cancels
+ /// any outstanding requests to focus it, while setting the primary focus to
+ /// another node according to the `disposition`.
///
- /// This method is safe to call regardless of whether this node has ever
- /// requested focus.
+ /// It is safe to call regardless of whether this node has ever requested
+ /// focus or not. If this node doesn't have focus or primary focus, nothing
+ /// happens.
///
- /// For nodes that return true from [hasFocus], but false from
- /// [hasPrimaryFocus], this will unfocus the descendant node that has the
- /// primary focus instead ([FocusManager.primaryFocus]).
+ /// The `disposition` argument determines which node will receive primary
+ /// focus after this one loses it.
///
- /// If [focusPrevious] is true, then rather than losing all focus, the focus
- /// will be moved to the node that the [enclosingScope] thinks should have it,
- /// based on its history of nodes that were set as first focus on it using
- /// [FocusScopeNode.setFirstFocus].
- void unfocus({ bool focusPrevious = false }) {
- assert(focusPrevious != null);
+ /// If `disposition` is set to [UnfocusDisposition.scope] (the default), then
+ /// the previously focused node history of the enclosing scope will be
+ /// cleared, and the primary focus will be moved to the nearest enclosing
+ /// scope ancestor that is enabled for focus, ignoring the
+ /// [FocusScopeNode.focusedChild] for that scope.
+ ///
+ /// If `disposition` is set to [UnfocusDisposition.previouslyFocusedChild],
+ /// then this node will be removed from the previously focused list in the
+ /// [enclosingScope], and the focus will be moved to the previously focused
+ /// node of the [enclosingScope], which (if it is a scope itself), will find
+ /// its focused child, etc., until a leaf focus node is found. If there is no
+ /// previously focused child, then the scope itself will receive focus, as if
+ /// [UnfocusDisposition.scope] were specified.
+ ///
+ /// If you want this node to lose focus and the focus to move to the next or
+ /// previous node in the enclosing [FocusTraversalGroup], call [nextFocus] or
+ /// [previousFocus] instead of calling `unfocus`.
+ ///
+ /// {@tool dartpad --template=stateful_widget_material}
+ /// This example shows the difference between the different [UnfocusDisposition]
+ /// values for [unfocus].
+ ///
+ /// Try setting focus on the four text fields by selecting them, and then
+ /// select "UNFOCUS" to see what happens when the current
+ /// [FocusManager.primaryFocus] is unfocused.
+ ///
+ /// Try pressing the TAB key after unfocusing to see what the next widget
+ /// chosen is.
+ ///
+ /// ```dart imports
+ /// import 'package:flutter/foundation.dart';
+ /// ```
+ ///
+ /// ```dart
+ /// UnfocusDisposition disposition = UnfocusDisposition.scope;
+ ///
+ /// @override
+ /// Widget build(BuildContext context) {
+ /// return Material(
+ /// child: Container(
+ /// color: Colors.white,
+ /// child: Column(
+ /// mainAxisAlignment: MainAxisAlignment.center,
+ /// children: <Widget>[
+ /// Wrap(
+ /// children: List<Widget>.generate(4, (int index) {
+ /// return SizedBox(
+ /// width: 200,
+ /// child: Padding(
+ /// padding: const EdgeInsets.all(8.0),
+ /// child: TextField(
+ /// decoration: InputDecoration(border: OutlineInputBorder()),
+ /// ),
+ /// ),
+ /// );
+ /// }),
+ /// ),
+ /// Row(
+ /// mainAxisAlignment: MainAxisAlignment.spaceAround,
+ /// children: <Widget>[
+ /// ...List<Widget>.generate(UnfocusDisposition.values.length,
+ /// (int index) {
+ /// return Row(
+ /// mainAxisSize: MainAxisSize.min,
+ /// children: <Widget>[
+ /// Radio<UnfocusDisposition>(
+ /// groupValue: disposition,
+ /// onChanged: (UnfocusDisposition value) {
+ /// setState(() {
+ /// disposition = value;
+ /// });
+ /// },
+ /// value: UnfocusDisposition.values[index],
+ /// ),
+ /// Text(describeEnum(UnfocusDisposition.values[index])),
+ /// ],
+ /// );
+ /// }),
+ /// OutlineButton(
+ /// child: const Text('UNFOCUS'),
+ /// onPressed: () {
+ /// setState(() {
+ /// primaryFocus.unfocus(disposition: disposition);
+ /// });
+ /// },
+ /// ),
+ /// ],
+ /// ),
+ /// ],
+ /// ),
+ /// ),
+ /// );
+ /// }
+ /// ```
+ /// {@end-tool}
+ void unfocus({
+ UnfocusDisposition disposition = UnfocusDisposition.scope,
+ }) {
+ assert(disposition != null);
if (!hasFocus && (_manager == null || _manager._markedForFocus != this)) {
return;
}
- if (!hasPrimaryFocus) {
- // If we are in the focus chain, but not the primary focus, then unfocus
- // the primary instead.
- _manager?.primaryFocus?.unfocus(focusPrevious: focusPrevious);
+ FocusScopeNode scope = enclosingScope;
+ if (scope == null) {
+ // If the scope is null, then this is either the root node, or a node that
+ // is not yet in the tree, neither of which do anything when unfocused.
+ return;
}
- _manager?._markUnfocused(this);
- final FocusScopeNode scope = enclosingScope;
- if (scope != null) {
- scope._focusedChildren.remove(this);
- if (focusPrevious) {
- scope._doRequestFocus();
- }
+ switch (disposition) {
+ case UnfocusDisposition.scope:
+ // If it can't request focus, then don't modify its focused children.
+ if (scope.canRequestFocus) {
+ // Clearing the focused children here prevents re-focusing the node
+ // that we just unfocused if we immediately hit "next" after
+ // unfocusing, and also prevents choosing to refocus the next-to-last
+ // focused child if unfocus is called more than once.
+ scope._focusedChildren.clear();
+ }
+
+ while (!scope.canRequestFocus) {
+ scope = scope.enclosingScope ?? _manager?.rootScope;
+ }
+ scope?._doRequestFocus(findFirstFocus: false);
+ break;
+ case UnfocusDisposition.previouslyFocusedChild:
+ // Select the most recent focused child from the nearest focusable scope
+ // and focus that. If there isn't one, focus the scope itself.
+ if (scope.canRequestFocus) {
+ scope?._focusedChildren?.remove(this);
+ }
+ while (!scope.canRequestFocus) {
+ scope.enclosingScope?._focusedChildren?.remove(scope);
+ scope = scope.enclosingScope ?? _manager?.rootScope;
+ }
+ scope?._doRequestFocus(findFirstFocus: true);
+ break;
}
+ assert(_focusDebug('Unfocused node:', <String>['primary focus was $this', 'next focus will be ${_manager?._markedForFocus}']));
}
/// Removes the keyboard token from this focus node if it has one.
@@ -785,7 +928,7 @@
FocusTraversalGroup.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
}
if (child._requestFocusWhenReparented) {
- child._doRequestFocus();
+ child._doRequestFocus(findFirstFocus: true);
child._requestFocusWhenReparented = false;
}
}
@@ -852,14 +995,15 @@
_reparent(node);
}
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();
+ node._doRequestFocus(findFirstFocus: true);
return;
}
- _doRequestFocus();
+ _doRequestFocus(findFirstFocus: true);
}
// Note that this is overridden in FocusScopeNode.
- void _doRequestFocus() {
+ void _doRequestFocus({@required bool findFirstFocus}) {
+ assert(findFirstFocus != null);
if (!canRequestFocus) {
assert(_focusDebug('Node NOT requesting focus because canRequestFocus is false: $this'));
return;
@@ -1043,7 +1187,7 @@
}
assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.');
if (hasFocus) {
- scope._doRequestFocus();
+ scope._doRequestFocus(findFirstFocus: true);
} else {
scope._setAsFocusedChildForScope();
}
@@ -1066,12 +1210,29 @@
_reparent(node);
}
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();
+ node._doRequestFocus(findFirstFocus: true);
}
}
@override
- void _doRequestFocus() {
+ void _doRequestFocus({@required bool findFirstFocus}) {
+ assert(findFirstFocus != null);
+
+ // It is possible that a previously focused child is no longer focusable.
+ while (focusedChild != null && !focusedChild.canRequestFocus)
+ _focusedChildren.removeLast();
+
+ // If findFirstFocus is false, then the request is to make this scope the
+ // focus instead of looking for the ultimate first focus for this scope and
+ // its descendants.
+ if (!findFirstFocus) {
+ if (canRequestFocus) {
+ _setAsFocusedChildForScope();
+ _markNextFocus(this);
+ }
+ return;
+ }
+
// 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;
@@ -1093,7 +1254,7 @@
// We found a FocusScopeNode 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._doRequestFocus();
+ primaryFocus._doRequestFocus(findFirstFocus: findFirstFocus);
}
}
@@ -1390,17 +1551,10 @@
// given it yet.
FocusNode _markedForFocus;
- // The node that has been marked as needing to be unfocused during the next
- // focus update.
- FocusNode _markedForUnfocus;
-
void _markDetached(FocusNode node) {
// The node has been removed from the tree, so it no longer needs to be
// notified of changes.
assert(_focusDebug('Node was detached: $node'));
- if (_markedForUnfocus == node) {
- _markedForUnfocus = null;
- }
if (_primaryFocus == node) {
_primaryFocus = null;
}
@@ -1418,36 +1572,12 @@
// The caller asked for the current focus to be the next focus, so just
// pretend that didn't happen.
_markedForFocus = null;
- // If this node is going to be the next focus, then it's not going to be
- // unfocused unless we call _markUnfocused again, so unset _unfocusedNode.
- if (_markedForUnfocus == node) {
- _markedForUnfocus = null;
- }
} else {
_markedForFocus = node;
_markNeedsUpdate();
}
}
- // Called to indicate that the given node should be marked to be unfocused at
- // the next focus update, and that any pending request to focus it should be
- // canceled.
- void _markUnfocused(FocusNode node) {
- assert(node != null);
- assert(_focusDebug('Unfocusing node $node'));
- if (_primaryFocus == node || _markedForFocus == node) {
- if (_markedForFocus == node) {
- _markedForFocus = null;
- }
- if (_primaryFocus == node) {
- assert(_markedForUnfocus == null);
- _markedForUnfocus = node;
- }
- _markNeedsUpdate();
- }
- assert(_focusDebug('Unfocused node $node:', <String>['primary focus is $_primaryFocus', 'next focus will be $_markedForFocus']));
- }
-
// True indicates that there is an update pending.
bool _haveScheduledUpdate = false;
@@ -1464,9 +1594,6 @@
void _applyFocusChange() {
_haveScheduledUpdate = false;
- if (_markedForUnfocus == _primaryFocus) {
- _primaryFocus = null;
- }
final FocusNode previousFocus = _primaryFocus;
if (_primaryFocus == null && _markedForFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet,
@@ -1479,11 +1606,6 @@
if (_markedForFocus != null && _markedForFocus != _primaryFocus) {
final Set<FocusNode> previousPath = previousFocus?.ancestors?.toSet() ?? <FocusNode>{};
final Set<FocusNode> nextPath = _markedForFocus.ancestors.toSet();
- if (_markedForUnfocus != null) {
- final Set<FocusNode> unfocusedNodes = <FocusNode>{_markedForUnfocus, ..._markedForUnfocus.ancestors};
- unfocusedNodes.removeAll(nextPath); // No need to dirty the ancestors that are in the newly focused set.
- _dirtyNodes.addAll(unfocusedNodes);
- }
// Notify nodes that are newly focused.
_dirtyNodes.addAll(nextPath.difference(previousPath));
// Notify nodes that are no longer focused
@@ -1506,7 +1628,6 @@
node._notify();
}
_dirtyNodes.clear();
- _markedForUnfocus = null;
if (previousFocus != _primaryFocus) {
notifyListeners();
}
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index f3da8ca..66628a3 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -914,6 +914,8 @@
tester.testTextInput.log.clear();
tester.testTextInput.closeConnection();
+ // A pump is needed to allow the focus change (unfocus) to be resolved.
+ await tester.pump();
// Widget does not have focus anymore.
expect(state.wantKeepAlive, false);
diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart
index 9a6b3b0..88513d0 100644
--- a/packages/flutter/test/widgets/focus_manager_test.dart
+++ b/packages/flutter/test/widgets/focus_manager_test.dart
@@ -316,7 +316,7 @@
await tester.pump();
expect(tester.binding.focusManager.primaryFocus, isNot(equals(child2)));
expect(tester.binding.focusManager.primaryFocus, isNot(equals(child1)));
- expect(scope.focusedChild, isNull);
+ expect(scope.focusedChild, equals(child1));
expect(scope.traversalDescendants.contains(child1), isFalse);
expect(scope.traversalDescendants.contains(child2), isFalse);
});
@@ -483,7 +483,7 @@
expect(scope1.focusedChild, equals(child1));
expect(scope2.focusedChild, equals(child4));
});
- testWidgets('Unfocus works properly', (WidgetTester tester) async {
+ testWidgets('Unfocus with disposition previouslyFocusedChild works properly', (WidgetTester tester) async {
final BuildContext context = await setupWidget(tester);
final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context);
final FocusAttachment scope1Attachment = scope1.attach(context);
@@ -510,27 +510,260 @@
child3Attachment.reparent(parent: parent2);
child4Attachment.reparent(parent: parent2);
+ // Build up a history.
+ child4.requestFocus();
+ await tester.pump();
+ child2.requestFocus();
+ await tester.pump();
+ child3.requestFocus();
+ await tester.pump();
child1.requestFocus();
await tester.pump();
expect(scope1.focusedChild, equals(child1));
- expect(parent2.children.contains(child1), isFalse);
+ expect(scope2.focusedChild, equals(child3));
- child1.unfocus();
+ child1.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
await tester.pump();
- expect(scope1.focusedChild, isNull);
+ expect(scope1.focusedChild, equals(child2));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
expect(child1.hasPrimaryFocus, isFalse);
- expect(scope1.hasFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isTrue);
+ // Can re-focus child.
child1.requestFocus();
await tester.pump();
expect(scope1.focusedChild, equals(child1));
- expect(parent2.children.contains(child1), isFalse);
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
+ expect(child1.hasPrimaryFocus, isTrue);
+ expect(child3.hasPrimaryFocus, isFalse);
- scope1.unfocus();
+ // The same thing happens when unfocusing a second time.
+ child1.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
+ await tester.pump();
+ expect(scope1.focusedChild, equals(child2));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isTrue);
+
+ // When the scope gets unfocused, then the sibling scope gets focus.
+ child1.requestFocus();
+ await tester.pump();
+ scope1.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
+ await tester.pump();
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasFocus, isFalse);
+ expect(scope2.hasFocus, isTrue);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child3.hasPrimaryFocus, isTrue);
+ });
+ testWidgets('Unfocus with disposition scope works properly', (WidgetTester tester) async {
+ final BuildContext context = await setupWidget(tester);
+ final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context);
+ final FocusAttachment scope1Attachment = scope1.attach(context);
+ final FocusScopeNode scope2 = FocusScopeNode(debugLabel: 'scope2');
+ final FocusAttachment scope2Attachment = scope2.attach(context);
+ final FocusNode parent1 = FocusNode(debugLabel: 'parent1');
+ final FocusAttachment parent1Attachment = parent1.attach(context);
+ final FocusNode parent2 = FocusNode(debugLabel: 'parent2');
+ final FocusAttachment parent2Attachment = parent2.attach(context);
+ final FocusNode child1 = FocusNode(debugLabel: 'child1');
+ final FocusAttachment child1Attachment = child1.attach(context);
+ final FocusNode child2 = FocusNode(debugLabel: 'child2');
+ final FocusAttachment child2Attachment = child2.attach(context);
+ final FocusNode child3 = FocusNode(debugLabel: 'child3');
+ final FocusAttachment child3Attachment = child3.attach(context);
+ final FocusNode child4 = FocusNode(debugLabel: 'child4');
+ final FocusAttachment child4Attachment = child4.attach(context);
+ scope1Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ scope2Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ parent1Attachment.reparent(parent: scope1);
+ parent2Attachment.reparent(parent: scope2);
+ child1Attachment.reparent(parent: parent1);
+ child2Attachment.reparent(parent: parent1);
+ child3Attachment.reparent(parent: parent2);
+ child4Attachment.reparent(parent: parent2);
+
+ // Build up a history.
+ child4.requestFocus();
+ await tester.pump();
+ child2.requestFocus();
+ await tester.pump();
+ child3.requestFocus();
+ await tester.pump();
+ child1.requestFocus();
+ await tester.pump();
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+
+ child1.unfocus(disposition: UnfocusDisposition.scope);
+ await tester.pump();
+ // Focused child doesn't change.
+ expect(scope1.focusedChild, isNull);
+ expect(scope2.focusedChild, equals(child3));
+ // Focus does change.
+ expect(scope1.hasPrimaryFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isFalse);
+
+ // Can re-focus child.
+ child1.requestFocus();
+ await tester.pump();
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
+ expect(child1.hasPrimaryFocus, isTrue);
+ expect(child3.hasPrimaryFocus, isFalse);
+
+ // The same thing happens when unfocusing a second time.
+ child1.unfocus(disposition: UnfocusDisposition.scope);
await tester.pump();
expect(scope1.focusedChild, isNull);
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasPrimaryFocus, isTrue);
+ expect(scope2.hasFocus, isFalse);
expect(child1.hasPrimaryFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isFalse);
+
+ // When the scope gets unfocused, then its parent scope (the root scope)
+ // gets focus, but it doesn't mess with the focused children.
+ child1.requestFocus();
+ await tester.pump();
+ scope1.unfocus(disposition: UnfocusDisposition.scope);
+ await tester.pump();
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
expect(scope1.hasFocus, isFalse);
+ expect(scope2.hasFocus, isFalse);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child3.hasPrimaryFocus, isFalse);
+ expect(FocusManager.instance.rootScope.hasPrimaryFocus, isTrue);
+ });
+ testWidgets('Unfocus works properly when some nodes are unfocusable', (WidgetTester tester) async {
+ final BuildContext context = await setupWidget(tester);
+ final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context);
+ final FocusAttachment scope1Attachment = scope1.attach(context);
+ final FocusScopeNode scope2 = FocusScopeNode(debugLabel: 'scope2');
+ final FocusAttachment scope2Attachment = scope2.attach(context);
+ final FocusNode parent1 = FocusNode(debugLabel: 'parent1');
+ final FocusAttachment parent1Attachment = parent1.attach(context);
+ final FocusNode parent2 = FocusNode(debugLabel: 'parent2');
+ final FocusAttachment parent2Attachment = parent2.attach(context);
+ final FocusNode child1 = FocusNode(debugLabel: 'child1');
+ final FocusAttachment child1Attachment = child1.attach(context);
+ final FocusNode child2 = FocusNode(debugLabel: 'child2');
+ final FocusAttachment child2Attachment = child2.attach(context);
+ final FocusNode child3 = FocusNode(debugLabel: 'child3');
+ final FocusAttachment child3Attachment = child3.attach(context);
+ final FocusNode child4 = FocusNode(debugLabel: 'child4');
+ final FocusAttachment child4Attachment = child4.attach(context);
+ scope1Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ scope2Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ parent1Attachment.reparent(parent: scope1);
+ parent2Attachment.reparent(parent: scope2);
+ child1Attachment.reparent(parent: parent1);
+ child2Attachment.reparent(parent: parent1);
+ child3Attachment.reparent(parent: parent2);
+ child4Attachment.reparent(parent: parent2);
+
+ // Build up a history.
+ child4.requestFocus();
+ await tester.pump();
+ child2.requestFocus();
+ await tester.pump();
+ child3.requestFocus();
+ await tester.pump();
+ child1.requestFocus();
+ await tester.pump();
+ expect(child1.hasPrimaryFocus, isTrue);
+
+ scope1.canRequestFocus = false;
+ await tester.pump();
+
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+ expect(child3.hasPrimaryFocus, isTrue);
+
+ child1.unfocus(disposition: UnfocusDisposition.scope);
+ await tester.pump();
+ expect(child3.hasPrimaryFocus, isTrue);
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasPrimaryFocus, isFalse);
+ expect(scope2.hasFocus, isTrue);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isFalse);
+
+ child1.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
+ await tester.pump();
+ expect(child3.hasPrimaryFocus, isTrue);
+ expect(scope1.focusedChild, equals(child1));
+ expect(scope2.focusedChild, equals(child3));
+ expect(scope1.hasPrimaryFocus, isFalse);
+ expect(scope2.hasFocus, isTrue);
+ expect(child1.hasPrimaryFocus, isFalse);
+ expect(child2.hasPrimaryFocus, isFalse);
+ });
+ testWidgets('Requesting focus on a scope works properly when some focusedChild nodes are unfocusable', (WidgetTester tester) async {
+ final BuildContext context = await setupWidget(tester);
+ final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context);
+ final FocusAttachment scope1Attachment = scope1.attach(context);
+ final FocusScopeNode scope2 = FocusScopeNode(debugLabel: 'scope2');
+ final FocusAttachment scope2Attachment = scope2.attach(context);
+ final FocusNode parent1 = FocusNode(debugLabel: 'parent1');
+ final FocusAttachment parent1Attachment = parent1.attach(context);
+ final FocusNode parent2 = FocusNode(debugLabel: 'parent2');
+ final FocusAttachment parent2Attachment = parent2.attach(context);
+ final FocusNode child1 = FocusNode(debugLabel: 'child1');
+ final FocusAttachment child1Attachment = child1.attach(context);
+ final FocusNode child2 = FocusNode(debugLabel: 'child2');
+ final FocusAttachment child2Attachment = child2.attach(context);
+ final FocusNode child3 = FocusNode(debugLabel: 'child3');
+ final FocusAttachment child3Attachment = child3.attach(context);
+ final FocusNode child4 = FocusNode(debugLabel: 'child4');
+ final FocusAttachment child4Attachment = child4.attach(context);
+ scope1Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ scope2Attachment.reparent(parent: tester.binding.focusManager.rootScope);
+ parent1Attachment.reparent(parent: scope1);
+ parent2Attachment.reparent(parent: scope2);
+ child1Attachment.reparent(parent: parent1);
+ child2Attachment.reparent(parent: parent1);
+ child3Attachment.reparent(parent: parent2);
+ child4Attachment.reparent(parent: parent2);
+
+ // Build up a history.
+ child4.requestFocus();
+ await tester.pump();
+ child2.requestFocus();
+ await tester.pump();
+ child3.requestFocus();
+ await tester.pump();
+ child1.requestFocus();
+ await tester.pump();
+ expect(child1.hasPrimaryFocus, isTrue);
+
+ child1.canRequestFocus = false;
+ child3.canRequestFocus = false;
+ await tester.pump();
+ scope1.requestFocus();
+ await tester.pump();
+
+ expect(scope1.focusedChild, equals(child2));
+ expect(child2.hasPrimaryFocus, isTrue);
+
+ scope2.requestFocus();
+ await tester.pump();
+
+ expect(scope2.focusedChild, equals(child4));
+ expect(child4.hasPrimaryFocus, isTrue);
});
testWidgets('Key handling bubbles up and terminates when handled.', (WidgetTester tester) async {
final Set<FocusNode> receivedAnEvent = <FocusNode>{};
@@ -821,11 +1054,11 @@
child1.unfocus();
await tester.pump();
expect(topFocus, isFalse);
- expect(parent1Focus, isFalse);
+ expect(parent1Focus, isTrue);
expect(child1Focus, isFalse);
expect(parent2Focus, isFalse);
expect(child2Focus, isFalse);
- expect(topNotify, equals(1));
+ expect(topNotify, equals(0));
expect(parent1Notify, equals(1));
expect(child1Notify, equals(1));
expect(parent2Notify, equals(0));
@@ -834,12 +1067,12 @@
clear();
child1.requestFocus();
await tester.pump();
- expect(topFocus, isTrue);
+ expect(topFocus, isFalse);
expect(parent1Focus, isTrue);
expect(child1Focus, isTrue);
expect(parent2Focus, isFalse);
expect(child2Focus, isFalse);
- expect(topNotify, equals(1));
+ expect(topNotify, equals(0));
expect(parent1Notify, equals(1));
expect(child1Notify, equals(1));
expect(parent2Notify, equals(0));
diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart
index 101b87c..ad5a6cb 100644
--- a/packages/flutter/test/widgets/focus_scope_test.dart
+++ b/packages/flutter/test/widgets/focus_scope_test.dart
@@ -1428,10 +1428,10 @@
// Check FocusNode with child (focus1). Shouldn't affect children.
await pumpTest(allowFocus1: false);
- expect(Focus.of(container1.currentContext).hasFocus, isFalse);
+ expect(Focus.of(container1.currentContext).hasFocus, isTrue); // focus2 has focus.
Focus.of(focus2.currentContext).requestFocus(); // Try to focus focus1
await tester.pump();
- expect(Focus.of(container1.currentContext).hasFocus, isFalse);
+ expect(Focus.of(container1.currentContext).hasFocus, isTrue); // focus2 still has focus.
Focus.of(container1.currentContext).requestFocus(); // Now try to focus focus2
await tester.pump();
expect(Focus.of(container1.currentContext).hasFocus, isTrue);
diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart
index c8c4a7c..4fc4e2b 100644
--- a/packages/flutter/test/widgets/routes_test.dart
+++ b/packages/flutter/test/widgets/routes_test.dart
@@ -6,9 +6,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
-import 'package:mockito/mockito.dart';
-import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
final List<String> results = <String>[];
@@ -1242,6 +1242,58 @@
// It should refocus page one after pops.
expect(focusNodeOnPageOne.hasFocus, isTrue);
});
+
+ testWidgets('focus traversal is correct when popping mutiple pages simultaneously - with focused children', (WidgetTester tester) async {
+ // Regression test: https://github.com/flutter/flutter/issues/48903
+ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+ await tester.pumpWidget(MaterialApp(
+ navigatorKey: navigatorKey,
+ home: const Text('dummy1'),
+ ));
+ final Element textOnPageOne = tester.element(find.text('dummy1'));
+ final FocusScopeNode focusNodeOnPageOne = FocusScope.of(textOnPageOne);
+ expect(focusNodeOnPageOne.hasFocus, isTrue);
+
+ // Pushes one page.
+ navigatorKey.currentState.push<void>(
+ MaterialPageRoute<void>(
+ builder: (BuildContext context) => const Material(child: TextField()),
+ )
+ );
+ await tester.pumpAndSettle();
+
+ final Element textOnPageTwo = tester.element(find.byType(TextField));
+ final FocusScopeNode focusNodeOnPageTwo = FocusScope.of(textOnPageTwo);
+ // The focus should be on second page.
+ expect(focusNodeOnPageOne.hasFocus, isFalse);
+ expect(focusNodeOnPageTwo.hasFocus, isTrue);
+
+ // Move the focus to another node.
+ focusNodeOnPageTwo.nextFocus();
+ await tester.pumpAndSettle();
+ expect(focusNodeOnPageTwo.hasFocus, isTrue);
+ expect(focusNodeOnPageTwo.hasPrimaryFocus, isFalse);
+
+ // Pushes another page.
+ navigatorKey.currentState.push<void>(
+ MaterialPageRoute<void>(
+ builder: (BuildContext context) => const Text('dummy3'),
+ )
+ );
+ await tester.pumpAndSettle();
+ final Element textOnPageThree = tester.element(find.text('dummy3'));
+ final FocusScopeNode focusNodeOnPageThree = FocusScope.of(textOnPageThree);
+ // The focus should be on third page.
+ expect(focusNodeOnPageOne.hasFocus, isFalse);
+ expect(focusNodeOnPageTwo.hasFocus, isFalse);
+ expect(focusNodeOnPageThree.hasFocus, isTrue);
+
+ // Pops two pages simultaneously.
+ navigatorKey.currentState.popUntil((Route<void> route) => route.isFirst);
+ await tester.pumpAndSettle();
+ // It should refocus page one after pops.
+ expect(focusNodeOnPageOne.hasFocus, isTrue);
+ });
});
}