diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 5d40de31180..782c376a7ba 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -335,10 +335,23 @@ abstract class FocusTraversalPolicy with Diagnosticable { } } + // Visit the children of the scope. visitGroups(groups[scopeGroupMarker?.focusNode]!); + + // Remove the FocusTraversalGroup nodes themselves, which aren't focusable. + // They were left in above because they were needed to find their members + // during sorting. + sortedDescendants.removeWhere((FocusNode node) { + return !node.canRequestFocus || node.skipTraversal; + }); + + // Sanity check to make sure that the algorithm above doesn't diverge from + // the one in FocusScopeNode.traversalDescendants in terms of which nodes it + // finds. assert( sortedDescendants.length <= scope.traversalDescendants.length && sortedDescendants.toSet().difference(scope.traversalDescendants.toSet()).isEmpty, - 'sorted descendants contains more nodes than it should: (${sortedDescendants.toSet().difference(scope.traversalDescendants.toSet())})' + 'Sorted descendants contains different nodes than FocusScopeNode.traversalDescendants would. ' + 'These are the different nodes: ${sortedDescendants.toSet().difference(scope.traversalDescendants.toSet())}' ); return sortedDescendants; } diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index e553f8376d1..33dccddb400 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -2088,6 +2088,82 @@ void main() { expect(containerNode.hasFocus, isFalse); expect(unfocusableNode.hasFocus, isFalse); }); + testWidgets("Nested FocusTraversalGroup with unfocusable children doesn't assert.", (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(debugLabel: '1'); + final GlobalKey key2 = GlobalKey(debugLabel: '2'); + final FocusNode focusNode = FocusNode(); + bool? gotFocus; + await tester.pumpWidget( + FocusTraversalGroup( + child: Column( + children: [ + Focus( + autofocus: true, + child: Container(), + ), + FocusTraversalGroup( + descendantsAreFocusable: false, + child: Focus( + onFocusChange: (bool focused) => gotFocus = focused, + child: Focus( + key: key1, + focusNode: focusNode, + child: Container(key: key2), + ), + ), + ), + ], + ), + ), + ); + + final Element childWidget = tester.element(find.byKey(key1)); + final FocusNode unfocusableNode = Focus.of(childWidget); + final Element containerWidget = tester.element(find.byKey(key2)); + final FocusNode containerNode = Focus.of(containerWidget); + + await tester.pump(); + primaryFocus!.nextFocus(); + + expect(gotFocus, isNull); + expect(containerNode.hasFocus, isFalse); + expect(unfocusableNode.hasFocus, isFalse); + + containerNode.requestFocus(); + await tester.pump(); + + expect(gotFocus, isNull); + expect(containerNode.hasFocus, isFalse); + expect(unfocusableNode.hasFocus, isFalse); + }); + testWidgets("Empty FocusTraversalGroup doesn't cause an exception.", (WidgetTester tester) async { + final GlobalKey key = GlobalKey(debugLabel: 'Test Key'); + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + FocusTraversalGroup( + child: Directionality( + textDirection: TextDirection.rtl, + child: Column( + children: [ + FocusTraversalGroup( + child: Container(key: key), + ), + Focus( + focusNode: focusNode, + autofocus: true, + child: Container(), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + primaryFocus!.nextFocus(); + await tester.pump(); + expect(primaryFocus, equals(focusNode)); + }); }); group(RawKeyboardListener, () { testWidgets('Raw keyboard listener introduces a Semantics node by default', (WidgetTester tester) async {