Skip to content

Commit dbbef15

Browse files
authored
Add Focus.parentNode to allow controlling the shape of the Focus tree. (#113655)
1 parent 0fe29f5 commit dbbef15

File tree

2 files changed

+153
-65
lines changed

2 files changed

+153
-65
lines changed

packages/flutter/lib/src/widgets/focus_scope.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class Focus extends StatefulWidget {
117117
super.key,
118118
required this.child,
119119
this.focusNode,
120+
this.parentNode,
120121
this.autofocus = false,
121122
this.onFocusChange,
122123
FocusOnKeyEventCallback? onKeyEvent,
@@ -144,6 +145,7 @@ class Focus extends StatefulWidget {
144145
Key? key,
145146
required Widget child,
146147
required FocusNode focusNode,
148+
FocusNode? parentNode,
147149
bool autofocus,
148150
ValueChanged<bool>? onFocusChange,
149151
bool includeSemantics,
@@ -153,6 +155,22 @@ class Focus extends StatefulWidget {
153155
// when then widget is updated.
154156
bool get _usingExternalFocus => false;
155157

158+
/// The optional parent node to use when reparenting the [focusNode] for this
159+
/// [Focus] widget.
160+
///
161+
/// If [parentNode] is null, then [Focus.maybeOf] is used to find the parent
162+
/// in the widget tree, which is typically what is desired, since it is easier
163+
/// to reason about the focus tree if it mirrors the shape of the widget tree.
164+
///
165+
/// Set this property if the focus tree needs to have a different shape than
166+
/// the widget tree. This is typically in cases where a dialog is in an
167+
/// [Overlay] (or another part of the widget tree), and focus should
168+
/// behave as if the widgets in the overlay are descendants of the given
169+
/// [parentNode] for purposes of focus.
170+
///
171+
/// Defaults to null.
172+
final FocusNode? parentNode;
173+
156174
/// The child widget of this [Focus].
157175
///
158176
/// {@macro flutter.widgets.ProxyWidget.child}
@@ -467,6 +485,7 @@ class _FocusWithExternalFocusNode extends Focus {
467485
super.key,
468486
required super.child,
469487
required FocusNode super.focusNode,
488+
super.parentNode,
470489
super.autofocus,
471490
super.onFocusChange,
472491
super.includeSemantics,
@@ -656,7 +675,7 @@ class _FocusState extends State<Focus> {
656675

657676
@override
658677
Widget build(BuildContext context) {
659-
_focusAttachment!.reparent();
678+
_focusAttachment!.reparent(parent: widget.parentNode);
660679
Widget child = widget.child;
661680
if (widget.includeSemantics) {
662681
child = Semantics(

packages/flutter/test/widgets/focus_scope_test.dart

Lines changed: 133 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,70 +9,6 @@ import 'package:flutter_test/flutter_test.dart';
99

1010
import 'semantics_tester.dart';
1111

12-
class TestFocus extends StatefulWidget {
13-
const TestFocus({
14-
super.key,
15-
this.debugLabel,
16-
this.name = 'a',
17-
this.autofocus = false,
18-
});
19-
20-
final String? debugLabel;
21-
final String name;
22-
final bool autofocus;
23-
24-
@override
25-
TestFocusState createState() => TestFocusState();
26-
}
27-
28-
class TestFocusState extends State<TestFocus> {
29-
late FocusNode focusNode;
30-
late String _label;
31-
bool built = false;
32-
33-
@override
34-
void dispose() {
35-
focusNode.removeListener(_updateLabel);
36-
focusNode.dispose();
37-
super.dispose();
38-
}
39-
40-
String get label => focusNode.hasFocus ? '${widget.name.toUpperCase()} FOCUSED' : widget.name.toLowerCase();
41-
42-
@override
43-
void initState() {
44-
super.initState();
45-
focusNode = FocusNode(debugLabel: widget.debugLabel);
46-
_label = label;
47-
focusNode.addListener(_updateLabel);
48-
}
49-
50-
void _updateLabel() {
51-
setState(() {
52-
_label = label;
53-
});
54-
}
55-
56-
@override
57-
Widget build(BuildContext context) {
58-
built = true;
59-
return GestureDetector(
60-
onTap: () {
61-
FocusScope.of(context).requestFocus(focusNode);
62-
},
63-
child: Focus(
64-
autofocus: widget.autofocus,
65-
focusNode: focusNode,
66-
debugLabel: widget.debugLabel,
67-
child: Text(
68-
_label,
69-
textDirection: TextDirection.ltr,
70-
),
71-
),
72-
);
73-
}
74-
}
75-
7612
void main() {
7713
group('FocusScope', () {
7814
testWidgets('Can focus', (WidgetTester tester) async {
@@ -530,6 +466,72 @@ void main() {
530466
expect(find.text('A FOCUSED'), findsOneWidget);
531467
});
532468

469+
testWidgets('Setting parentNode determines focus tree hierarchy.', (WidgetTester tester) async {
470+
final FocusNode topNode = FocusNode(debugLabel: 'Top');
471+
final FocusNode parentNode = FocusNode(debugLabel: 'Parent');
472+
final FocusNode childNode = FocusNode(debugLabel: 'Child');
473+
final FocusNode insertedNode = FocusNode(debugLabel: 'Inserted');
474+
475+
await tester.pumpWidget(
476+
FocusScope(
477+
child: Focus.withExternalFocusNode(
478+
focusNode: topNode,
479+
child: Column(
480+
children: <Widget>[
481+
Focus.withExternalFocusNode(
482+
focusNode: parentNode,
483+
child: const SizedBox(),
484+
),
485+
Focus.withExternalFocusNode(
486+
focusNode: childNode,
487+
parentNode: parentNode,
488+
autofocus: true,
489+
child: const SizedBox(),
490+
)
491+
],
492+
),
493+
),
494+
),
495+
);
496+
await tester.pump();
497+
498+
expect(childNode.hasPrimaryFocus, isTrue);
499+
expect(parentNode.hasFocus, isTrue);
500+
expect(topNode.hasFocus, isTrue);
501+
502+
// Check that inserting a Focus in between doesn't reparent the child.
503+
await tester.pumpWidget(
504+
FocusScope(
505+
child: Focus.withExternalFocusNode(
506+
focusNode: topNode,
507+
child: Column(
508+
children: <Widget>[
509+
Focus.withExternalFocusNode(
510+
focusNode: parentNode,
511+
child: const SizedBox(),
512+
),
513+
Focus.withExternalFocusNode(
514+
focusNode: insertedNode,
515+
child: Focus.withExternalFocusNode(
516+
focusNode: childNode,
517+
parentNode: parentNode,
518+
autofocus: true,
519+
child: const SizedBox(),
520+
),
521+
)
522+
],
523+
),
524+
),
525+
),
526+
);
527+
await tester.pump();
528+
529+
expect(childNode.hasPrimaryFocus, isTrue);
530+
expect(parentNode.hasFocus, isTrue);
531+
expect(topNode.hasFocus, isTrue);
532+
expect(insertedNode.hasFocus, isFalse);
533+
});
534+
533535
// Arguably, this isn't correct behavior, but it is what happens now.
534536
testWidgets("Removing focused widget doesn't move focus to next widget within FocusScope", (WidgetTester tester) async {
535537
final GlobalKey<TestFocusState> keyA = GlobalKey();
@@ -2015,3 +2017,70 @@ void main() {
20152017
});
20162018
});
20172019
}
2020+
2021+
class TestFocus extends StatefulWidget {
2022+
const TestFocus({
2023+
super.key,
2024+
this.debugLabel,
2025+
this.name = 'a',
2026+
this.autofocus = false,
2027+
this.parentNode,
2028+
});
2029+
2030+
final String? debugLabel;
2031+
final String name;
2032+
final bool autofocus;
2033+
final FocusNode? parentNode;
2034+
2035+
@override
2036+
TestFocusState createState() => TestFocusState();
2037+
}
2038+
2039+
class TestFocusState extends State<TestFocus> {
2040+
late FocusNode focusNode;
2041+
late String _label;
2042+
bool built = false;
2043+
2044+
@override
2045+
void dispose() {
2046+
focusNode.removeListener(_updateLabel);
2047+
focusNode.dispose();
2048+
super.dispose();
2049+
}
2050+
2051+
String get label => focusNode.hasFocus ? '${widget.name.toUpperCase()} FOCUSED' : widget.name.toLowerCase();
2052+
2053+
@override
2054+
void initState() {
2055+
super.initState();
2056+
focusNode = FocusNode(debugLabel: widget.debugLabel);
2057+
_label = label;
2058+
focusNode.addListener(_updateLabel);
2059+
}
2060+
2061+
void _updateLabel() {
2062+
setState(() {
2063+
_label = label;
2064+
});
2065+
}
2066+
2067+
@override
2068+
Widget build(BuildContext context) {
2069+
built = true;
2070+
return GestureDetector(
2071+
onTap: () {
2072+
FocusScope.of(context).requestFocus(focusNode);
2073+
},
2074+
child: Focus(
2075+
autofocus: widget.autofocus,
2076+
focusNode: focusNode,
2077+
parentNode: widget.parentNode,
2078+
debugLabel: widget.debugLabel,
2079+
child: Text(
2080+
_label,
2081+
textDirection: TextDirection.ltr,
2082+
),
2083+
),
2084+
);
2085+
}
2086+
}

0 commit comments

Comments
 (0)