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

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
parent 444b13b8
...@@ -85,7 +85,7 @@ class FocusAttachment { ...@@ -85,7 +85,7 @@ class FocusAttachment {
assert(_focusDebug('Detaching node:', <String>[_node.toString(), 'With enclosing scope ${_node.enclosingScope}'])); assert(_focusDebug('Detaching node:', <String>[_node.toString(), 'With enclosing scope ${_node.enclosingScope}']));
if (isAttached) { if (isAttached) {
if (_node.hasPrimaryFocus || (_node._manager != null && _node._manager._markedForFocus == _node)) { 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. // This node is no longer in the tree, so shouldn't send notifications anymore.
_node._manager?._markDetached(_node); _node._manager?._markDetached(_node);
...@@ -93,7 +93,6 @@ class FocusAttachment { ...@@ -93,7 +93,6 @@ class FocusAttachment {
_node._attachment = null; _node._attachment = null;
assert(!_node.hasPrimaryFocus); assert(!_node.hasPrimaryFocus);
assert(_node._manager?._markedForFocus != _node); assert(_node._manager?._markedForFocus != _node);
assert(_node._manager?._markedForUnfocus != _node);
} }
assert(!isAttached); assert(!isAttached);
} }
...@@ -132,6 +131,43 @@ class FocusAttachment { ...@@ -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 /// An object that can be used by a stateful widget to obtain the keyboard focus
/// and to handle keyboard events. /// and to handle keyboard events.
/// ///
...@@ -437,12 +473,13 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -437,12 +473,13 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
final FocusScopeNode scope = enclosingScope; final FocusScopeNode scope = enclosingScope;
return _canRequestFocus && (scope == null || scope.canRequestFocus); return _canRequestFocus && (scope == null || scope.canRequestFocus);
} }
bool _canRequestFocus; bool _canRequestFocus;
@mustCallSuper @mustCallSuper
set canRequestFocus(bool value) { set canRequestFocus(bool value) {
if (value != _canRequestFocus) { if (value != _canRequestFocus) {
if (!value) { if (!value) {
unfocus(focusPrevious: true); unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
} }
_canRequestFocus = value; _canRequestFocus = value;
_manager?._markPropertiesChanged(this); _manager?._markPropertiesChanged(this);
...@@ -562,15 +599,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -562,15 +599,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// ///
/// * [Focus.isAt], which is a static method that will return the focus /// * [Focus.isAt], which is a static method that will return the focus
/// state of the nearest ancestor [Focus] widget's focus node. /// state of the nearest ancestor [Focus] widget's focus node.
bool get hasFocus { bool get hasFocus => hasPrimaryFocus || (_manager?.primaryFocus?.ancestors?.contains(this) ?? false);
if (_manager?.primaryFocus == null || _manager?._markedForUnfocus == this) {
return false;
}
if (hasPrimaryFocus) {
return true;
}
return _manager.primaryFocus.ancestors.contains(this);
}
/// Returns true if this node currently has the application-wide input focus. /// Returns true if this node currently has the application-wide input focus.
/// ///
...@@ -646,43 +675,157 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -646,43 +675,157 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
return globalOffset & object.semanticBounds.size; return globalOffset & object.semanticBounds.size;
} }
/// Removes focus from a node that has the primary focus, and cancels any /// Removes the focus on this node by moving the primary focus to another node.
/// outstanding requests to focus it. ///
/// /// This method removes focus from a node that has the primary focus, cancels
/// Calling [requestFocus] sends a request to the [FocusManager] to make that /// any outstanding requests to focus it, while setting the primary focus to
/// node the primary focus, which schedules a microtask to resolve the latest /// another node according to the `disposition`.
/// 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. /// 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
/// This method is safe to call regardless of whether this node has ever /// happens.
/// requested focus. ///
/// /// The `disposition` argument determines which node will receive primary
/// For nodes that return true from [hasFocus], but false from /// focus after this one loses it.
/// [hasPrimaryFocus], this will unfocus the descendant node that has the ///
/// primary focus instead ([FocusManager.primaryFocus]). /// If `disposition` is set to [UnfocusDisposition.scope] (the default), then
/// /// the previously focused node history of the enclosing scope will be
/// If [focusPrevious] is true, then rather than losing all focus, the focus /// cleared, and the primary focus will be moved to the nearest enclosing
/// will be moved to the node that the [enclosingScope] thinks should have it, /// scope ancestor that is enabled for focus, ignoring the
/// based on its history of nodes that were set as first focus on it using /// [FocusScopeNode.focusedChild] for that scope.
/// [FocusScopeNode.setFirstFocus]. ///
void unfocus({ bool focusPrevious = false }) { /// If `disposition` is set to [UnfocusDisposition.previouslyFocusedChild],
assert(focusPrevious != null); /// 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)) { if (!hasFocus && (_manager == null || _manager._markedForFocus != this)) {
return; return;
} }
if (!hasPrimaryFocus) { FocusScopeNode scope = enclosingScope;
// If we are in the focus chain, but not the primary focus, then unfocus if (scope == null) {
// the primary instead. // If the scope is null, then this is either the root node, or a node that
_manager?.primaryFocus?.unfocus(focusPrevious: focusPrevious); // is not yet in the tree, neither of which do anything when unfocused.
return;
} }
_manager?._markUnfocused(this); switch (disposition) {
final FocusScopeNode scope = enclosingScope; case UnfocusDisposition.scope:
if (scope != null) { // If it can't request focus, then don't modify its focused children.
scope._focusedChildren.remove(this); if (scope.canRequestFocus) {
if (focusPrevious) { // Clearing the focused children here prevents re-focusing the node
scope._doRequestFocus(); // 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. /// Removes the keyboard token from this focus node if it has one.
...@@ -785,7 +928,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -785,7 +928,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
FocusTraversalGroup.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope); FocusTraversalGroup.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
} }
if (child._requestFocusWhenReparented) { if (child._requestFocusWhenReparented) {
child._doRequestFocus(); child._doRequestFocus(findFirstFocus: true);
child._requestFocusWhenReparented = false; child._requestFocusWhenReparented = false;
} }
} }
...@@ -852,14 +995,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -852,14 +995,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
_reparent(node); _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.'); 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; return;
} }
_doRequestFocus(); _doRequestFocus(findFirstFocus: true);
} }
// Note that this is overridden in FocusScopeNode. // Note that this is overridden in FocusScopeNode.
void _doRequestFocus() { void _doRequestFocus({@required bool findFirstFocus}) {
assert(findFirstFocus != null);
if (!canRequestFocus) { if (!canRequestFocus) {
assert(_focusDebug('Node NOT requesting focus because canRequestFocus is false: $this')); assert(_focusDebug('Node NOT requesting focus because canRequestFocus is false: $this'));
return; return;
...@@ -1043,7 +1187,7 @@ class FocusScopeNode extends FocusNode { ...@@ -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.'); assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.');
if (hasFocus) { if (hasFocus) {
scope._doRequestFocus(); scope._doRequestFocus(findFirstFocus: true);
} else { } else {
scope._setAsFocusedChildForScope(); scope._setAsFocusedChildForScope();
} }
...@@ -1066,12 +1210,29 @@ class FocusScopeNode extends FocusNode { ...@@ -1066,12 +1210,29 @@ class FocusScopeNode extends FocusNode {
_reparent(node); _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.'); 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 @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 // Start with the primary focus as the focused child of this scope, if there
// is one. Otherwise start with this node itself. // is one. Otherwise start with this node itself.
FocusNode primaryFocus = focusedChild ?? this; FocusNode primaryFocus = focusedChild ?? this;
...@@ -1093,7 +1254,7 @@ class FocusScopeNode extends FocusNode { ...@@ -1093,7 +1254,7 @@ class FocusScopeNode extends FocusNode {
// We found a FocusScopeNode at the leaf, so ask it to focus itself // 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 // instead of this scope. That will cause this scope to return true from
// hasFocus, but false from hasPrimaryFocus. // hasFocus, but false from hasPrimaryFocus.
primaryFocus._doRequestFocus(); primaryFocus._doRequestFocus(findFirstFocus: findFirstFocus);
} }
} }
...@@ -1390,17 +1551,10 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn ...@@ -1390,17 +1551,10 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn
// given it yet. // given it yet.
FocusNode _markedForFocus; FocusNode _markedForFocus;
// The node that has been marked as needing to be unfocused during the next
// focus update.
FocusNode _markedForUnfocus;
void _markDetached(FocusNode node) { void _markDetached(FocusNode node) {
// The node has been removed from the tree, so it no longer needs to be // The node has been removed from the tree, so it no longer needs to be
// notified of changes. // notified of changes.
assert(_focusDebug('Node was detached: $node')); assert(_focusDebug('Node was detached: $node'));
if (_markedForUnfocus == node) {
_markedForUnfocus = null;
}
if (_primaryFocus == node) { if (_primaryFocus == node) {
_primaryFocus = null; _primaryFocus = null;
} }
...@@ -1418,36 +1572,12 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn ...@@ -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 // The caller asked for the current focus to be the next focus, so just
// pretend that didn't happen. // pretend that didn't happen.
_markedForFocus = null; _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 { } else {
_markedForFocus = node; _markedForFocus = node;
_markNeedsUpdate(); _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. // True indicates that there is an update pending.
bool _haveScheduledUpdate = false; bool _haveScheduledUpdate = false;
...@@ -1464,9 +1594,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn ...@@ -1464,9 +1594,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn
void _applyFocusChange() { void _applyFocusChange() {
_haveScheduledUpdate = false; _haveScheduledUpdate = false;
if (_markedForUnfocus == _primaryFocus) {
_primaryFocus = null;
}
final FocusNode previousFocus = _primaryFocus; final FocusNode previousFocus = _primaryFocus;
if (_primaryFocus == null && _markedForFocus == null) { if (_primaryFocus == null && _markedForFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet, // 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 ...@@ -1479,11 +1606,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn
if (_markedForFocus != null && _markedForFocus != _primaryFocus) { if (_markedForFocus != null && _markedForFocus != _primaryFocus) {
final Set<FocusNode> previousPath = previousFocus?.ancestors?.toSet() ?? <FocusNode>{}; final Set<FocusNode> previousPath = previousFocus?.ancestors?.toSet() ?? <FocusNode>{};
final Set<FocusNode> nextPath = _markedForFocus.ancestors.toSet(); 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. // Notify nodes that are newly focused.
_dirtyNodes.addAll(nextPath.difference(previousPath)); _dirtyNodes.addAll(nextPath.difference(previousPath));
// Notify nodes that are no longer focused // Notify nodes that are no longer focused
...@@ -1506,7 +1628,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn ...@@ -1506,7 +1628,6 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier implements Diagn
node._notify(); node._notify();
} }
_dirtyNodes.clear(); _dirtyNodes.clear();
_markedForUnfocus = null;
if (previousFocus != _primaryFocus) { if (previousFocus != _primaryFocus) {
notifyListeners(); notifyListeners();
} }
......
...@@ -914,6 +914,8 @@ void main() { ...@@ -914,6 +914,8 @@ void main() {
tester.testTextInput.log.clear(); tester.testTextInput.log.clear();
tester.testTextInput.closeConnection(); 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. // Widget does not have focus anymore.
expect(state.wantKeepAlive, false); expect(state.wantKeepAlive, false);
......
...@@ -316,7 +316,7 @@ void main() { ...@@ -316,7 +316,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.binding.focusManager.primaryFocus, isNot(equals(child2))); expect(tester.binding.focusManager.primaryFocus, isNot(equals(child2)));
expect(tester.binding.focusManager.primaryFocus, isNot(equals(child1))); 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(child1), isFalse);
expect(scope.traversalDescendants.contains(child2), isFalse); expect(scope.traversalDescendants.contains(child2), isFalse);
}); });
...@@ -483,7 +483,7 @@ void main() { ...@@ -483,7 +483,7 @@ void main() {
expect(scope1.focusedChild, equals(child1)); expect(scope1.focusedChild, equals(child1));
expect(scope2.focusedChild, equals(child4)); 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 BuildContext context = await setupWidget(tester);
final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context); final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1')..attach(context);
final FocusAttachment scope1Attachment = scope1.attach(context); final FocusAttachment scope1Attachment = scope1.attach(context);
...@@ -510,27 +510,260 @@ void main() { ...@@ -510,27 +510,260 @@ void main() {
child3Attachment.reparent(parent: parent2); child3Attachment.reparent(parent: parent2);
child4Attachment.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(); child1.requestFocus();
await tester.pump(); await tester.pump();
expect(scope1.focusedChild, equals(child1)); 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(); 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(child1.hasPrimaryFocus, isFalse);
expect(child2.hasPrimaryFocus, isTrue);
// 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.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(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(); child1.requestFocus();
await tester.pump(); await tester.pump();
expect(scope1.focusedChild, equals(child1)); expect(scope1.focusedChild, equals(child1));
expect(parent2.children.contains(child1), isFalse); 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);
scope1.unfocus(); // The same thing happens when unfocusing a second time.
child1.unfocus(disposition: UnfocusDisposition.scope);
await tester.pump(); await tester.pump();
expect(scope1.focusedChild, isNull); expect(scope1.focusedChild, isNull);
expect(scope2.focusedChild, equals(child3));
expect(scope1.hasPrimaryFocus, isTrue);
expect(scope2.hasFocus, isFalse);
expect(child1.hasPrimaryFocus, 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(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 { testWidgets('Key handling bubbles up and terminates when handled.', (WidgetTester tester) async {
final Set<FocusNode> receivedAnEvent = <FocusNode>{}; final Set<FocusNode> receivedAnEvent = <FocusNode>{};
...@@ -821,11 +1054,11 @@ void main() { ...@@ -821,11 +1054,11 @@ void main() {
child1.unfocus(); child1.unfocus();
await tester.pump(); await tester.pump();
expect(topFocus, isFalse); expect(topFocus, isFalse);
expect(parent1Focus, isFalse); expect(parent1Focus, isTrue);
expect(child1Focus, isFalse); expect(child1Focus, isFalse);
expect(parent2Focus, isFalse); expect(parent2Focus, isFalse);
expect(child2Focus, isFalse); expect(child2Focus, isFalse);
expect(topNotify, equals(1)); expect(topNotify, equals(0));
expect(parent1Notify, equals(1)); expect(parent1Notify, equals(1));
expect(child1Notify, equals(1)); expect(child1Notify, equals(1));
expect(parent2Notify, equals(0)); expect(parent2Notify, equals(0));
...@@ -834,12 +1067,12 @@ void main() { ...@@ -834,12 +1067,12 @@ void main() {
clear(); clear();
child1.requestFocus(); child1.requestFocus();
await tester.pump(); await tester.pump();
expect(topFocus, isTrue); expect(topFocus, isFalse);
expect(parent1Focus, isTrue); expect(parent1Focus, isTrue);
expect(child1Focus, isTrue); expect(child1Focus, isTrue);
expect(parent2Focus, isFalse); expect(parent2Focus, isFalse);
expect(child2Focus, isFalse); expect(child2Focus, isFalse);
expect(topNotify, equals(1)); expect(topNotify, equals(0));
expect(parent1Notify, equals(1)); expect(parent1Notify, equals(1));
expect(child1Notify, equals(1)); expect(child1Notify, equals(1));
expect(parent2Notify, equals(0)); expect(parent2Notify, equals(0));
......
...@@ -1428,10 +1428,10 @@ void main() { ...@@ -1428,10 +1428,10 @@ void main() {
// Check FocusNode with child (focus1). Shouldn't affect children. // Check FocusNode with child (focus1). Shouldn't affect children.
await pumpTest(allowFocus1: false); 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 Focus.of(focus2.currentContext).requestFocus(); // Try to focus focus1
await tester.pump(); 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 Focus.of(container1.currentContext).requestFocus(); // Now try to focus focus2
await tester.pump(); await tester.pump();
expect(Focus.of(container1.currentContext).hasFocus, isTrue); expect(Focus.of(container1.currentContext).hasFocus, isTrue);
......
...@@ -6,9 +6,9 @@ import 'dart:collection'; ...@@ -6,9 +6,9 @@ import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
final List<String> results = <String>[]; final List<String> results = <String>[];
...@@ -1242,6 +1242,58 @@ void main() { ...@@ -1242,6 +1242,58 @@ void main() {
// It should refocus page one after pops. // It should refocus page one after pops.
expect(focusNodeOnPageOne.hasFocus, isTrue); 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);
});
}); });
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment