diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index f133dbc3a7..fc17b2492c 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -85,7 +85,7 @@ class FocusAttachment { assert(_focusDebug('Detaching node:', [_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 @@ class FocusAttachment { _node._attachment = null; assert(!_node.hasPrimaryFocus); assert(_node._manager?._markedForFocus != _node); - assert(_node._manager?._markedForUnfocus != _node); } assert(!isAttached); } @@ -132,6 +131,43 @@ class FocusAttachment { } } +/// 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 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { 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 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { /// /// * [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 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { 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: [ + /// Wrap( + /// children: List.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: [ + /// ...List.generate(UnfocusDisposition.values.length, + /// (int index) { + /// return Row( + /// mainAxisSize: MainAxisSize.min, + /// children: [ + /// Radio( + /// 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:', ['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 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { 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 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { _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 @@ 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(); + scope._doRequestFocus(findFirstFocus: true); } else { scope._setAsFocusedChildForScope(); } @@ -1066,12 +1210,29 @@ class FocusScopeNode extends FocusNode { _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 @@ class FocusScopeNode extends FocusNode { // 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 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn // 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 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn // 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:', ['primary focus is $_primaryFocus', 'next focus will be $_markedForFocus'])); - } - // True indicates that there is an update pending. bool _haveScheduledUpdate = false; @@ -1464,9 +1594,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn 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 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn if (_markedForFocus != null && _markedForFocus != _primaryFocus) { final Set previousPath = previousFocus?.ancestors?.toSet() ?? {}; final Set nextPath = _markedForFocus.ancestors.toSet(); - if (_markedForUnfocus != null) { - final Set unfocusedNodes = {_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 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn 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 f3da8cafb8..66628a3ffe 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -914,6 +914,8 @@ void main() { 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 9a6b3b05d4..88513d04a1 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -316,7 +316,7 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 receivedAnEvent = {}; @@ -821,11 +1054,11 @@ void main() { 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 @@ void main() { 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 101b87c176..ad5a6cbfa7 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -1428,10 +1428,10 @@ void main() { // 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 c8c4a7c755..4fc4e2b8be 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -6,9 +6,9 @@ import 'dart:collection'; 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 results = []; @@ -1242,6 +1242,58 @@ void main() { // 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 navigatorKey = GlobalKey(); + 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( + MaterialPageRoute( + 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( + MaterialPageRoute( + 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 route) => route.isFirst); + await tester.pumpAndSettle(); + // It should refocus page one after pops. + expect(focusNodeOnPageOne.hasFocus, isTrue); + }); }); }