深色模式
Flutter状态栏与导航栏设置
概述
Flutter 里的 system UI,主要指状态栏、底部导航栏,以及 iOS 上对应的状态栏和 home indicator 区域。相关配置其实分成两件事:一是系统栏显不显示、内容要不要延伸到系统栏下面;二是系统栏图标和背景用什么样式。
这部分 API 虽然都放在 SystemChrome 里,但 Flutter 本身并不直接绘制状态栏和导航栏。Flutter 负责把显示模式和样式偏好传给宿主平台,再由 Android 或 iOS 的窗口系统决定最终效果。把这一层理顺后,很多“为什么设置了没生效”或者“为什么又被覆盖了”的问题就不难解释了。
修改 system UI 的原理
Flutter 不直接画状态栏和导航栏
状态栏和导航栏属于宿主系统窗口,不属于 Flutter widget 树的一部分。Flutter 主要做两类事情:
- 设置系统栏的显示模式,比如普通显示、全屏、沉浸式、
edgeToEdge - 设置系统栏的样式,比如图标明暗、背景色、分割线颜色
SystemChrome.setEnabledSystemUIMode() 的实现会通过 SystemChannels.platform.invokeMethod() 往平台通道发送 SystemChrome.setEnabledSystemUIMode 或 SystemChrome.setEnabledSystemUIOverlays。也就是说,Flutter 只是把配置下发到原生层,真正生效的是 Android 和 iOS 的窗口系统。
SystemChrome.setSystemUIOverlayStyle() 也是同样的思路,最后会把 SystemUiOverlayStyle 发给宿主平台。
命令式设置和声明式设置
Flutter 改 system UI,有两条路径:
- 命令式:直接调用
SystemChrome.setEnabledSystemUIMode()、SystemChrome.setSystemUIOverlayStyle() - 声明式:在 widget 树里放
AnnotatedRegion<SystemUiOverlayStyle>
SystemChrome.setSystemUIOverlayStyle() 有一个容易忽略的细节:Flutter 会把这次更新放进 microtask,同一轮事件循环里如果连续调用多次,只有最后一次会生效。
AnnotatedRegion<SystemUiOverlayStyle> 则是另一套机制。Flutter 在 RenderView.automaticSystemUiAdjustment 为 true 时,会在每一帧命中测试 layer tree 顶部和底部的 AnnotatedRegionLayer<SystemUiOverlayStyle>,顶部结果用于状态栏,底部结果用于导航栏,再合成最终样式。
AppBar 就是靠这套机制自动设置 system UI 的,所以:
- 页面里有
AppBar时,优先使用AppBar.systemOverlayStyle - 没有
AppBar时,再考虑AnnotatedRegion<SystemUiOverlayStyle> - 不要一边用
AppBar,一边再在外层包一层自己的AnnotatedRegion去抢样式 - 直接在
build()里频繁调用SystemChrome.setSystemUIOverlayStyle(),很容易被当前页面的 layer tree 覆盖
先分清两类配置
显示模式
SystemChrome.setEnabledSystemUIMode() 控制的是“系统栏怎么显示”。
dart
Future<void> SystemChrome.setEnabledSystemUIMode(
SystemUiMode mode, {
List<SystemUiOverlay>? overlays,
})常用的 SystemUiMode:
edgeToEdge:内容延伸到状态栏和导航栏下面,系统栏覆盖在应用内容之上manual:手动指定显示哪些系统栏,要配合overlays使用leanBack:临时全屏,点击屏幕后系统栏重新出现immersive:沉浸式,边缘滑动可临时拉出系统栏immersiveSticky:更接近视频或游戏场景的沉浸式,系统栏短暂出现后会自动回去
manual 模式下,overlays 取值是 SystemUiOverlay.top 和 SystemUiOverlay.bottom:
dart
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: const [SystemUiOverlay.top, SystemUiOverlay.bottom],
);常见组合:
[]:状态栏和导航栏都隐藏[SystemUiOverlay.top]:只显示状态栏[SystemUiOverlay.bottom]:只显示导航栏[SystemUiOverlay.top, SystemUiOverlay.bottom]:都显示
显示样式
SystemChrome.setSystemUIOverlayStyle() 控制的是“系统栏长什么样”。
dart
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);常用字段:
statusBarColor:状态栏背景色,主要影响 AndroidstatusBarIconBrightness:状态栏图标明暗,主要影响 AndroidstatusBarBrightness:状态栏明暗,只有 iOS 使用systemNavigationBarColor:导航栏背景色,AndroidsystemNavigationBarIconBrightness:导航栏图标明暗,AndroidsystemNavigationBarDividerColor:导航栏分割线颜色,AndroidsystemNavigationBarContrastEnforced:透明导航栏时是否让系统补一层对比度背景systemStatusBarContrastEnforced:透明状态栏时是否让系统补一层对比度背景
如果只是想快速切换深色图标和浅色图标,也可以直接使用 SystemUiOverlayStyle.dark 和 SystemUiOverlayStyle.light:
SystemUiOverlayStyle.dark:深色图标,适合浅色背景SystemUiOverlayStyle.light:浅色图标,适合深色背景
Android 与 iOS 的字段差异
显示样式这一层,Android 和 iOS 支持的字段并不对等:
statusBarIconBrightness只在 Android 生效statusBarBrightness只在 iOS 生效systemNavigationBarColor、systemNavigationBarIconBrightness、systemNavigationBarDividerColor基本都属于 Android 范围- iOS 如果使用
CupertinoNavigationBar,更常见的配置入口是backgroundColor和brightness
官方文档也明确提到,如果某个平台不支持某个样式字段,设置了也不会有任何效果。所以跨平台配置时,不要期待“同一组字段在两端完全等价”,更现实的目标是让顶部和底部区域的视觉结果尽量一致。
Android 与 iOS 上尽量保持一致
想让 Android 和 iOS 的 system UI 看起来一致,通常不要把重点放在“系统栏背景色必须完全由系统栏自己来画”,而是放在这三件事上:
- 顶部背景视觉一致
- 状态栏图标和文字对比度一致
- 底部区域分别符合 Android 和 iOS 的平台习惯
现在更稳的做法,一般是透明状态栏配合页面自己铺背景。这样即使在 Android 15+ 的 edgeToEdge 模式下,也更容易保持一致。
浅色顶部背景可以这样配:
dart
const lightSystemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
);深色顶部背景可以这样配:
dart
const darkSystemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
);这里有一个容易绕进去的点:Android 主要看 statusBarIconBrightness,iOS 主要看 statusBarBrightness。实践里,通常把 statusBarBrightness 和顶部背景亮度对应起来会更稳:浅色背景用 Brightness.light,深色背景用 Brightness.dark。这不是“字段完全同义”,而是一种更容易得到一致视觉结果的配置经验。
配置方法
有 AppBar 的页面
有 AppBar 时,最直接的写法是配 AppBar.systemOverlayStyle:
dart
return Scaffold(
appBar: AppBar(
title: const Text('详情页'),
backgroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
body: const SizedBox(),
);需要细调时,用 copyWith():
dart
return Scaffold(
appBar: AppBar(
title: const Text('详情页'),
backgroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
),
body: const SizedBox(),
);如果多个页面的 AppBar 风格一致,可以统一写到 ThemeData.appBarTheme:
dart
MaterialApp(
theme: ThemeData(
appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
),
home: const HomePage(),
);有 CupertinoNavigationBar 的页面
iOS 风格页面如果使用 CupertinoNavigationBar,更合适的做法是直接配置它自己的 backgroundColor 和 brightness:
dart
return CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('详情页'),
backgroundColor: CupertinoColors.white,
brightness: Brightness.light,
),
child: const SizedBox.expand(),
);如果 brightness 不写,Flutter 会根据 backgroundColor 的亮度自动推断一个值。实际项目里,通常让 brightness 和导航栏背景亮度保持一致:浅色背景用 Brightness.light,深色背景用 Brightness.dark。这样更容易让状态栏内容和背景保持足够对比度。
没有 AppBar 的页面
全屏图片页、视频页、自定义沉浸式头图页,通常没有标准 AppBar。这时用 AnnotatedRegion<SystemUiOverlayStyle> 更合适:
dart
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.black,
),
child: Scaffold(
backgroundColor: Colors.black,
body: const SizedBox.expand(),
),
);这种写法的好处是样式跟着页面结构走,不需要在页面切换时手动补一堆 SystemChrome 调用。
应用启动时设置全局默认值
如果整个应用默认就是沉浸式布局,或者大部分页面都希望使用同一套 system UI,可以在 main() 里先设置一次:
dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
),
);
runApp(const App());
}这类配置适合做应用级默认值。后续某个具体页面如果用了 AppBar.systemOverlayStyle 或 AnnotatedRegion<SystemUiOverlayStyle>,会按页面自己的设置覆盖全局默认值。
隐藏状态栏和导航栏
完全隐藏系统栏,可以用 manual:
dart
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: const [],
);恢复显示:
dart
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);如果要做视频播放页或阅读页那种全屏沉浸式,也可以使用:
dart
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);不过这里有一个平台限制:Android 弹出键盘时,系统会临时把导航栏和状态栏显示出来。键盘收起后,Android 不会立刻恢复之前的全屏状态,官方文档说明至少要等 1 秒后再重新设置一次。
同时使用命令式和声明式时的注意点
项目里同时使用命令式和声明式控制并不罕见,但最好先分工:
- 命令式适合应用级默认值,比如在
main()里设置默认的SystemUiMode或默认样式 - 声明式适合页面级覆盖,比如
AppBar.systemOverlayStyle、CupertinoNavigationBar.brightness或AnnotatedRegion<SystemUiOverlayStyle>
这样分工最稳,页面切换时也更容易维护。
还要注意几个常见坑:
- 不要在
build()里频繁调用SystemChrome.setSystemUIOverlayStyle()。这个 API 会把更新放进 microtask,同一轮事件循环里连续调用多次,最后只有最后一次会生效。 - 页面里已经用了
AppBar时,不要再在外层包一层自己的AnnotatedRegion<SystemUiOverlayStyle>。AppBar内部本来就会自动创建AnnotatedRegion。 - 如果
AppBar外面又包了一层AnnotatedRegion,效果会被拆开:AppBar的样式优先影响状态栏,外层AnnotatedRegion的样式又可能优先影响导航栏。这种“上面一套、下面一套”的覆盖关系,排查起来很烦。 - 如果想完全走命令式控制,就不要再让声明式自动调整持续参与。官方在
RenderView.automaticSystemUiAdjustment文档里也建议,纯命令式场景应考虑关闭自动 system UI 调整,否则 layer tree 仍可能在后续 frame 里把样式覆盖回去。
edge-to-edge 下的布局处理
SystemUiMode.edgeToEdge 只解决“内容可以画到系统栏下面”,不负责帮页面避开遮挡。页面内容如果不想顶到刘海、状态栏或底部手势区域,仍然要自己处理安全区。
最常见的做法是使用 SafeArea:
dart
return Scaffold(
body: SafeArea(
child: Column(
children: const [
Text('正文内容'),
],
),
),
);如果页面背景本来就希望铺满全屏,而只有按钮、标题这些交互内容需要避开系统栏,可以让背景全屏铺开,再只给前景内容套 SafeArea。这是 edgeToEdge 页面里最常见的布局方式。
Android 15 与 Android 16 的限制
这部分最近变动比较大,最好按官方当前文档理解。
截至 Flutter 官方 breaking change 文档在 2026 年 3 月 24 日的说明:
- 从 Flutter
3.27开始,默认flutter.targetSdkVersion会让 Android 目标版本到API 35 - 目标 Android
15(API 35)时,Flutter 在 Android 上默认使用SystemUiMode.edgeToEdge - 目标 Android
16(API 36)及以上时,无法退出SystemUiMode.edgeToEdge - 在
edgeToEdge下,SystemUiOverlayStyle.statusBarColor和SystemUiOverlayStyle.systemNavigationBarColor在 Android15+上有额外限制
如果项目还想保留 Android 15 上那种非 edgeToEdge 的旧行为,官方迁移文档给出的临时方案,是在 android/app/src/main/res/values/styles.xml 和 android/app/src/main/res/values-night/styles.xml 对应主题里加入:
xml
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>这只适用于 Android 15 的过渡迁移。官方已经明确说明,Android 16 及以后不能再靠这个方式退出 edgeToEdge。所以更稳妥的方向通常不是“继续把系统栏涂成纯色挡住内容”,而是按 edgeToEdge 重新处理页面布局、对比度和安全区。
参考
- SystemChrome.setEnabledSystemUIMode
- SystemChrome.setSystemUIOverlayStyle
- SystemUiMode
- SystemUiOverlayStyle
- SystemUiOverlayStyle.statusBarBrightness
- SystemUiOverlayStyle.statusBarIconBrightness
- AppBar.systemOverlayStyle
- CupertinoNavigationBar.backgroundColor
- CupertinoNavigationBar.brightness
- RenderView.automaticSystemUiAdjustment
- Set default of SystemUiMode to edge-to-edge
