It changes how the barrier paints. It does not stop it existing.
I lost a genuinely embarrassing amount of time to this, so here it is in one place.
The setup
I was building a floating overlay that renders above a host app — a QA HUD, though the shape applies to any in-app overlay: a debug panel, a tutorial coach-mark layer, a persistent player bar.
The overlay needs its own Navigator. Not for navigation — because panels inside it use showDialog, DropdownButton, PopupMenuButton, tooltips and text-selection toolbars, and every one of those pushes a route or inserts an overlay entry. Without a Navigator in scope, they throw.
It also can't borrow the host app's Navigator. A debug dialog has no business appearing in the app's route history.
So: give the overlay its own Navigator, and make its route non-opaque so the app stays visible underneath.
Navigator(
onGenerateRoute: (_) => PageRouteBuilder(
opaque: false, // app stays visible ✓
barrierColor: null, // no dimming ✓
barrierDismissible: false,
transitionDuration: Duration.zero, // no animation ✓
pageBuilder: (_, __, ___) => const MyOverlay(),
),
);
Reads fine. Every knob set to "don't get in the way."
What actually happened
The app underneath became completely uninteractable. Not sluggish. Not partially blocked. Every tap, every drag, every scroll — swallowed before it reached the app.
Here's the rule nobody puts in bold:
Every
ModalRoute— andPageRouteBuilderis aModalRoute— always contributes a full-screenModalBarrierto the overlay.
opaque: falsecontrols whether routes below are kept alive and painted.
barrierColor: nullcontrols whether the barrier paints anything.Neither controls whether the barrier absorbs pointers. It always does.
An invisible, transparent, undismissible sheet of glass over the entire app. Which is precisely what a modal route is for — it's just that I didn't want a modal route.
The fix: OverlayRoute
OverlayRoute is the primitive underneath. It manages overlay entries and nothing else — no barrier, no modality, no transition machinery.
class _ShellRoute extends OverlayRoute<void> {
_ShellRoute({required this.builder});
final WidgetBuilder builder;
@override
Iterable<OverlayEntry> createOverlayEntries() => <OverlayEntry>[
OverlayEntry(builder: builder, maintainState: true),
];
}
That's the whole class.
The behaviour you get:
- The overlay paints above the app.
- Pointers fall through wherever the overlay itself paints nothing.
- Routes pushed on top of this one — a dropdown menu, a dialog — are still modal, which is correct. Those should capture input while open.
You keep the Navigator (so showDialog and friends work) and lose the barrier you never wanted.
The part that should worry you
I had roughly five hundred widget tests. Every one of them stayed green.
Not because they were bad tests. Because of what they tested. They pumped the activation detector in isolation. They pumped panels in isolation. They asserted widgets were present, that callbacks fired, that state changed.
Not one of them mounted the composed thing and then tried to tap the app behind it.
Presence assertions are structurally incapable of catching this. find.byType(MyButton) succeeds perfectly well when an invisible barrier is sitting on top of that button. The widget is there. It's just unreachable.
The regression test is almost insultingly simple, and it's the one that matters:
testWidgets('a tap reaches the app through a closed overlay', (tester) async {
var taps = 0;
await tester.pumpWidget(
MyOverlayHost(
builder: (context) => MaterialApp(
home: Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => taps++,
child: const Text('app button'),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('app button'));
await tester.pump();
expect(taps, 1,
reason: 'the overlay must not swallow pointers — if this fails, '
'the whole app is uninteractable');
});
A companion assertion is worth having too, because it catches the regression at the cause rather than the symptom:
expect(find.byType(ModalBarrier), findsNothing);
(Adjust if your host app legitimately has one — the point is to assert the count you expect, not zero by default.)
The generalisable lesson
If you build anything that renders above someone else's widget tree, you have taken on a responsibility that unit tests cannot verify for you: the app underneath must still work.
That property only shows up when the pieces are assembled. So:
- Write one test that taps through your layer to a callback in the host app. Just one. It is the highest-value test in the entire suite.
- Assert on reachability, not presence. "The widget exists" and "a user can hit it" are different claims, and only one of them is what you actually promised.
-
Be suspicious of any layer you added for a side effect. I added a
Navigatorto getshowDialogworking. The barrier came along for free, and free things are the ones nobody reviews.
Found while building Vantage, an in-app QA HUD for Flutter — MIT, source at github.com/nateshmbhat/vantage. The real fix is _VantageShellRoute, which carries a comment telling future-me never to swap it back.
This was one of three bugs that got past ~1,500 green tests. The other two — a panel under the Dynamic Island, and a plugin capturing user state in release builds — are in the longer write-up.
If you've hit the invisible-barrier thing, I'd like to hear about it — I doubt I'm the first.
Top comments (0)