Starting with Android 15, applications by default use edge-to-edge mode that renders system status and navigation elements over the application. In Android 15, we still have an option to opt out of this mode with android:windowOptOutEdgeToEdgeEnforcement. This won’t be the case for Android 16 and later.
The edge-to-edge display mode will be the new standard in the Android world, as it already is in the iOS world, and even now, it gives the apps a more modern, sleek, and native look.
Supporting the edge-to-edge for gesture navigation (the small bottom notch, like on iOS) is one challenge, but ensuring the app remains fully usable and visually appealing with 2- and 3-button navigation on older Android versions presents another.
In this article, I’ll guide you through the differences in how the system navigation bar appears and is configurable in Flutter in different Android versions, how to give the users the best (and the prettiest) experience, and how Flutter allows us to set these styles with some more low-level dives.
We will make our application go from this:
To this:
NOTE: Here you can download the above graphics and view them in better quality (source LeanCode).
Disclaimer: The contents of this article were researched on various devices from various brands and system overlays, but we focus primarily on a stock Android, i.e., a Google Pixel device, when referring to some data with no specifics, e.g., when gesture navigation was introduced.
Until Android 10, there was only a 3-button navigation available. Three buttons on a black bar at the bottom of the screen. With Android 10, gesture navigation and the edge-to-edge UI display mode were introduced. With some of the updates, the bottom navigation evolved in appearance.
With edge-to-edge UI disabled in an app, the system navigation bar is opaque (usually black) and is below the app, not on top of it.
For the most modern feel of the app, I recommend always enabling edge-to-edge UI.
// In your main file or app widget. Calling it once is enough. SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
Android and Flutter documentation can be sometimes not very clear (or even straight on misleading) so let me emphasize it here: if your application targets Android 15 (targetSdkVersion is 35, or you use one from flutter.targetSdkVersion and use Flutter 3.32), then the edge-to-edge is enabled by default only if the user’s device is running Android 15 or above. For versions lower than that, you need to enable this mode manually in the code.
The above snippet won’t have any effect on Android 9 and below, as there was no edge-to-edge or gesture navigation available then.
Once the edge-to-edge is enabled, we can set the system navigation bar style.
The bottom navigation bar style can be altered with the SystemUiOverlayStyle class in Flutter.
This class holds properties related to the status bar (the bar at the top of the screen, with time, mobile reception, and notification icons): statusBarBrightness, statusBarColor, statusBarIconBrightness, systemStatusBarContrastEnforced, and the system navigation bar: systemNavigationBarColor, systemNavigationBarContrastEnforced, systemNavigationBarDividerColor, and systemNavigationBarIconBrightness.
Choosing the correct status bar values is much easier than the bottom navigation bar. There’s much less variation between Android versions there, and the status bar almost always is on top of the app bar anyway, so you have control over the color of the background, or you add some semi-transparent overlay, because you want the app bar’s title to also have a proper contrast with app bar’s background, if it would be some user-generated image content.
For the status bar, it’s enough to use one of two constants: SystemUiOverlayStyle.light and SystemUiOverlayStyle.dark. For the system navigation bar, it’s common that it appears over non-uniform backgrounds, and we need to tweak Flutter’s default to have nice edge-to-edge transparency.
Setting this style can be done on a few layers. Let me break them down for you.
The most direct way is to call SystemChrome.setSystemUIOverlayStyle static method. This imperative call is also used by the solutions mentioned below. Using this method directly is not a good idea. We usually need different styles on different screens, and Flutter calls this method every frame (how and why below), so it might override what we have set.
If you use MaterialApp or CupertinoApp, this method is called in the builder depending on the ThemeData.brightness and CupertinoThemeData.brightness, respectively (source and source).
An AnnotatedRegion class is a widget in the Flutter framework that allows us to annotate some part of the screen (with an annotated layer in the layer tree, specifically).
Every frame, “Flutter will hit-test the layer tree at the top and bottom of the screen (specifically: the center of the top and bottom unsafe rectangles) on each frame looking for an AnnotatedRegionLayer with an instance of a SystemUiOverlayStyle. The hit-test result from the top of the screen provides the status bar settings and the hit-test result from the bottom of the screen provides the system nav bar settings. If there is no AnnotatedRegionLayer on the bottom, the hit-test result from the top provides the system nav bar settings. If there is no AnnotatedRegionLayer on the top, the hit-test result from the bottom provides the system status bar settings.” (source from Flutter source code, applying styles source).
On a sidenote, the above behavior can be turned off by setting automaticSystemUiAdjustment to false.
The aforementioned strategy is used by Flutter to provide some defaults in Material’s AppBar and in Cupertino’s showCupertinoSheet and CupertinoNavigationBar (source, source, and source).
Styles to the Material AppBar can be set globally in AppBarTheme.systemOverlayStyle. But as written previously, the system navigation bar may need other tweaking for different screens than the status bar. As we already know, the annotated layers are hit-tested every frame, at the top and the bottom. If we set the AppBar.systemOverlayStyle we still can wrap our whole Scaffold (or any widget for the whole page, or just the bottom portion, Scaffold.body, etc.) with another AnnotatedRegion that will set the system bottom navigation bar style.
Flutter uses both strategies. What are the defaults, then, if we didn’t alter the styles ourselves?
MaterialApp and CupertinoApp set the style to SystemUiOverlayStyle.light or .dark, depending on the theme’s brightness. These two constants differ in status bar brightness, but the system navigation bar styles are the same: systemNavigationBarColor is black, and systemNavigationBarIconBrightness is light.
These defaults make the system navigation bar opaque (except for Android 15 and up, where it always is at least semi-transparent), which is the opposite of what we want to achieve.
If the user is using the gesture navigation, then having a correct and pretty effect is very easy, we just make the system navigation bar background transparent, and the notch will automatically change color depending on the background on which it appears to have a proper contrast, like on iOS. If the user is using the 2- or 3-button navigation, then it gets trickier.
How to achieve it then?
I’ve researched how particular fields of the SystemUiOverlayStyle have an effect on the appearance of the system navigation bar on different Android devices. The tests assumed targeting Android 15 (API 35), Flutter 3.32.4 (latest as of writing this article). I skipped the navigation bar divider color, as it’s deprecated in Android now and not vital for the user’s experience. The table below is 100% accurate for Pixel devices on clean Android. Testing on a few other devices of different brands showed the same or very similar results. All tests with edge-to-edge enabled (except if not available).
One exception was a Xiaomi device on Android 10, which, with icon brightness light, always made the bottom navbar background color transparent (and icons light).
NOTE: Here you can download the above table and view it in better quality (source LeanCode).
Above is a pretty complex table that explains how the SystemUiOverlayStyle fields control how the system navigation bar looks depending on the Android version. Below is my recommended approach and the tl;dr of the whole article, basically.
I see two types of pages in the application that should be addressed differently: pages with static content at the bottom of the screen and those without it.
I see two types of pages in the application that should be addressed differently. Depending on what appears at the bottom edge of the page:
If a page in the app has a solid background at the bottom, like a sticky footer with a button to submit a form, a bottom navigation bar (Flutter one, not OS one), bottom app bar, or if the page is “full screen”, i.e. it does not scroll and would never have content below the system navigation bar - the system navigation bar should have no background color and have a brightness that would contrast with the page’s background.
SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: switch (themeBackgroundBrightness) {
Brightness.light => Brightness.dark,
Brightness.dark => Brightnesss.light,
},
systemNavigationBarContrastEnforced: false,
);
The systemNavigationBarIconBrightness should be of the opposite brightness to the background of the page (or the bottom area of the page). If the background color of the page comes from your theme, the passed value should just be the opposite of your theme’s brightness. If it’s something else, like a static asset, it should be set to a brightness that would contrast with it, e.g., if the background image is very dark at the bottom, the brightness passed should be light. This only applies to Android 14 (API 34) and below.
On Android 15 (API 35) icon color will slightly vary depending on whether the phone is in dark theme, if the app’s theme (its colors and appearance in general) and the phone’s dark theme settings won’t match, the contrast between the icons and the background will be lower, like in the table below.
If a page has no static/sticky element at the bottom of the screen, it’s better to use the system navigation bar with some background, so that it will always contrast with whatever it’s on top of. For this, we would need to know what the brightness of the background we draw on is, so that the system navigation bar background matches the theme of our app.
final themeBackgroundBrightness = ...;
SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: switch (themeBackgroundBrightness) {
Brightness.light => Brightness.dark,
Brightness.dark => Brightnesss.light,
},
systemNavigationBarContrastEnforced: true,
);
The themeBackgroundBrightness in the above examples is just the brightness of your theme. It would be Theme.brightnessOf(context) if using Material.
Wrap your scrollable Scaffolds in an AnnotatedRegion with the style for #2 Pages with a non-solid background at the bottom. Wrap your BottomAppBars, BottomNavigationBars, sticky buttons, or other widgets that you have at the bottom of the screen (including the bottom unsafe area) with an AnnotatedRegion for #1 Pages with a solid background at the bottom.
If you have some kind of design system, or a few common widgets that wrap other widgets from the Flutter framework, it’s good to wrap the widgets mentioned above to accommodate these annotations.
Below are a few fragments of the code from one of our production apps:
class DSScaffold extends StatefulWidget {
final Widget? stickyButton;
final ImageProvider? backgroundImage;
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollMetricsNotification>(
child: AnnotatedRegion(
value: context.colorTheme.bottomSystemUiOverlayStyleOverScrolled,
// ...
bottomNavigationBar: switch (widget.stickyButton) {
final stickyButton? => DSStickyButton(
// ...
backgroundBrightness: backgroundImage != null
// Our background images are always dark
? Brightness.dark
: null,
button: stickyButton,
),
},
}
@internal
class DSStickyButton extends StatefulWidget {
// ...
@override
Widget build(BuildContext context) {
return NotificationListener<SizeChangedLayoutNotification>(
child: AnnotatedRegion(
value: context.colorTheme.bottomSystemUiOverlayStyleOnBackground
.copyWith(
systemNavigationBarIconBrightness:
switch (widget.backgroundBrightness) {
Brightness.dark => Brightness.light,
Brightness.light => Brightness.dark,
_ => null,
},
),
// ...
}
}
class DSBottomNavigationBar<T> extends StatelessWidget {
// ...
@override
Widget build(BuildContext context) {
return AnnotatedRegion(
value: context.colorTheme.bottomSystemUiOverlayStyleOnBackground,
// ...
}
}
And the context.colorTheme fragment:
class DSColorTheme {
// ...
/// System UI overlay style for system navigation bar when it's on top of
/// some more-or-less static background, not other content while e.g. scrolling.
SystemUiOverlayStyle get bottomSystemUiOverlayStyleOnBackground {
return SystemUiOverlayStyle(
systemNavigationBarColor: transparent,
systemNavigationBarIconBrightness: switch (brightness) {
Brightness.light => Brightness.dark,
Brightness.dark => Brightness.light,
},
systemNavigationBarContrastEnforced: false,
);
}
/// System UI overlay style for system navigation bar when it's on top of
/// some dynamic contents, like scrolling.
SystemUiOverlayStyle get bottomSystemUiOverlayStyleOverScrolled {
return SystemUiOverlayStyle(
systemNavigationBarColor: transparent,
systemNavigationBarIconBrightness: switch (brightness) {
Brightness.light => Brightness.dark,
Brightness.dark => Brightness.light,
},
systemNavigationBarContrastEnforced: true,
);
}
}
After migrating to Flutter 3.32 from 3.24 in one of our production apps, WindowCompat.setDecorFitsSystemWindows(getWindow(), false) was causing issues with the native Android theme, causing a native app bar to appear.
SystemChrome.latestStyle is not reliable. It “resets” and is not a good source of current system navigation bar style-related data. But it’s marked as visible only for testing anyway, so you shouldn’t look at it.
The system navigation bar is on top of the app. It shows what’s below with semitransparency. Status bar color is always semitransparent (80%). If the color has 0 transparency, it’s changed to white. Icon brightness and status bar color have an effect only when contrast enforced is true.
Between OS’s dark and light themes, the icon color changes slightly, and if the app theme doesn’t match the OS theme, the contrast is smaller. It’s the case in other apps too (YouTube, for example).
Non-edge-to-edge (E2E) by default.
If not E2E, the system navigation bar is below the app. There is always a white background below the system navigation bar (in dark and light mode). Opaque and semitransparent colors work. Contrast enforced has no effect without E2E.
If E2E (by calling SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)) - Same as API 35, but opaque system navigation bar colors are also in effect.
Icon colors don’t change between light and dark themes within the same icon brightness (as opposed to API 35). Icon brightness can be set if the contrast enforced is false.
Just like above, but there is no edge-to-edge mode; calling SystemChrome.setEnabledSystemUiMode(SystemUiMode.edgeToEdge) does nothing.
Nothing is available, the system navigation bar is black with light icons, below the app.