一、什么是 Edge-to--Edge?
Edge-to-Edge,中文可以理解为边到边显示 或沉浸式边缘布局。
在传统 Android 应用中,状态栏和底部导航栏通常会占用固定区域,App 内容不会绘制到这些系统栏后面。
而 Edge-to-Edge 模式下,App 的内容可以延伸到整个屏幕,包括状态栏和导航栏背后区域,从视觉上看更加沉浸、现代,也更接近 iOS 的全屏体验。
简单理解:
传统模式:
状态栏
----------------
App 内容区域
----------------
导航栏
Edge-to-Edge:
App 内容从屏幕顶部一直延伸到底部
状态栏 / 导航栏浮在 App 内容之上
从 Android 15 开始,如果应用 targetSdkVersion 达到 35,系统会默认强制启用 Edge-to-Edge;Android 16 起,官方已经不再允许通过旧的 opt-out 方式退出 Edge-to-Edge。Flutter 官方也已经将 SystemUiMode 默认行为调整到 Edge-to-Edge 方向。
二、为什么 Flutter 项目必须适配 Edge-to-Edge?
以前很多 Flutter 项目可能会这样写:
dart
Scaffold(
appBar: AppBar(title: const Text('首页')),
body: PageContent(),
bottomNavigationBar: BottomNavigationBar(...),
)
在旧 Android 版本上,这样通常没问题,因为系统会自动帮你预留状态栏和导航栏区域。
但在 Android 15+ 的 Edge-to-Edge 模式下,App 内容会绘制到系统栏下面。如果没有正确处理安全区域,可能会出现:
- 顶部标题被状态栏遮挡
- 底部按钮被系统导航栏遮挡
- BottomNavigationBar 和手势导航条重叠
- 页面底部输入框、提交按钮无法点击
- 弹窗、BottomSheet 底部间距异常
- 全屏图片、视频页面沉浸式效果不一致
Android 官方明确说明,target SDK 35 或更高的应用在 Android 15+ 设备上会默认 Edge-to-Edge,开发者需要主动处理 system bars、display cutout 和 system gesture 等 inset,避免内容被系统 UI 遮挡。
三、Flutter 中如何开启 Edge-to-Edge?
在 Flutter 中,可以通过 SystemChrome.setEnabledSystemUIMode 开启 Edge-to-Edge。
一般建议在 main() 中设置:
dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
);
runApp(const MyApp());
}
如果你还需要设置状态栏和导航栏颜色,可以配合 SystemChrome.setSystemUIOverlayStyle 使用:
dart
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
完整示例:
dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
runApp(const MyApp());
}
需要注意的是,如果你的 App 已经 target Android 15 或更高版本,即使你不主动调用这段代码,在 Android 15+ 上也可能已经默认进入 Edge-to-Edge 行为。Flutter 官方说明,从 Flutter 3.27 开始,默认 target SDK 升级到 Android 15 后,项目会受到 Edge-to-Edge 默认行为影响。
四、适配核心:SafeArea 和 MediaQuery
Edge-to-Edge 的关键不是「开启」,而是「处理遮挡区域」。
Flutter 中主要有两个常用方式:
- SafeArea
- MediaQuery.padding / MediaQuery.viewPadding / MediaQuery.viewInsets
五、使用 SafeArea 处理基础页面
最简单的适配方式是使用 SafeArea。
dart
Scaffold(
body: SafeArea(
child: Column(
children: [
Text('标题'),
Expanded(
child: ListView(
children: [
// 页面内容
],
),
),
],
),
),
);
SafeArea 会自动根据设备状态栏、刘海屏、底部导航栏等区域给子组件添加合适的 padding。
适合场景:
- 普通页面
- 表单页面
- 列表页面
- 有固定顶部标题的页面
- 有底部按钮的页面
但 SafeArea 并不是所有场景都适合。例如全屏图片、视频播放、沉浸式首页 Banner 等页面,通常希望背景延伸到状态栏后面,只保护文字、按钮等关键内容即可。
六、顶部适配:状态栏区域
1. 普通页面写法
dart
Scaffold(
body: SafeArea(
bottom: false,
child: Column(
children: [
_Header(),
Expanded(child: _Content()),
],
),
),
);
这里 bottom: false 表示只处理顶部安全区域,不处理底部。
适合顶部有标题栏,但底部由其他组件单独处理的页面。
2. 自定义 AppBar 适配
如果你不用 Flutter 自带的 AppBar,而是自己写顶部导航栏,可以这样处理:
dart
class CustomAppBar extends StatelessWidget {
const CustomAppBar({super.key});
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
return Container(
padding: EdgeInsets.only(top: topPadding),
height: topPadding + 56,
alignment: Alignment.center,
child: const Text(
'首页',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
);
}
}
这样顶部高度会自动包含状态栏区域。
七、底部适配:导航栏和底部按钮
Edge-to-Edge 最容易出问题的地方通常是底部。
例如:
dart
Scaffold(
body: ListView(...),
bottomNavigationBar: Container(
height: 56,
child: Text('底部导航'),
),
);
在 Android 手势导航或三键导航下,底部区域可能会被系统导航栏遮挡。
推荐写法:
dart
class BottomActionBar extends StatelessWidget {
const BottomActionBar({super.key});
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 12,
bottom: bottomPadding + 12,
),
child: SizedBox(
height: 48,
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
child: const Text('提交'),
),
),
);
}
}
这样按钮不会贴到底部,也不会被导航栏挡住。
八、Scaffold 的推荐适配方式
1. 普通页面
dart
Scaffold(
body: SafeArea(
child: ListView(
children: const [
Text('页面内容'),
],
),
),
);
2. 顶部沉浸式 Banner 页面
适合首页、详情页、图片头图等场景。
dart
Scaffold(
extendBodyBehindAppBar: true,
body: Stack(
children: [
Image.network(
'https://example.com/banner.png',
width: double.infinity,
height: 260,
fit: BoxFit.cover,
),
SafeArea(
child: Column(
children: [
_CustomHeader(),
Expanded(child: _Content()),
],
),
),
],
),
);
这里背景图可以延伸到状态栏后面,但标题、返回按钮等关键元素仍然通过 SafeArea 保护。
3. 底部导航页面
dart
Scaffold(
body: const _HomeContent(),
bottomNavigationBar: SafeArea(
top: false,
child: BottomNavigationBar(
currentIndex: 0,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '首页',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '我的',
),
],
),
),
);
如果你的底部导航栏有自定义背景色、圆角、悬浮效果,可以用 MediaQuery.paddingOf(context).bottom 手动加底部间距。
九、输入框页面适配键盘
除了状态栏和导航栏,输入框页面还要考虑键盘弹起。
键盘高度可以通过:
MediaQuery.viewInsetsOf(context).bottom 获取。
示例:
dart
class ChatInputBar extends StatelessWidget {
const ChatInputBar({super.key});
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(
bottom: bottomInset > 0 ? bottomInset : bottomPadding,
),
child: Container(
padding: const EdgeInsets.all(12),
child: const TextField(
decoration: InputDecoration(
hintText: '请输入内容',
),
),
),
);
}
}
这里的逻辑是:
- 键盘弹出:使用 viewInsets.bottom
- 键盘收起:使用安全区 bottom padding
适合聊天页、评论页、搜索页、登录注册页。
十、BottomSheet 适配
BottomSheet 也是 Edge-to-Edge 下很容易出问题的组件。
推荐写法:
dart
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(
bottom: keyboardHeight,
),
child: Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: bottomPadding + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('底部弹窗内容'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
child: const Text('确认'),
),
],
),
),
);
},
);
如果 BottomSheet 内部有输入框,isScrollControlled: true 基本是必加的。
十一、状态栏图标颜色适配
Edge-to-Edge 下,状态栏通常是透明的,所以状态栏图标颜色要根据背景明暗调整。
浅色背景
dart
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
深色背景
dart
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
),
);
如果某些页面背景图比较复杂,建议给顶部区域增加渐变遮罩:
dart
Positioned(
top: 0,
left: 0,
right: 0,
height: 120,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.4),
Colors.transparent,
],
),
),
),
),
);
这样可以避免白色图标叠在浅色背景上看不清。
十二、Android 原生侧配置注意事项
如果项目还没有完成适配,在 Android 15 上曾经可以通过 windowOptOutEdgeToEdgeEnforcement 暂时退出 Edge-to-Edge:
xml
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
但这只是过渡方案。Android 官方已经说明,Android 16 target SDK 36 后,这个 opt-out 属性会被废弃并禁用,应用不能再退出 Edge-to-Edge。
所以不建议长期依赖这个配置。正确做法是:
- 接受 Edge-to-Edge 是未来默认行为
- 检查所有页面顶部和底部遮挡问题
- 使用 SafeArea / MediaQuery 正确处理 inset
- 特殊页面单独设计沉浸式效果
十三、常见页面适配建议
1. 普通列表页
推荐:
dart
SafeArea(
child: ListView(...),
)
2. 首页沉浸式头图
推荐:
dart
Stack(
children: [
HeaderBackground(),
SafeArea(child: HeaderContent()),
],
)
3. 底部固定按钮
推荐:
dart
final bottom = MediaQuery.paddingOf(context).bottom;
Padding(
padding: EdgeInsets.only(bottom: bottom + 16),
child: Button(),
)
4. 聊天输入框
推荐:
dart
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final bottom = MediaQuery.paddingOf(context).bottom;
paddingBottom = keyboard > 0 ? keyboard : bottom;
5. 视频/图片全屏页
推荐:
SystemUiMode.edgeToEdge + Stack + 局部 SafeArea
背景可以全屏,操作按钮需要放在 SafeArea 内。
十四、项目级封装建议
在实际 Flutter 项目中,不建议每个页面都手写 MediaQuery.paddingOf(context).bottom。
可以封装一个通用底部安全容器:
dart
class AppSafeBottom extends StatelessWidget {
const AppSafeBottom({
super.key,
required this.child,
this.minimum = 12,
});
final Widget child;
final double minimum;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Padding(
padding: EdgeInsets.only(
bottom: bottom + minimum,
),
child: child,
);
}
}
使用:
dart
AppSafeBottom(
child: ElevatedButton(
onPressed: () {},
child: const Text('提交'),
),
)
也可以封装一个页面容器:
dart
class AppPage extends StatelessWidget {
const AppPage({
super.key,
required this.child,
this.safeTop = true,
this.safeBottom = true,
});
final Widget child;
final bool safeTop;
final bool safeBottom;
@override
Widget build(BuildContext context) {
return SafeArea(
top: safeTop,
bottom: safeBottom,
child: child,
);
}
}
使用:
dart
Scaffold(
body: AppPage(
child: ListView(
children: const [
Text('内容'),
],
),
),
);
十五、适配检查清单
升级 Flutter 或 target Android 15+ 后,建议重点检查以下页面:
- 首页
- 登录页
- 聊天页
- 搜索页
- 详情页
- 图片预览页
- 视频播放页
- 有 BottomNavigationBar 的页面
- 有底部固定按钮的页面
- 有 BottomSheet 的页面
- 有 TextField 的页面
- 全屏弹窗页面
重点看这些问题:
- 顶部内容是否被状态栏遮挡
- 返回按钮是否离屏幕顶部太近
- 底部按钮是否被导航栏遮挡
- BottomNavigationBar 是否和系统手势条重叠
- 键盘弹起后输入框是否正常显示
- 深色背景下状态栏图标是否可见
- 浅色背景下状态栏图标是否可见
- Android 三键导航模式下是否正常
- Android 手势导航模式下是否正常
- 刘海屏、挖孔屏设备是否正常
Android 官方也建议开发者在 Edge-to-Edge 模式下重点处理 system bars、display cutout 和 system gesture insets,特别是可点击控件不能被系统栏遮挡。
十六、推荐最终实践方案
对于大多数 Flutter 项目,可以采用下面这套规则:
- main() 中主动设置 SystemUiMode.edgeToEdge
- 状态栏、导航栏设置透明
- 普通页面使用 SafeArea
- 沉浸式页面使用 Stack + 局部 SafeArea
- 底部按钮统一封装 SafeBottom
- 输入框页面处理 viewInsets.bottom
- BottomSheet 设置 isScrollControlled: true
- 不再长期依赖 Android opt-out 配置
- 必须测试 Android 15 / Android 16 / 手势导航 / 三键导航
入口代码:
dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
runApp(const MyApp());
}
页面代码:
dart
Scaffold(
body: SafeArea(
child: YourPageContent(),
),
);
底部按钮:
dart
class SafeBottomButton extends StatelessWidget {
const SafeBottomButton({
super.key,
required this.text,
required this.onPressed,
});
final String text;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(
16,
12,
16,
bottom + 12,
),
child: SizedBox(
height: 48,
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
child: Text(text),
),
),
);
}
}
十七、总结
Edge-to-Edge 不是一个简单的视觉效果,而是 Android 新版本下的默认布局趋势。
对于 Flutter 项目来说,适配 Edge-to-Edge 的核心不是写一行:
dart
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
而是要真正处理好:
- 顶部状态栏安全区域
- 底部导航栏安全区域
- 键盘弹起区域
- 刘海屏和挖孔屏
- 沉浸式页面和普通页面的差异
- Android 15 / Android 16 的默认行为变化
推荐尽早完成适配,而不是依赖临时 opt-out。因为从 Android 16 开始,退出 Edge-to-Edge 的旧方案已经不再可靠,Flutter 项目后续也会越来越默认地朝 Edge-to-Edge 方向演进。