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 {