edge_to_edge_plus 0.1.0
edge_to_edge_plus: ^0.1.0 copied to clipboard
Typed Android window insets, tappableElement-aware system bar protection and diagnostics for the mandatory edge-to-edge enforcement in Android 15 and 16.
edge_to_edge_plus #
Typed Android window insets for Flutter, and a system bar background that knows the difference between gesture and three-button navigation.
void main() => runApp(
const EdgeToEdgeScope( // 1. wrap the app
child: MaterialApp(home: HomePage()),
),
);
// 2. draw a background behind the navigation bar — but only when there is one
Column(
mainAxisSize: MainAxisSize.min,
children: [
MyFooter(),
SystemBarProtection.bottom(color: footerColour),
],
)
That is the whole integration. No MainActivity.kt edits, no theme
attributes, no manifest changes.
Why this exists #
Android 15 (API 35) draws apps targeting API 35+ edge-to-edge by default.
Android 16 removed the opt-out entirely: windowOptOutEdgeToEdgeEnforcement
is deprecated and disabled for apps targeting API 36.
At the same time the tools you used to reach for stopped working.
Window.setStatusBarColor and Window.setNavigationBarColor are
deprecated on API 35+; the status bar is transparent and ignores its
colour outright, and the navigation bar colour now only reaches three-button
navigation.
Google's own answer, from "Insets handling tips for Android 15's edge-to-edge
enforcement", is to stop setting colours and draw your own view
behind the bar, sized from WindowInsets.Type#tappableElement() for the
three-button navigation bar and WindowInsets.Type#statusBars() for the status
bar. They are shipping a library that does this — for Compose. There is no
Flutter equivalent.
This package is that equivalent, and it is deliberately small.
The gap, precisely #
MediaQuery gives you padding, viewPadding, viewInsets and
systemGestureInsets — four values that have already been merged together. It
does not expose tappableElement, and that is the only correct signal for
"is there a three-button navigation bar, and how tall is it".
Here is the same Android 16 device (API 36, emulator), measured by this package in both navigation modes:
| Inset | Gesture navigation | Three-button navigation |
|---|---|---|
statusBars.top |
24.0 | 24.0 |
navigationBars.bottom |
24.0 | 48.0 |
tappableElement.bottom |
0.0 | 48.0 |
systemGestures |
L 29.7 · R 29.7 · B 32.0 | B 48.0 |
Both modes reserve space at the bottom, so navigationBars is non-zero in
both. Only three-button navigation actually swallows taps there, and only
tappableElement says so. Size your bottom chrome from navigationBars and
you get a dead band of colour under gesture navigation on half the devices in
the world.
SystemBarProtection.bottom reads tappableElement, so it renders a 48pt band
in the right-hand column and nothing at all in the left-hand one.
What Flutter already handles #
Read this before adding the dependency. If everything you need is in this table, you do not need this package.
| You need | Already in Flutter | Use |
|---|---|---|
| Keep content out from under the bars | ✅ | SafeArea |
| Merged padding / view padding / keyboard inset | ✅ | MediaQuery.paddingOf, viewPaddingOf, viewInsetsOf |
| Edge-to-edge turned on at all | ✅ since Flutter 3.27 | it is the default on Android 15+ |
| Per-screen system bar icon brightness | ✅ | AnnotatedRegion<SystemUiOverlayStyle> |
| Keyboard-avoiding layout | ✅ | Scaffold.resizeToAvoidBottomInset |
| Gesture vs three-button, measured | ❌ | SystemInsets.tappableElement |
| A background behind a system bar | ❌ | SystemBarProtection |
displayCutout / captionBar / mandatorySystemGestures |
❌ | SystemInsets |
| The nav bar's resting height while the IME is up | ❌ | navigationBarsIgnoringVisibility |
| Being told your config is silently broken | ❌ | EdgeToEdge.diagnose() |
This package is not a SafeArea replacement, not a theming system, and not
a keyboard-avoidance layer. It exposes the insets Flutter merges away, and
draws one widget with them.
Install #
dependencies:
edge_to_edge_plus: ^0.1.0
Runtime dependencies: flutter and meta — and meta is not a design
choice, it is what Pigeon's generated code imports. Nothing else.
Recipes #
A page with a solid bottom (footer, bottom bar, sticky button) #
The footer colour should continue behind a three-button navigation bar, and the system's contrast scrim should get out of the way because your own surface already provides the contrast.
EdgeToEdgeRegion.solidBottom(
backgroundBrightness: Theme.of(context).brightness,
child: Scaffold(
body: content,
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
MyFooter(),
SystemBarProtection.bottom(color: footerColour),
],
),
),
)
Under gesture navigation SystemBarProtection.bottom collapses to zero height
and the footer sits flush against the gesture strip, which is what you want.
A page whose content scrolls under the bars #
Leave the system scrim enabled: arbitrary content can end up behind the three-button bar and the icons still have to be readable.
EdgeToEdgeRegion.scrollingBottom(
backgroundBrightness: Brightness.dark,
child: Scaffold(
body: ListView(
padding: EdgeInsets.only(
bottom: EdgeToEdgeScope.insetsOf(context).systemBars.bottom,
),
children: panels,
),
),
)
A scrim under the status bar #
Fade content out under a transparent status bar instead of cutting it off:
SystemBarProtection.top(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xB3000000), Color(0x00000000)],
),
child: myScrollingContent,
)
The keyboard #
This package reports the IME inset and stops there — Scaffold already handles
avoidance. What it adds is the resting navigation bar height, so bottom
chrome does not jump when the keyboard opens:
final insets = EdgeToEdgeScope.insetsOf(context);
insets.ime.bottom; // keyboard height
insets.imeVisible; // reliable during the animation
insets.navigationBars.bottom; // 0 while the IME covers it
insets.navigationBarsIgnoringVisibility.bottom; // the stable resting height
Fixing the default MaterialApp gives you #
SystemUiOverlayStyle.light and .dark both set systemNavigationBarColor to
opaque black with light icons, and MaterialApp applies one of them from
your theme's brightness. On a light app that is a black bar with invisible
icons. Override it once:
MaterialApp(
theme: ThemeData(
appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemBarStyles.solidBottomOnLight,
),
),
)
Checking your configuration #
for (final issue in await EdgeToEdge.diagnose(context: context)) {
debugPrint('${issue.severity.name}: ${issue.title}\n${issue.explanation}');
}
Reports a dead windowOptOutEdgeToEdgeEnforcement, a targetSdk below 35,
system bar colours the OS is ignoring, and a missing EdgeToEdgeScope. Every
one of those fails silently on its own.
Seeing what is going on #
EdgeToEdgeDebugOverlay(child: MyApp())
Draws a labelled band for every inset type plus a summary card. Compiles out of
release builds entirely — the guard is kReleaseMode, a compile-time constant.
Compatibility #
| Platform | Behaviour |
|---|---|
| Android 16+ (API 36) | Full support. Edge-to-edge is enforced and cannot be opted out of for apps targeting 36. |
| Android 15 (API 35) | Full support. Enforced for apps targeting 35+; the opt-out attribute still works here. |
| Android 10–14 (API 29–34) | Full support. All inset types are reported; contrast enforcement setters work. |
| Android 7–9 (API 24–28) | Insets reported. Contrast enforcement is a no-op (the platform API is API 29+). |
| iOS, web, macOS, Windows, Linux | Safe no-op. isSupported is false, insets fall back to MediaQuery, widgets render their child unchanged. Nothing throws. |
minSdk is 24, matching the current Flutter default.
On non-Android platforms the MediaQuery fallback fills statusBars,
navigationBars, systemBars, ime and systemGestures, and leaves
tappableElement, displayCutout, mandatorySystemGestures and captionBar
at zero — because MediaQuery genuinely does not know them. The practical
effect is deliberate: SystemBarProtection draws nothing off Android, which is
the right answer on a platform with no three-button navigation bar.
This package will never recommend windowOptOutEdgeToEdgeEnforcement #
Other packages tell you to add this to res/values-v35/styles.xml:
<!-- Do not do this. -->
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
It is deprecated and disabled for apps targeting API 36 on Android 16.
Adding it buys you one release cycle and leaves you with a layout that has
never been tested against the behaviour every user will get. EdgeToEdge.diagnose()
reports it as an error when it detects the attribute on a device that is
ignoring it.
The same goes for wording: this package does not "fix" systemNavigationBarColor.
That setter is gone, and nothing can bring it back. What this package does is
draw the background yourself, correctly sized — which is the migration Google
documented.
API #
| Symbol | What it is |
|---|---|
EdgeToEdgeScope |
Wrap your app; streams insets and rebuilds dependents. Falls back to MediaQuery if you forget it. |
SystemInsets |
One EdgeInsets per WindowInsets.Type, in logical pixels. |
SystemBarProtection.top / .bottom |
A background behind a system bar, sized from statusBars / tappableElement. |
EdgeToEdgeRegion.solidBottom / .scrollingBottom / .custom |
Per-page system bar style over AnnotatedRegion. |
SystemBarStyles |
The same styles as plain constants, for AppBarTheme.systemOverlayStyle. |
SystemNavigationMode |
threeButton · twoButton · gesture · unknown. |
EdgeToEdgeCapabilities |
API level, target SDK, whether enforcement applies, whether the opt-out flag is set and honoured. |
EdgeToEdgeDiagnostics |
The silent-misconfiguration checks. |
EdgeToEdgeDebugOverlay |
The visualiser. |
EdgeToEdge |
Static, context-free accessors for one-shot reads. |
Naming note. The enum is
SystemNavigationMode, notNavigationMode: Flutter already exports aNavigationModefrommedia_query.dart, and shipping the shorter name would makeambiguous_importerrors for anyone importing both this package andpackage:flutter/material.dart.
Example app #
example/ is a five-screen demo, not a counter:
- Inset inspector — every inset type live; rotate or open the keyboard.
- Solid bottom — sticky footer with
SystemBarProtection.bottom. - Scrolling bottom — full-bleed content with a status bar gradient.
- Before / after — one switch that turns the handling off so the overlap is visible.
- Diagnostics —
EdgeToEdge.diagnose()for the device you are on.
cd example && flutter run
To see the three-button case on an emulator:
adb shell cmd overlay enable com.android.internal.systemui.navbar.threebutton
Contributing #
The Dart ↔ Android boundary is generated from pigeons/messages.dart by
Pigeon. Never hand-edit lib/src/messages.g.dart
or android/src/main/kotlin/tr/com/kerembas/edge_to_edge_plus/Messages.g.kt.
dart run pigeon --input pigeons/messages.dart && dart format .
The dart format half is required — Pigeon does not emit formatted Dart, and
CI checks both "regeneration produces no diff" and "everything is formatted".
Before opening a PR:
dart format --output=none --set-exit-if-changed .
dart analyze --fatal-infos --fatal-warnings
flutter test --coverage
(cd example/android && ./gradlew :edge_to_edge_plus:test)
DECISIONS.md records why things are the way they are — including the ones
that look wrong until you read the reason. Add an entry when you make a
non-obvious call.
License #
MIT © Kerem Baş