Skip to content

安全区(Safe Area)

版本说明

本文档适用于 flutter-ohos 3.35.7flutter-ohos 3.41.9 鸿蒙定制版。安全区(Safe Area)是跨平台开发中必须理解的核心概念,在鸿蒙设备上有其特殊性。

什么是安全区

安全区(Safe Area)是指屏幕上不被系统 UI 元素遮挡的区域。在这个区域内,你的应用内容可以安全地显示,不用担心被遮挡或误触。

系统 UI 元素包括:

元素说明位置
状态栏显示时间、信号、电量等屏幕顶部
刘海/灵动岛前置摄像头和传感器区域屏幕顶部中央
智慧岛鸿蒙部分机型的通知/交互区域屏幕顶部中央
主页指示器底部横条,用于返回桌面屏幕底部
导航栏三键导航或手势导航区域屏幕底部
圆角屏幕屏幕边缘的圆角裁切屏幕四角

下图展示了屏幕上的不安全区域(红色)和安全区域(绿色):

安全区概览

为什么需要安全区

问题:内容被系统 UI 遮挡

如果你的应用内容从屏幕最边缘开始绘制,就会导致内容被系统 UI 元素遮挡:

没有使用 SafeArea 的问题

看,上方的文字被刘海区域遮挡,下方的按钮被主页指示器遮挡——用户既看不到完整内容,也无法点击按钮。

解决:使用 SafeArea

SafeArea 组件包裹内容后,内容会自动留出内边距,避开不安全区域:

使用 SafeArea 后的效果

安全区的工作原理

本质:就是加 Padding

SafeArea 的本质非常简单——它读取系统提供的安全区内边距,然后给你的内容加上对应的 Padding

dart
// SafeArea 的简化原理(实际源码更复杂)
Widget build(BuildContext context) {
  final padding = MediaQuery.of(context).padding; // 从系统获取安全区内边距
  return Padding(
    padding: EdgeInsets.only(
      top: padding.top,       // 顶部留出状态栏高度
      bottom: padding.bottom,  // 底部留出主页指示器高度
      left: padding.left,      // 左侧(横屏时可能有值)
      right: padding.right,    // 右侧(横屏时可能有值)
    ),
    child: widget.child,
  );
}

MediaQuery.padding 详解

安全区内边距来自 MediaQuery.padding,下图展示了各个方向的内边距值:

安全区内边距详解

关键理解

  • 竖屏时padding.leftpadding.right 通常为 0(除非曲面屏边缘)
  • 横屏时padding.leftpadding.right 会有值(刘海/灵动岛在侧边)
  • 不同设备的 padding.toppadding.bottom 值不同,不要硬编码

SafeArea 组件

构造函数

dart
SafeArea({
  Key? key,
  bool left = true,           // 是否避开左侧不安全区域
  bool top = true,            // 是否避开顶部不安全区域
  bool right = true,          // 是否避开右侧不安全区域
  bool bottom = true,         // 是否避开底部不安全区域
  EdgeInsets minimum = EdgeInsets.zero,  // 额外的最小内边距
  bool maintainBottomViewPadding = false, // 是否使用 viewPadding 替代 padding
  Widget? child,              // 子组件
})

属性速查

属性类型默认值说明
leftbooltrue是否处理左侧安全区
topbooltrue是否处理顶部安全区
rightbooltrue是否处理右侧安全区
bottombooltrue是否处理底部安全区
minimumEdgeInsetsEdgeInsets.zero额外最小内边距(与安全区取较大值)
maintainBottomViewPaddingboolfalse底部使用 viewPadding(包含键盘区域)

基础用法

1. 最简单的用法

dart
Scaffold(
  body: SafeArea(
    child: YourContent(),
  ),
)

只需要一行代码,内容就自动避开所有不安全区域。

2. 只避开顶部

如果你的页面有底部导航栏(Scaffold 自动处理),只需要避开顶部:

dart
SafeArea(
  bottom: false,  // 底部不处理,由 Scaffold 的 BottomNavigationBar 处理
  child: YourContent(),
)

3. 只避开底部

全屏地图或相机场景,顶部内容需要延伸到状态栏后面,但底部按钮需要避开主页指示器:

dart
SafeArea(
  top: false,  // 顶部不处理,让背景延伸到状态栏后面
  child: Stack(
    children: [
      MapWidget(),           // 全屏地图,延伸到状态栏后面
      Positioned(
        bottom: 0,
        left: 0,
        right: 0,
        child: SafeArea(     // 嵌套的 SafeArea,只保护底部按钮
          top: false,
          child: MapControls(),  // 底部控制按钮,避开主页指示器
        ),
      ),
    ],
  ),
)

4. 添加额外内边距

minimum 参数确保安全区内边距至少是你指定的值:

dart
SafeArea(
  minimum: const EdgeInsets.symmetric(horizontal: 16),  // 至少 16px 水平内边距
  child: YourContent(),
)
// 如果安全区内边距 > 16,用安全区的值
// 如果安全区内边距 < 16,用 16

Scaffold 与安全区的关系

很多初学者会疑惑:「Scaffold 不是已经自动处理安全区了吗?为什么还要用 SafeArea?」

答案是:Scaffold 只自动处理 AppBar 和 BottomNavigationBar 的安全区,body 中的内容不会自动避开

Scaffold 自动处理 vs 手动处理
dart
// Scaffold 会自动处理这些区域的安全区:
Scaffold(
  appBar: AppBar(...),                    // ✅ 自动避开顶部状态栏
  bottomNavigationBar: BottomNavigationBar(...), // ✅ 自动避开底部指示器
  body: Center(
    child: Text('你好'),  // ⚠ 这个文字不会被自动保护!
  ),
)

// 如果 body 内容需要全屏显示(如列表滚动到顶部),需要手动加 SafeArea
Scaffold(
  appBar: AppBar(...),
  body: SafeArea(           // 手动保护 body 内容
    child: ListView(...),
  ),
)

重要

Scaffold 的 body 在没有 appBar 时,内容会从屏幕最顶部开始!此时必须用 SafeArea 包裹。

不同平台的安全区差异

不同平台的设备有不同的不安全区域,SafeArea 会根据平台自动适配:

不同平台安全区差异
平台顶部不安全区域底部不安全区域特殊注意
iOS状态栏 + 灵动岛/刘海 (47~59px)主页指示器 (34px)横屏时左右也有内边距
Android状态栏 (24~36px)导航栏 (0~48px)部分机型有挖孔摄像头
鸿蒙状态栏 + 智慧岛 (44~56px)手势指示器 (28~34px)部分机型有智慧岛

鸿蒙特别注意

flutter-ohos 3.35.7 / 3.41.9 中,鸿蒙设备的安全区数据由系统自动提供,SafeAreaMediaQuery.padding 可以正确获取鸿蒙设备的安全区域信息。但需要注意:

  1. 部分鸿蒙机型有智慧岛功能,会额外占用顶部空间
  2. 鸿蒙设备的底部手势区域高度可能与 iOS/Android 不同
  3. 建议在鸿蒙设备上实际测试,确保安全区适配效果正确

进阶用法

1. 全屏背景 + 安全区内容

最常见的场景:背景图片延伸到全屏,但文字内容在安全区内。

dart
Stack(
  children: [
    // 全屏背景 - 不用 SafeArea
    Positioned.fill(
      child: Image.asset(
        'assets/background.jpg',
        fit: BoxFit.cover,
      ),
    ),
    // 安全内容 - 用 SafeArea
    SafeArea(
      child: Column(
        children: [
          const Text('标题', style: TextStyle(fontSize: 24)),
          const SizedBox(height: 16),
          const Text('内容在安全区内,不会被遮挡'),
        ],
      ),
    ),
  ],
)

2. 部分安全区 + 部分延伸

dart
Column(
  children: [
    // 顶部横幅 - 延伸到状态栏后面
    Container(
      height: 200,
      color: Colors.blue,
      padding: EdgeInsets.only(
        top: MediaQuery.of(context).padding.top, // 手动添加顶部内边距
      ),
      child: const Center(
        child: Text('横幅', style: TextStyle(color: Colors.white, fontSize: 24)),
      ),
    ),
    // 列表内容 - 在安全区内
    Expanded(
      child: SafeArea(
        top: false, // 顶部已经处理过了
        child: ListView(
          children: const [
            ListTile(title: Text('项目 1')),
            ListTile(title: Text('项目 2')),
          ],
        ),
      ),
    ),
  ],
)

3. SliverAppBar 中的安全区

CustomScrollView 中使用 SliverAppBar 时,SliverAppBar 会自动处理顶部安全区,但 body 内容需要注意:

dart
CustomScrollView(
  slivers: [
    SliverAppBar(
      expandedHeight: 200,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('标题'),
        background: Container(color: Colors.blue),
      ),
    ),
    // SliverAppBar 已经处理了顶部安全区
    // 但如果内容滚动到顶部后需要安全区保护,可以加 SliverPadding
    SliverPadding(
      padding: MediaQuery.of(context).removePadding(
        removeTop: true, // 顶部已由 SliverAppBar 处理
        removeBottom: false,
        context: context,
      ).padding,
      sliver: SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => ListTile(title: Text('项目 $index')),
          childCount: 20,
        ),
      ),
    ),
  ],
)

4. 横屏适配

横屏时,安全区可能出现在左侧或右侧(取决于刘海/摄像头在哪一侧):

dart
// 横屏时左右也会有安全区内边距
SafeArea(
  child: Row(
    children: [
      Expanded(child: LeftPanel()),
      Expanded(child: RightPanel()),
    ],
  ),
)

5. 使用 SliverSafeArea

CustomScrollView 中,可以使用 SliverSafeArea 来保护 sliver 子组件:

dart
CustomScrollView(
  slivers: [
    const SliverSafeArea(
      sliver: SliverList(
        delegate: SliverChildBuilderDelegate(
          (context, index) => ListTile(title: Text('项目 $index')),
          childCount: 20,
        ),
      ),
    ),
  ],
)

padding vs viewPadding vs viewInsets

理解这三个概念对处理安全区非常重要:

属性说明典型值
padding永久性系统 UI 遮挡的区域(状态栏、主页指示器等)top: 47~59, bottom: 34
viewPaddingpadding 相同,但不受键盘影响(键盘弹出时值不变)top: 47~59, bottom: 34
viewInsets临时性系统 UI 遮挡的区域(主要是软键盘键盘弹出时 bottom: 200+
dart
// 关键区别:
final mq = MediaQuery.of(context);

// 键盘未弹出时:
mq.padding.bottom         // 34(主页指示器)
mq.viewPadding.bottom     // 34(同上)
mq.viewInsets.bottom      // 0

// 键盘弹出时:
mq.padding.bottom         // 0!padding 会被键盘"挤掉"
mq.viewPadding.bottom     // 34(不变)
mq.viewInsets.bottom      // 336(键盘高度)

常见误区

SafeArea 默认使用 MediaQuery.padding。当键盘弹出时,padding.bottom 会变为 0,因为键盘已经覆盖了主页指示器区域。

如果你需要在键盘弹出时仍然保持底部安全区(例如侧边栏场景),使用:

dart
SafeArea(
  maintainBottomViewPadding: true,  // 使用 viewPadding 替代 padding
  child: YourContent(),
)

完整示例

全功能安全区示例

dart
import 'package:flutter/material.dart';

class SafeAreaDemo extends StatelessWidget {
  const SafeAreaDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // 没有 appBar,body 需要自己处理安全区
      body: SafeArea(
        child: Column(
          children: [
            // 顶部导航(自定义)
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              color: Colors.blue,
              child: const Row(
                children: [
                  Icon(Icons.arrow_back, color: Colors.white),
                  SizedBox(width: 16),
                  Text(
                    '安全区示例',
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
            ),
            // 主内容
            Expanded(
              child: ListView(
                padding: const EdgeInsets.all(16),
                children: [
                  _buildInfoCard(context),
                  const SizedBox(height: 16),
                  _buildPaddingInfo(context),
                ],
              ),
            ),
            // 底部按钮
            Container(
              padding: const EdgeInsets.all(16),
              child: ElevatedButton(
                onPressed: () {},
                style: ElevatedButton.styleFrom(
                  minimumSize: const Size(double.infinity, 48),
                ),
                child: const Text('底部按钮'),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoCard(BuildContext context) {
    final padding = MediaQuery.of(context).padding;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('当前安全区信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Text('顶部内边距: ${padding.top.toStringAsFixed(1)}'),
            Text('底部内边距: ${padding.bottom.toStringAsFixed(1)}'),
            Text('左侧内边距: ${padding.left.toStringAsFixed(1)}'),
            Text('右侧内边距: ${padding.right.toStringAsFixed(1)}'),
          ],
        ),
      ),
    );
  }

  Widget _buildPaddingInfo(BuildContext context) {
    final mq = MediaQuery.of(context);
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('padding vs viewPadding vs viewInsets', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Text('padding.bottom: ${mq.padding.bottom.toStringAsFixed(1)}'),
            Text('viewPadding.bottom: ${mq.viewPadding.bottom.toStringAsFixed(1)}'),
            Text('viewInsets.bottom: ${mq.viewInsets.bottom.toStringAsFixed(1)}'),
            const SizedBox(height: 8),
            const Text('(弹出键盘后 viewInsets.bottom 会变化)', style: TextStyle(fontSize: 12, color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

常见问题

1. 什么时候必须用 SafeArea?

以下情况必须使用 SafeArea

  • Scaffold 没有 AppBar 时,body 内容需要从顶部安全开始
  • Scaffold 没有 BottomNavigationBar 时,底部内容需要避开主页指示器
  • 使用自定义顶部/底部导航栏时
  • 全屏页面(如地图、相机、视频播放器)中有需要保护的内容

以下情况不需要(或已自动处理):

  • Scaffold 有 AppBar 时——AppBar 自动处理顶部安全区
  • Scaffold 有 BottomNavigationBar 时——底部导航栏自动处理底部安全区
  • Dialog、BottomSheet——系统自动处理安全区

2. SafeArea 应该放在哪里?

dart
// ✅ 推荐:包在 body 内容外层
Scaffold(
  body: SafeArea(
    child: YourContent(),
  ),
)

// ❌ 不推荐:包在 Scaffold 外面(会导致 AppBar 也被缩小)
SafeArea(
  child: Scaffold(
    appBar: AppBar(...),  // AppBar 自己会处理安全区,不需要 SafeArea
    body: YourContent(),
  ),
)

3. 键盘弹出时底部内容被遮挡?

dart
// 使用 maintainBottomViewPadding 保持底部安全区
SafeArea(
  maintainBottomViewPadding: true,
  child: YourContent(),
)

// 或者让 Scaffold 自动处理键盘
Scaffold(
  resizeToAvoidBottomInset: true,  // 默认就是 true
  body: SafeArea(child: YourContent()),
)

4. 鸿蒙设备上安全区值不正确?

flutter-ohos 3.35.7 / 3.41.9 中,如果遇到安全区值异常:

dart
// 1. 先打印当前安全区值,排查问题
final padding = MediaQuery.of(context).padding;
print('top: ${padding.top}, bottom: ${padding.bottom}');

// 2. 如果值异常,可以手动设置
MediaQuery(
  data: MediaQuery.of(context).copyWith(
    padding: const EdgeInsets.only(top: 56, bottom: 34), // 手动覆盖
  ),
  child: SafeArea(child: YourContent()),
)

5. 状态栏透明/沉浸式时如何处理?

dart
// 让状态栏透明,但内容仍在安全区内
SystemChrome.setSystemUIOverlayStyle(
  const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,      // 状态栏透明
    statusBarIconBrightness: Brightness.dark, // 深色图标
  ),
);

// SafeArea 仍然生效,内容不会跑到状态栏后面
Scaffold(
  extendBodyBehindAppBar: true, // body 延伸到 AppBar 后面
  appBar: AppBar(
    backgroundColor: Colors.transparent, // AppBar 也透明
    elevation: 0,
  ),
  body: SafeArea(
    top: false, // AppBar 区域已经处理了
    child: YourContent(),
  ),
)

速查表

场景方案
基本页面SafeArea(child: content)
有 AppBar 的页面AppBar 自动处理,body 不需要额外 SafeArea
全屏背景 + 安全内容Stack + 背景 + SafeArea
只保护顶部SafeArea(bottom: false, ...)
只保护底部SafeArea(top: false, ...)
额外内边距SafeArea(minimum: EdgeInsets.all(16), ...)
键盘弹出时保持底部SafeArea(maintainBottomViewPadding: true, ...)
CustomScrollViewSliverSafeArea(sliver: ...)
手动获取内边距MediaQuery.of(context).padding
鸿蒙适配SafeArea 自动适配,注意智慧岛和底部手势区

下一步

基于 Flutter 官方文档整理