Add debugPaintFocusBoxes (#188288)
Adds a new debug global, `debugPaintFocusBoxes`, to visualize the focus
tree:
* Green box: the primary focus node
* Blue boxes: focus nodes that are ancestors of the primary focus node
* Cyan boxes: focus nodes that are focusable and traversable
* Yellow boxes: focus nodes that are focusable but not traversable
* Red boxes: focus nodes that are not focusable
## Demo
```dart
void main() {
debugPaintFocusBoxes = true;
runApp(const CalendarDatePickerApp());
}
```
https://github.com/user-attachments/assets/208ba4fc-d806-4ce8-93bc-4161c0f27328
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [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].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.diff --git a/packages/flutter/lib/src/widgets/debug.dart b/packages/flutter/lib/src/widgets/debug.dart
index 3f9a117..9e252ed 100644
--- a/packages/flutter/lib/src/widgets/debug.dart
+++ b/packages/flutter/lib/src/widgets/debug.dart
@@ -7,6 +7,8 @@
/// @docImport 'package:flutter/scheduler.dart';
///
/// @docImport 'binding.dart';
+/// @docImport 'focus_manager.dart';
+/// @docImport 'focus_scope.dart';
/// @docImport 'widget_inspector.dart';
library;
@@ -175,6 +177,29 @@
/// Show banners for deprecated widgets.
bool debugHighlightDeprecatedWidgets = false;
+/// Causes each [Focus] widget to paint a box around its bounds.
+///
+/// Different colors indicate different focus states:
+///
+/// * Green: the node has primary focus.
+/// * Blue: the node is in the focus chain but does not
+/// have primary focus (i.e., it is an ancestor of the primary focus).
+/// * Cyan: the node is focusable and participates in focus traversal.
+/// * Yellow: the node skips focus traversal ([FocusNode.skipTraversal] is true)
+/// but can still receive focus directly.
+/// * Red: the node cannot receive focus ([FocusNode.canRequestFocus] is false).
+///
+/// Enabling this causes each [Focus] widget to wrap its child with a widget,
+/// which can cause state loss if the child is a stateful widget that isn't keyed.
+///
+/// This has no effect in release builds.
+///
+/// See also:
+///
+/// * [FocusNode], which manages focus for a widget subtree.
+/// * [debugFocusChanges], which logs to the console when focus changes occur.
+bool debugPaintFocusBoxes = false;
+
Key? _firstNonUniqueKey(Iterable<Widget> widgets) {
final Set<Key> keySet = HashSet<Key>();
for (final widget in widgets) {
@@ -543,7 +568,8 @@
debugPrintGlobalKeyedWidgetLifecycle ||
debugProfileBuildsEnabled ||
debugHighlightDeprecatedWidgets ||
- debugProfileBuildsEnabledUserWidgets) {
+ debugProfileBuildsEnabledUserWidgets ||
+ debugPaintFocusBoxes) {
throw FlutterError(reason);
}
return true;
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index 4aeb809..e75dda7 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -4,6 +4,8 @@
/// @docImport 'package:flutter/material.dart';
/// @docImport 'package:flutter/rendering.dart';
+///
+/// @docImport 'debug.dart';
library;
import 'dart:async';
@@ -26,6 +28,12 @@
/// Can be used to debug focus issues: each time the focus changes, the focus
/// tree will be printed and requests for focus and other focus operations will
/// be logged.
+///
+/// This has no effect in release builds.
+///
+/// See also:
+///
+/// * [debugPaintFocusBoxes], which draws boxes around focus nodes.
bool debugFocusChanges = false;
// When using _focusDebug, always call it like so:
diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart
index 661b651..98445d0 100644
--- a/packages/flutter/lib/src/widgets/focus_scope.dart
+++ b/packages/flutter/lib/src/widgets/focus_scope.dart
@@ -16,9 +16,12 @@
import 'package:flutter/foundation.dart';
import 'basic.dart';
+import 'container.dart';
+import 'debug.dart';
import 'focus_manager.dart';
import 'framework.dart';
import 'inherited_notifier.dart';
+import 'transitions.dart';
/// A widget that manages a [FocusNode] to allow keyboard focus to be given
/// to this widget and its descendants.
@@ -728,6 +731,12 @@
child: widget.child,
);
}
+ assert(() {
+ if (debugPaintFocusBoxes) {
+ child = _DebugFocusBorder(node: focusNode, child: child);
+ }
+ return true;
+ }());
return _FocusInheritedScope(node: focusNode, child: child);
}
}
@@ -896,6 +905,43 @@
}
}
+/// Wraps a child with a colored border indicating the focus state of the node.
+/// Only used when [debugPaintFocusBoxes] is true.
+class _DebugFocusBorder extends StatelessWidget {
+ const _DebugFocusBorder({required this.node, required this.child});
+
+ final FocusNode node;
+ final Widget child;
+
+ Color get _borderColor {
+ if (node.hasPrimaryFocus) {
+ return const Color(0xF000FF00);
+ } else if (node.hasFocus) {
+ return const Color(0xF00000FF);
+ } else if (!node.canRequestFocus) {
+ return const Color(0xF0FF0000);
+ } else if (node.skipTraversal) {
+ return const Color(0xF0FFFF00);
+ } else {
+ return const Color(0xF000FFFF);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ListenableBuilder(
+ listenable: node,
+ builder: (BuildContext context, _) {
+ return DecoratedBox(
+ decoration: BoxDecoration(border: Border.all(color: _borderColor, width: 3.0)),
+ position: DecorationPosition.foreground,
+ child: child,
+ );
+ },
+ );
+ }
+}
+
// The InheritedWidget for Focus and FocusScope.
class _FocusInheritedScope extends InheritedNotifier<FocusNode> {
const _FocusInheritedScope({required FocusNode node, required super.child})
diff --git a/packages/flutter/test/widgets/debug_test.dart b/packages/flutter/test/widgets/debug_test.dart
index d2e3862..a5c5bf2 100644
--- a/packages/flutter/test/widgets/debug_test.dart
+++ b/packages/flutter/test/widgets/debug_test.dart
@@ -327,4 +327,166 @@
renderObject = tester.firstRenderObject(find.byType(CompositedTransformFollower));
expect(renderObject.debugLayer?.debugCreator, isNotNull);
});
+
+ group('debugPaintFocusBoxes', () {
+ const kPrimaryFocusColor = Color(0xF000FF00);
+ const kAncestorOfPrimaryFocusColor = Color(0xF00000FF);
+ const kFocusableColor = Color(0xF000FFFF);
+ const kSkipTraversalColor = Color(0xF0FFFF00);
+ const kNotFocusableColor = Color(0xF0FF0000);
+
+ testWidgets('adds a border on each Focus widget if enabled',
+ (WidgetTester tester) async {
+ debugPaintFocusBoxes = true;
+
+ final nodePrimary = FocusNode(debugLabel: 'primary');
+ final nodeParent = FocusNode(debugLabel: 'parent');
+ final nodeFocusable = FocusNode(debugLabel: 'focusable');
+ final nodeSkipTraversal = FocusNode(debugLabel: 'skipTraversal', skipTraversal: true);
+ final nodeNotFocusable = FocusNode(debugLabel: 'notFocusable', canRequestFocus: false);
+
+ addTearDown(nodePrimary.dispose);
+ addTearDown(nodeParent.dispose);
+ addTearDown(nodeFocusable.dispose);
+ addTearDown(nodeSkipTraversal.dispose);
+ addTearDown(nodeNotFocusable.dispose);
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Column(
+ children: <Widget>[
+ Focus(
+ focusNode: nodeParent,
+ child: Focus(
+ focusNode: nodePrimary,
+ child: const SizedBox(width: 10, height: 10),
+ ),
+ ),
+ Focus(focusNode: nodeFocusable, child: const SizedBox(width: 10, height: 10)),
+ Focus(focusNode: nodeSkipTraversal, child: const SizedBox(width: 10, height: 10)),
+ Focus(focusNode: nodeNotFocusable, child: const SizedBox(width: 10, height: 10)),
+ ],
+ ),
+ ),
+ );
+
+ nodePrimary.requestFocus();
+ await tester.pumpAndSettle();
+
+ Color borderColorOf(FocusNode node) {
+ final Finder finder = find.descendant(
+ of: find.byWidgetPredicate((w) => w is Focus && w.focusNode == node),
+ matching: find.byType(DecoratedBox),
+ );
+ final DecoratedBox box = tester.widget<DecoratedBox>(finder.first);
+ return ((box.decoration as BoxDecoration).border! as Border).top.color;
+ }
+
+ expect(borderColorOf(nodePrimary), kPrimaryFocusColor);
+ expect(borderColorOf(nodeParent), kAncestorOfPrimaryFocusColor);
+ expect(borderColorOf(nodeFocusable), kFocusableColor);
+ expect(borderColorOf(nodeSkipTraversal), kSkipTraversalColor);
+ expect(borderColorOf(nodeNotFocusable), kNotFocusableColor);
+
+ debugPaintFocusBoxes = false;
+ });
+
+ testWidgets('does not add a border if disabled',
+ (WidgetTester tester) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Focus(
+ focusNode: focusNode,
+ child: const SizedBox(width: 100, height: 100),
+ ),
+ ),
+ );
+
+ expect(find.byType(DecoratedBox), findsNothing);
+ });
+
+ testWidgets('border updates when focus changes', (WidgetTester tester) async {
+ debugPaintFocusBoxes = true;
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Focus(
+ focusNode: focusNode,
+ child: const SizedBox(width: 100, height: 100),
+ ),
+ ),
+ );
+
+ Color borderColor() {
+ final Finder finder = find.descendant(
+ of: find.byWidgetPredicate((w) => w is Focus && w.focusNode == focusNode),
+ matching: find.byType(DecoratedBox),
+ );
+ final DecoratedBox box = tester.widget<DecoratedBox>(finder.first);
+ return ((box.decoration as BoxDecoration).border! as Border).top.color;
+ }
+
+ // Start unfocused: cyan.
+ expect(borderColor(), kFocusableColor);
+
+ // Gain primary focus: green.
+ focusNode.requestFocus();
+ await tester.pumpAndSettle();
+ expect(borderColor(), kPrimaryFocusColor);
+
+ // Skip traversal but primary focus: green.
+ focusNode.skipTraversal = true;
+ await tester.pumpAndSettle();
+ expect(borderColor(), kPrimaryFocusColor);
+
+ // Lose primary focus and skip traversal: yellow.
+ focusNode.unfocus();
+ await tester.pumpAndSettle();
+ expect(borderColor(), kSkipTraversalColor);
+
+ // Not focusable: red.
+ focusNode.canRequestFocus = false;
+ await tester.pumpAndSettle();
+ expect(borderColor(), kNotFocusableColor);
+
+ debugPaintFocusBoxes = false;
+ });
+
+ testWidgets('no exceptions with multiple focus states', (WidgetTester tester) async {
+ debugPaintFocusBoxes = true;
+ final primary = FocusNode();
+ final unfocusable = FocusNode(canRequestFocus: false);
+ final skipTraversalNode = FocusNode(skipTraversal: true);
+ addTearDown(primary.dispose);
+ addTearDown(unfocusable.dispose);
+ addTearDown(skipTraversalNode.dispose);
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Column(
+ children: <Widget>[
+ Focus(focusNode: primary, child: const SizedBox(width: 10, height: 10)),
+ Focus(focusNode: unfocusable, child: const SizedBox(width: 10, height: 10)),
+ Focus(focusNode: skipTraversalNode, child: const SizedBox(width: 10, height: 10)),
+ ],
+ ),
+ ),
+ );
+
+ primary.requestFocus();
+ await tester.pump();
+ expect(tester.takeException(), isNull);
+
+ debugPaintFocusBoxes = false;
+ });
+ });
}