Unverified Commit dbbef15a authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add Focus.parentNode to allow controlling the shape of the Focus tree. (#113655)

parent 0fe29f58
...@@ -117,6 +117,7 @@ class Focus extends StatefulWidget { ...@@ -117,6 +117,7 @@ class Focus extends StatefulWidget {
super.key, super.key,
required this.child, required this.child,
this.focusNode, this.focusNode,
this.parentNode,
this.autofocus = false, this.autofocus = false,
this.onFocusChange, this.onFocusChange,
FocusOnKeyEventCallback? onKeyEvent, FocusOnKeyEventCallback? onKeyEvent,
...@@ -144,6 +145,7 @@ class Focus extends StatefulWidget { ...@@ -144,6 +145,7 @@ class Focus extends StatefulWidget {
Key? key, Key? key,
required Widget child, required Widget child,
required FocusNode focusNode, required FocusNode focusNode,
FocusNode? parentNode,
bool autofocus, bool autofocus,
ValueChanged<bool>? onFocusChange, ValueChanged<bool>? onFocusChange,
bool includeSemantics, bool includeSemantics,
...@@ -153,6 +155,22 @@ class Focus extends StatefulWidget { ...@@ -153,6 +155,22 @@ class Focus extends StatefulWidget {
// when then widget is updated. // when then widget is updated.
bool get _usingExternalFocus => false; bool get _usingExternalFocus => false;
/// The optional parent node to use when reparenting the [focusNode] for this
/// [Focus] widget.
///
/// If [parentNode] is null, then [Focus.maybeOf] is used to find the parent
/// in the widget tree, which is typically what is desired, since it is easier
/// to reason about the focus tree if it mirrors the shape of the widget tree.
///
/// Set this property if the focus tree needs to have a different shape than
/// the widget tree. This is typically in cases where a dialog is in an
/// [Overlay] (or another part of the widget tree), and focus should
/// behave as if the widgets in the overlay are descendants of the given
/// [parentNode] for purposes of focus.
///
/// Defaults to null.
final FocusNode? parentNode;
/// The child widget of this [Focus]. /// The child widget of this [Focus].
/// ///
/// {@macro flutter.widgets.ProxyWidget.child} /// {@macro flutter.widgets.ProxyWidget.child}
...@@ -467,6 +485,7 @@ class _FocusWithExternalFocusNode extends Focus { ...@@ -467,6 +485,7 @@ class _FocusWithExternalFocusNode extends Focus {
super.key, super.key,
required super.child, required super.child,
required FocusNode super.focusNode, required FocusNode super.focusNode,
super.parentNode,
super.autofocus, super.autofocus,
super.onFocusChange, super.onFocusChange,
super.includeSemantics, super.includeSemantics,
...@@ -656,7 +675,7 @@ class _FocusState extends State<Focus> { ...@@ -656,7 +675,7 @@ class _FocusState extends State<Focus> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_focusAttachment!.reparent(); _focusAttachment!.reparent(parent: widget.parentNode);
Widget child = widget.child; Widget child = widget.child;
if (widget.includeSemantics) { if (widget.includeSemantics) {
child = Semantics( child = Semantics(
......
...@@ -9,70 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -9,70 +9,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
class TestFocus extends StatefulWidget {
const TestFocus({
super.key,
this.debugLabel,
this.name = 'a',
this.autofocus = false,
});
final String? debugLabel;
final String name;
final bool autofocus;
@override
TestFocusState createState() => TestFocusState();
}
class TestFocusState extends State<TestFocus> {
late FocusNode focusNode;
late String _label;
bool built = false;
@override
void dispose() {
focusNode.removeListener(_updateLabel);
focusNode.dispose();
super.dispose();
}
String get label => focusNode.hasFocus ? '${widget.name.toUpperCase()} FOCUSED' : widget.name.toLowerCase();
@override
void initState() {
super.initState();
focusNode = FocusNode(debugLabel: widget.debugLabel);
_label = label;
focusNode.addListener(_updateLabel);
}
void _updateLabel() {
setState(() {
_label = label;
});
}
@override
Widget build(BuildContext context) {
built = true;
return GestureDetector(
onTap: () {
FocusScope.of(context).requestFocus(focusNode);
},
child: Focus(
autofocus: widget.autofocus,
focusNode: focusNode,
debugLabel: widget.debugLabel,
child: Text(
_label,
textDirection: TextDirection.ltr,
),
),
);
}
}
void main() { void main() {
group('FocusScope', () { group('FocusScope', () {
testWidgets('Can focus', (WidgetTester tester) async { testWidgets('Can focus', (WidgetTester tester) async {
...@@ -530,6 +466,72 @@ void main() { ...@@ -530,6 +466,72 @@ void main() {
expect(find.text('A FOCUSED'), findsOneWidget); expect(find.text('A FOCUSED'), findsOneWidget);
}); });
testWidgets('Setting parentNode determines focus tree hierarchy.', (WidgetTester tester) async {
final FocusNode topNode = FocusNode(debugLabel: 'Top');
final FocusNode parentNode = FocusNode(debugLabel: 'Parent');
final FocusNode childNode = FocusNode(debugLabel: 'Child');
final FocusNode insertedNode = FocusNode(debugLabel: 'Inserted');
await tester.pumpWidget(
FocusScope(
child: Focus.withExternalFocusNode(
focusNode: topNode,
child: Column(
children: <Widget>[
Focus.withExternalFocusNode(
focusNode: parentNode,
child: const SizedBox(),
),
Focus.withExternalFocusNode(
focusNode: childNode,
parentNode: parentNode,
autofocus: true,
child: const SizedBox(),
)
],
),
),
),
);
await tester.pump();
expect(childNode.hasPrimaryFocus, isTrue);
expect(parentNode.hasFocus, isTrue);
expect(topNode.hasFocus, isTrue);
// Check that inserting a Focus in between doesn't reparent the child.
await tester.pumpWidget(
FocusScope(
child: Focus.withExternalFocusNode(
focusNode: topNode,
child: Column(
children: <Widget>[
Focus.withExternalFocusNode(
focusNode: parentNode,
child: const SizedBox(),
),
Focus.withExternalFocusNode(
focusNode: insertedNode,
child: Focus.withExternalFocusNode(
focusNode: childNode,
parentNode: parentNode,
autofocus: true,
child: const SizedBox(),
),
)
],
),
),
),
);
await tester.pump();
expect(childNode.hasPrimaryFocus, isTrue);
expect(parentNode.hasFocus, isTrue);
expect(topNode.hasFocus, isTrue);
expect(insertedNode.hasFocus, isFalse);
});
// Arguably, this isn't correct behavior, but it is what happens now. // Arguably, this isn't correct behavior, but it is what happens now.
testWidgets("Removing focused widget doesn't move focus to next widget within FocusScope", (WidgetTester tester) async { testWidgets("Removing focused widget doesn't move focus to next widget within FocusScope", (WidgetTester tester) async {
final GlobalKey<TestFocusState> keyA = GlobalKey(); final GlobalKey<TestFocusState> keyA = GlobalKey();
...@@ -2015,3 +2017,70 @@ void main() { ...@@ -2015,3 +2017,70 @@ void main() {
}); });
}); });
} }
class TestFocus extends StatefulWidget {
const TestFocus({
super.key,
this.debugLabel,
this.name = 'a',
this.autofocus = false,
this.parentNode,
});
final String? debugLabel;
final String name;
final bool autofocus;
final FocusNode? parentNode;
@override
TestFocusState createState() => TestFocusState();
}
class TestFocusState extends State<TestFocus> {
late FocusNode focusNode;
late String _label;
bool built = false;
@override
void dispose() {
focusNode.removeListener(_updateLabel);
focusNode.dispose();
super.dispose();
}
String get label => focusNode.hasFocus ? '${widget.name.toUpperCase()} FOCUSED' : widget.name.toLowerCase();
@override
void initState() {
super.initState();
focusNode = FocusNode(debugLabel: widget.debugLabel);
_label = label;
focusNode.addListener(_updateLabel);
}
void _updateLabel() {
setState(() {
_label = label;
});
}
@override
Widget build(BuildContext context) {
built = true;
return GestureDetector(
onTap: () {
FocusScope.of(context).requestFocus(focusNode);
},
child: Focus(
autofocus: widget.autofocus,
focusNode: focusNode,
parentNode: widget.parentNode,
debugLabel: widget.debugLabel,
child: Text(
_label,
textDirection: TextDirection.ltr,
),
),
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment