Scope widget inspector overlay to the selected widget's modal route (#186784)
When a widget inside a **modal route** (bottom sheet, dialog, etc.) is
selected in the widget inspector, the on-device overlay (selection box,
layout guide lines, and tooltip) was drawn across the **entire screen**,
including over the scaffold behind the modal. That made inspection
misleading while a modal was open.
This PR scopes inspector overlay behavior to the selected widget’s
**modal route**:
- **`_inspectAt`**: filters hit-test `selection.candidates` to the
active modal route scope (prefers the current route when present;
otherwise uses the smallest-area hit’s route; excludes offstage routes).
- **`_InspectorOverlayLayer`**: skips drawing overlay candidates that do
not share the selected render object’s `ModalRoute`.
- **`InspectorSelection.clearOverlayCandidates()`**: clears stale
candidates when selection is updated via tools (`setSelection`), without
clearing `current` / `currentElement`.
**Before:** selecting a sheet widget could show guide lines over
main-screen widgets behind the sheet.
**After:** overlay candidates and painted guides stay within the modal
route scope. Manual verification: `examples/inspector_overlay_repro`
(optional reviewer aid).
No DevTools changes are required; the overlay is painted in
`widget_inspector.dart`.
Fixes #186782
## Pre-launch Checklist
- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [ ] 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].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.
If you need help, consider asking for advice on the #hackers-new channel
on [Discord].
If this change needs to override an active code freeze, provide a
comment explaining why. The code freeze workflow can be overridden by
code reviewers. See pinned issues for any active code freezes with
guidance.
**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.
<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart
index 8378fc1..a27db16 100644
--- a/packages/flutter/lib/src/widgets/widget_inspector.dart
+++ b/packages/flutter/lib/src/widgets/widget_inspector.dart
@@ -36,6 +36,7 @@
import 'gesture_detector.dart';
import 'icon_data.dart';
import 'media_query.dart';
+import 'routes.dart';
import 'service_extensions.dart';
import 'view.dart';
@@ -1609,10 +1610,12 @@
bool setSelection(Object? object, [String? groupName]) {
switch (object) {
case Element() when object != selection.currentElement:
+ selection.clearCandidates();
selection.currentElement = object;
_notifyToolsOfSelection(selection.currentElement);
return true;
case RenderObject() when object != selection.current:
+ selection.clearCandidates();
selection.current = object;
_notifyToolsOfSelection(selection.current);
return true;
@@ -3010,7 +3013,7 @@
final RenderObject userRender = ignorePointer.child!;
final List<RenderObject> selected = hitTest(position, userRender);
- selection.candidates = selected;
+ selection.candidates = _filterInspectorHitCandidatesToModalRouteScope(selected);
}
void _handlePanDown(DragDownDetails event) {
@@ -3302,6 +3305,18 @@
_computeCurrent();
}
+ /// Clears [candidates] without changing [current] or [currentElement].
+ ///
+ /// Used when the selection is updated from DevTools or another tool so stale
+ /// on-device hit-test candidates are not drawn on the overlay.
+ void clearCandidates() {
+ if (_candidates.isEmpty) {
+ return;
+ }
+ _candidates = <RenderObject>[];
+ _index = 0;
+ }
+
/// Selected render object typically from the [candidates] list.
///
/// Setting [candidates] or calling [clear] resets the selection.
@@ -3313,7 +3328,7 @@
set current(RenderObject? value) {
if (_current != value) {
_current = value;
- _currentElement = (value?.debugCreator as DebugCreator?)?.element;
+ _currentElement = _elementForRenderObject(value);
notifyListeners();
}
}
@@ -3473,6 +3488,87 @@
const Color _kHighlightedRenderObjectFillColor = Color.fromARGB(128, 128, 128, 255);
const Color _kHighlightedRenderObjectBorderColor = Color.fromARGB(128, 64, 64, 128);
+Element? _elementForRenderObject(RenderObject? object) {
+ final Object? creator = object?.debugCreator;
+ if (creator is DebugCreator) {
+ return creator.element;
+ }
+ return null;
+}
+
+ModalRoute<Object?>? _modalRouteForRenderObject(RenderObject? object) {
+ final Element? element = _elementForRenderObject(object);
+ if (element == null) {
+ return null;
+ }
+ return ModalRoute.of<Object?>(element);
+}
+
+double _inspectorHitArea(RenderObject object) {
+ final Size size = object.semanticBounds.size;
+ return size.width * size.height;
+}
+
+ModalRoute<Object?>? _inspectorScopeRouteForHits(List<RenderObject> hits) {
+ for (final hit in hits) {
+ final ModalRoute<Object?>? route = _modalRouteForRenderObject(hit);
+ if (route?.isCurrent ?? false) {
+ return route;
+ }
+ }
+
+ // When multiple modal routes are hit (e.g. scaffold behind a bottom sheet),
+ // prefer the route for the most specific (smallest) target.
+ RenderObject? smallestHit;
+ double smallestArea = double.infinity;
+ for (final hit in hits) {
+ final ModalRoute<Object?>? route = _modalRouteForRenderObject(hit);
+ if (route == null) {
+ continue;
+ }
+ final double area = _inspectorHitArea(hit);
+ if (area < smallestArea) {
+ smallestArea = area;
+ smallestHit = hit;
+ }
+ }
+ if (smallestHit != null) {
+ return _modalRouteForRenderObject(smallestHit);
+ }
+
+ return _modalRouteForRenderObject(hits.first);
+}
+
+List<RenderObject> _filterInspectorHitCandidatesToModalRouteScope(List<RenderObject> hits) {
+ if (hits.isEmpty) {
+ return hits;
+ }
+
+ // Ignore widgets that belong to offstage modal routes.
+ final List<RenderObject> onstageHits = hits
+ .where((RenderObject hit) {
+ final ModalRoute<Object?>? route = _modalRouteForRenderObject(hit);
+ return route == null || !route.offstage;
+ })
+ .toList();
+ if (onstageHits.isEmpty) {
+ return onstageHits;
+ }
+
+ final ModalRoute<Object?>? scopeRoute = _inspectorScopeRouteForHits(onstageHits);
+ final List<RenderObject> scopedHits = onstageHits
+ .where(
+ (RenderObject hit) =>
+ identical(_modalRouteForRenderObject(hit), scopeRoute),
+ )
+ .toList();
+
+ scopedHits.sort(
+ (RenderObject a, RenderObject b) => _inspectorHitArea(a).compareTo(_inspectorHitArea(b)),
+ );
+ return scopedHits;
+}
+
/// A layer that outlines the selected [RenderObject] and candidate render
/// objects that also match the last pointer location.
///
@@ -3545,7 +3641,11 @@
for (final RenderObject candidate in selection.candidates) {
if (candidate == selected ||
!candidate.attached ||
- !_isInInspectorRenderObjectTree(candidate)) {
+ !_isInInspectorRenderObjectTree(candidate) ||
+ !identical(
+ _modalRouteForRenderObject(candidate),
+ _modalRouteForRenderObject(selected),
+ )) {
continue;
}
candidates.add(_TransformedRect(candidate, rootRenderObject));
diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart
index 7b588cb..aa7a25f 100644
--- a/packages/flutter/test/widgets/widget_inspector_test.dart
+++ b/packages/flutter/test/widgets/widget_inspector_test.dart
@@ -3228,6 +3228,202 @@
selection.currentElement = elementA;
expect(count, equals(5));
});
+
+ testWidgets('clearCandidates preserves current selection', (
+ WidgetTester tester,
+ ) async {
+ await pumpWidgetTreeWithABC(tester);
+ final selection = InspectorSelection();
+ addTearDown(selection.dispose);
+ final RenderParagraph renderObjectA = tester.renderObject<RenderParagraph>(
+ find.text('a'),
+ );
+ final RenderParagraph renderObjectB = tester.renderObject<RenderParagraph>(
+ find.text('b'),
+ );
+
+ selection.candidates = <RenderObject>[renderObjectA, renderObjectB];
+ expect(selection.current, renderObjectA);
+
+ selection.clearCandidates();
+ expect(selection.candidates, isEmpty);
+ expect(selection.current, renderObjectA);
+ });
+ });
+
+ testWidgets('inspector selection candidates are scoped to the active modal route', (
+ WidgetTester tester,
+ ) async {
+ WidgetInspectorService.instance.selection.clear();
+
+ final GlobalKey behindKey = GlobalKey();
+ final GlobalKey sheetTextKey = GlobalKey();
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: WidgetInspector(
+ exitWidgetSelectionButtonBuilder: null,
+ moveExitWidgetSelectionButtonBuilder: null,
+ tapBehaviorButtonBuilder: null,
+ child: Navigator(
+ onGenerateRoute: (RouteSettings settings) {
+ return PageRouteBuilder<void>(
+ settings: settings,
+ pageBuilder: (BuildContext context, Animation<double> a, Animation<double> b) {
+ return Column(
+ children: <Widget>[
+ Text('behind', key: behindKey, textDirection: TextDirection.ltr),
+ GestureDetector(
+ onTap: () {
+ Navigator.of(context).push<void>(
+ PageRouteBuilder<void>(
+ pageBuilder:
+ (
+ BuildContext context,
+ Animation<double> a,
+ Animation<double> b,
+ ) {
+ return Center(
+ child: Text(
+ 'in sheet',
+ key: sheetTextKey,
+ textDirection: TextDirection.ltr,
+ ),
+ );
+ },
+ ),
+ );
+ },
+ child: const Text('open sheet', textDirection: TextDirection.ltr),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ final RenderObject behindRender = tester.renderObject<RenderObject>(find.byKey(behindKey));
+
+ await tester.tap(find.text('open sheet'), warnIfMissed: false);
+ await tester.pumpAndSettle();
+
+ WidgetInspectorService.instance.isSelectMode = true;
+ await tester.tap(find.byKey(sheetTextKey), warnIfMissed: false);
+ await tester.pump();
+ final List<RenderObject> candidates = WidgetInspectorService.instance.selection.candidates;
+ expect(candidates, isNot(contains(behindRender)));
+
+ final RenderObject sheetRender = tester.renderObject<RenderObject>(
+ find.byKey(sheetTextKey),
+ );
+ expect(candidates, contains(sheetRender));
+ });
+
+ testWidgets('inspector selection scopes to nested navigator inside overlay route', (
+ WidgetTester tester,
+ ) async {
+ WidgetInspectorService.instance.selection.clear();
+
+ final GlobalKey innerTextKey = GlobalKey();
+
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: WidgetInspector(
+ exitWidgetSelectionButtonBuilder: null,
+ moveExitWidgetSelectionButtonBuilder: null,
+ tapBehaviorButtonBuilder: null,
+ child: Navigator(
+ onGenerateRoute: (RouteSettings settings) {
+ return PageRouteBuilder<void>(
+ settings: settings,
+ pageBuilder: (BuildContext context, Animation<double> a, Animation<double> b) {
+ return Column(
+ children: <Widget>[
+ GestureDetector(
+ onTap: () {
+ Navigator.of(context).push<void>(
+ PageRouteBuilder<void>(
+ pageBuilder:
+ (
+ BuildContext overlayContext,
+ Animation<double> a,
+ Animation<double> b,
+ ) {
+ return SizedBox(
+ width: 300,
+ height: 300,
+ child: Navigator(
+ onGenerateRoute: (RouteSettings settings) {
+ return PageRouteBuilder<void>(
+ settings: settings,
+ pageBuilder:
+ (
+ BuildContext navContext,
+ Animation<double> a,
+ Animation<double> b,
+ ) {
+ return Center(
+ child: Text(
+ 'nested inner',
+ key: innerTextKey,
+ textDirection: TextDirection.ltr,
+ ),
+ );
+ },
+ );
+ },
+ ),
+ );
+ },
+ ),
+ );
+ },
+ child: const Text('open overlay', textDirection: TextDirection.ltr),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('open overlay'), warnIfMissed: false);
+ await tester.pumpAndSettle();
+
+ WidgetInspectorService.instance.isSelectMode = true;
+ await tester.tap(find.byKey(innerTextKey), warnIfMissed: false);
+ await tester.pump();
+
+ final RenderObject innerRender = tester.renderObject<RenderObject>(
+ find.byKey(innerTextKey),
+ );
+ final List<RenderObject> candidates =
+ WidgetInspectorService.instance.selection.candidates;
+ expect(candidates, contains(innerRender));
+ });
+
+ testWidgets('setSelection clears stale overlay candidates', (WidgetTester tester) async {
+ await pumpWidgetTreeWithABC(tester);
+ final Element elementA = findElementABC('a');
+ final Element elementB = findElementABC('b');
+ WidgetInspectorService.instance.selection.candidates = <RenderObject>[
+ elementA.renderObject!,
+ elementB.renderObject!,
+ ];
+ expect(WidgetInspectorService.instance.selection.candidates.length, 2);
+
+ WidgetInspectorService.instance.setSelection(elementA);
+ expect(WidgetInspectorService.instance.selection.candidates, isEmpty);
+ expect(WidgetInspectorService.instance.selection.currentElement, elementA);
});
test('ext.flutter.inspector.disposeGroup', () async {