From 753dbac88231687bbdf8da3632af2f7b6c23e38e Mon Sep 17 00:00:00 2001 From: Pedro Massango Date: Thu, 10 Jul 2025 23:10:37 +0200 Subject: [PATCH] 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. --- .../lib/src/widgets/focus_traversal.dart | 10 +++ .../test/widgets/focus_traversal_test.dart | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index c58c46729f0..4de6de22af2 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -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 { policy: widget.policy, ); + @override + void initState() { + super.initState(); + widget.onFocusNodeCreated?.call(focusNode); + } + @override void dispose() { focusNode.dispose(); diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index cc0112b625b..eb37be44979 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -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: [ + MaterialButton( + focusNode: enabledButton1Node, + onPressed: () {}, // enabled + child: const Text('Enabled Button 1'), + ), + FocusTraversalGroup( + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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 {