Unverified Commit 0f4e1db7 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

[Focus] defer autofocus resolution to `_applyFocusChange` (#85562)

parent 8847015a
......@@ -96,6 +96,36 @@ typedef FocusOnKeyCallback = KeyEventResult Function(FocusNode node, RawKeyEvent
/// was handled.
typedef FocusOnKeyEventCallback = KeyEventResult Function(FocusNode node, KeyEvent event);
// Represents a pending autofocus request.
@immutable
class _Autofocus {
const _Autofocus({ required this.scope, required this.autofocusNode });
final FocusScopeNode scope;
final FocusNode autofocusNode;
// Applies the autofocus request, if the node is still attached to the
// original scope and the scope has no focused child.
//
// The widget tree is responsible for calling reparent/detach on attached
// nodes to keep their parent/manager information up-to-date, so here we can
// safely check if the scope/node involved in each autofocus request is
// still attached, and discard the ones are no longer attached to the
// original manager.
void applyIfValid(FocusManager manager) {
final bool shouldApply = (scope.parent != null || identical(scope, manager.rootScope))
&& identical(scope._manager, manager)
&& scope.focusedChild == null
&& autofocusNode.ancestors.contains(scope);
if (shouldApply) {
assert(_focusDebug('Applying autofocus: $autofocusNode'));
autofocusNode._doRequestFocus(findFirstFocus: true);
} else {
assert(_focusDebug('Autofocus request discarded for node: $autofocusNode.'));
}
}
}
/// An attachment point for a [FocusNode].
///
/// Using a [FocusAttachment] is rarely needed, unless you are building
......@@ -1353,14 +1383,16 @@ class FocusScopeNode extends FocusNode {
/// The node is notified that it has received the primary focus in a
/// microtask, so notification may lag the request by up to one frame.
void autofocus(FocusNode node) {
assert(_focusDebug('Node autofocusing: $node'));
if (focusedChild == null) {
if (node._parent == null) {
_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(findFirstFocus: true);
// Attach the node to the tree first, so in _applyFocusChange if the node
// is detached we don't add it back to the tree.
if (node._parent == null) {
_reparent(node);
}
assert(_manager != null);
assert(_focusDebug('Autofocus scheduled for $node: scope $this'));
_manager?._pendingAutofocuses.add(_Autofocus(scope: this, autofocusNode: node));
_manager?._markNeedsUpdate();
}
@override
......@@ -1368,13 +1400,14 @@ class FocusScopeNode extends FocusNode {
assert(findFirstFocus != null);
// It is possible that a previously focused child is no longer focusable.
while (focusedChild != null && !focusedChild!.canRequestFocus)
while (this.focusedChild != null && !this.focusedChild!.canRequestFocus)
_focusedChildren.removeLast();
final FocusNode? focusedChild = this.focusedChild;
// 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 (!findFirstFocus || focusedChild == null) {
if (canRequestFocus) {
_setAsFocusedChildForScope();
_markNextFocus(this);
......@@ -1382,28 +1415,7 @@ class FocusScopeNode extends FocusNode {
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;
// Keep going down through scopes until the ultimately focusable item is
// found, a scope doesn't have a focusedChild, or a non-scope is
// encountered.
while (primaryFocus is FocusScopeNode && primaryFocus.focusedChild != null) {
primaryFocus = primaryFocus.focusedChild!;
}
if (identical(primaryFocus, this)) {
// We didn't find a FocusNode at the leaf, so we're focusing the scope, if
// allowed.
if (primaryFocus.canRequestFocus) {
_setAsFocusedChildForScope();
_markNextFocus(this);
}
} else {
// 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(findFirstFocus: findFirstFocus);
}
focusedChild._doRequestFocus(findFirstFocus: true);
}
@override
......@@ -1811,6 +1823,9 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
}
}
// The list of autofocus requests made since the last _appyFocusChange call.
final List<_Autofocus> _pendingAutofocuses = <_Autofocus>[];
// True indicates that there is an update pending.
bool _haveScheduledUpdate = false;
......@@ -1828,6 +1843,12 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
void _applyFocusChange() {
_haveScheduledUpdate = false;
final FocusNode? previousFocus = _primaryFocus;
for (final _Autofocus autofocus in _pendingAutofocuses) {
autofocus.applyIfValid(this);
}
_pendingAutofocuses.clear();
if (_primaryFocus == null && _markedForFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet,
// then revert to the root scope.
......
......@@ -676,7 +676,8 @@ class _FocusState extends State<Focus> {
focusNode.descendantsAreFocusable = widget.descendantsAreFocusable;
} else {
_focusAttachment!.detach();
focusNode.removeListener(_handleFocusChanged);
oldWidget.focusNode?.removeListener(_handleFocusChanged);
_internalNode?.removeListener(_handleAutofocus);
_initNode();
}
......
......@@ -1199,6 +1199,148 @@ void main() {
});
});
group('Autofocus', () {
testWidgets(
'works when the previous focused node is detached',
(WidgetTester tester) async {
final FocusNode node1 = FocusNode();
final FocusNode node2 = FocusNode();
await tester.pumpWidget(
FocusScope(
child: Focus(autofocus: true, focusNode: node1, child: const Placeholder()),
),
);
await tester.pump();
expect(node1.hasPrimaryFocus, isTrue);
await tester.pumpWidget(
FocusScope(
child: SizedBox(
child: Focus(autofocus: true, focusNode: node2, child: const Placeholder()),
),
),
);
await tester.pump();
expect(node2.hasPrimaryFocus, isTrue);
});
testWidgets(
'node detached before autofocus is applied',
(WidgetTester tester) async {
final FocusScopeNode scopeNode = FocusScopeNode();
final FocusNode node1 = FocusNode();
await tester.pumpWidget(
FocusScope(
node: scopeNode,
child: Focus(
autofocus: true,
focusNode: node1,
child: const Placeholder(),
),
),
);
await tester.pumpWidget(
FocusScope(
node: scopeNode,
child: const Focus(child: Placeholder()),
),
);
await tester.pump();
expect(node1.hasPrimaryFocus, isFalse);
expect(scopeNode.hasPrimaryFocus, isTrue);
});
testWidgets('autofocus the first candidate', (WidgetTester tester) async {
final FocusNode node1 = FocusNode();
final FocusNode node2 = FocusNode();
final FocusNode node3 = FocusNode();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: <Focus>[
Focus(
autofocus: true,
focusNode: node1,
child: const SizedBox(),
),
Focus(
autofocus: true,
focusNode: node2,
child: const SizedBox(),
),
Focus(
autofocus: true,
focusNode: node3,
child: const SizedBox(),
),
],
),
),
);
expect(node1.hasPrimaryFocus, isTrue);
});
testWidgets('Autofocus works with global key reparenting', (WidgetTester tester) async {
final FocusNode node = FocusNode();
final FocusScopeNode scope1 = FocusScopeNode(debugLabel: 'scope1');
final FocusScopeNode scope2 = FocusScopeNode(debugLabel: 'scope2');
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: <Focus>[
FocusScope(
node: scope1,
child: Focus(
key: key,
focusNode: node,
child: const SizedBox(),
),
),
FocusScope(node: scope2, child: const SizedBox()),
],
),
),
);
// _applyFocusChange will be called before persistentCallbacks,
// guaranteeing the focus changes are applied before the BuildContext
// `node` attaches to gets reparented.
scope1.autofocus(node);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: <Focus>[
FocusScope(node: scope1, child: const SizedBox()),
FocusScope(
node: scope2,
child: Focus(
key: key,
focusNode: node,
child: const SizedBox(),
),
),
],
),
),
);
expect(node.hasPrimaryFocus, isTrue);
expect(scope2.hasFocus, isTrue);
});
});
testWidgets("Doesn't lose focused child when reparenting if the nearestScope doesn't change.", (WidgetTester tester) async {
final BuildContext context = await setupWidget(tester);
final FocusScopeNode parent1 = FocusScopeNode(debugLabel: 'parent1');
......
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