feat: Expose FocusNode of FocusTraversalGroup (#171979)

Expose the focus node of `FocusTraversalGroup` so customers can listen
to focus changes of its descendants.

Fixes https://github.com/flutter/flutter/issues/171516

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
Pedro Massango 2025-07-10 23:10:37 +02:00 committed by GitHub
parent 94265c52b3
commit 753dbac882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 82 additions and 0 deletions

View File

@ -2038,6 +2038,7 @@ class FocusTraversalGroup extends StatefulWidget {
FocusTraversalPolicy? policy,
this.descendantsAreFocusable = true,
this.descendantsAreTraversable = true,
this.onFocusNodeCreated,
required this.child,
}) : policy = policy ?? ReadingOrderTraversalPolicy();
@ -2069,6 +2070,9 @@ class FocusTraversalGroup extends StatefulWidget {
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// Called when the [FocusNode] of this widget is created.
final void Function(FocusNode)? onFocusNodeCreated;
/// Returns the [FocusTraversalPolicy] that applies to the nearest ancestor of
/// the given [FocusNode].
///
@ -2206,6 +2210,12 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
policy: widget.policy,
);
@override
void initState() {
super.initState();
widget.onFocusNodeCreated?.call(focusNode);
}
@override
void dispose() {
focusNode.dispose();

View File

@ -3786,6 +3786,78 @@ void main() {
expect(childScope.hasFocus, isTrue);
expect(nodeA.hasFocus, isFalse);
});
testWidgets('GIVEN onFocusNodeCreated is not null '
'THEN it is called when the FocusTraversalGroup is built', (WidgetTester tester) async {
FocusNode? node;
await tester.pumpWidget(
FocusTraversalGroup(
onFocusNodeCreated: (FocusNode createdNode) => node = createdNode,
child: const SizedBox.shrink(),
),
);
await tester.pumpAndSettle();
expect(node, isNotNull);
});
testWidgets(
'GIVEN a FocusScope with no focusable descendants '
'WHEN the user presses TAB to navigate focus '
'THEN focus should skip the scope and land on the next focusable widget without requiring multiple TAB presses',
(WidgetTester tester) async {
final FocusNode enabledButton1Node = FocusNode();
addTearDown(enabledButton1Node.dispose);
final FocusNode enabledButton2Node = FocusNode();
addTearDown(enabledButton2Node.dispose);
await tester.pumpWidget(
MaterialApp(
home: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
MaterialButton(
focusNode: enabledButton1Node,
onPressed: () {}, // enabled
child: const Text('Enabled Button 1'),
),
FocusTraversalGroup(
child: const Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
MaterialButton(
onPressed: null, // disabled
child: Text('Disabled Button 1'),
),
SizedBox(height: 16),
MaterialButton(
onPressed: null, // disabled
child: Text('Disabled Button 2'),
),
],
),
),
MaterialButton(
focusNode: enabledButton2Node,
onPressed: () {}, // enabled
child: const Text('Enabled Button 2'),
),
],
),
),
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(enabledButton1Node.hasPrimaryFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(enabledButton2Node.hasPrimaryFocus, isTrue);
},
);
}
class TestRoute extends PageRouteBuilder<void> {