鸿蒙沉浸式与安全区适配,一次讲透【鸿蒙心迹】
标题
鸿蒙沉浸式与安全区适配,一次讲透【鸿蒙心迹】
转鸿蒙的第一周,自我感觉良好:声明式 UI 上手很快,一个商品详情页半天就搭完了。结果提测当晚 UI 走查给我连记三个问题:头图没顶进状态栏,上面一条白边丑得刺眼;底部"立即购买"被小白条(导航条)压住一角,点按钮变成退出应用;输入框一弹键盘,整个页面被顶得面目全非。
那天晚上我把官方沉浸式指南从头读到尾,才意识到自己一直在用 Android 的 fitsSystemWindows 思维硬套 ArkUI——而鸿蒙把这件事拆成了两条路线、三块区域,想通了那层,三个问题其实各有一行代码的解法。这篇把我踩过的坑和最终的完整代码一次讲清,每个 API 都对着官方文档核过,照抄就能跑。
一、先花三分钟搞懂概念(搞不懂必然踩坑)
鸿蒙把屏幕分成两块:避让区(状态栏 + 底部导航区域,导航区域可以是小白条,也可能是三键导航)和安全区(剩下的、你的 UI 默认待的地方)。
关键认知:默认情况下,你的页面根本就没有延伸到全屏。窗口背景是全屏画的,但 UI 元素被系统自动摁在安全区里布局——这就是为什么你什么都不做,头图上面也有一条"白边"(那是状态栏区域,透出的是窗口背景色)。
想做沉浸式,官方给了两条路线,先选路线再动手:
| 你的诉求 | 路线 |
|---|---|
| 只想让背景色 / 头图 / 底栏颜色延伸到状态栏和导航条底下(90% 的业务页面) | 组件安全区方案:expandSafeArea 属性,一行搞定 |
| 需要把可交互控件单独布局进状态栏/导航区(自定义下拉头、聊天页悬浮输入条等) | 窗口全屏布局方案:setWindowLayoutFullScreen(true),之后所有避让自己做 |
| 游戏 / 视频想彻底全屏 | 全屏布局 + setSpecificSystemBarEnabled 隐藏避让区 |
我当晚犯的错,就是三个问题全都在用路线二硬解——结果一个都不需要。
二、路线一(推荐):expandSafeArea,三行代码救三个坑
1. Demo 页面:一个商品详情页覆盖全部三个坑
新建工程(Empty Ability,ArkTS),放一张商品头图 goods.jpg 到 entry/src/main/resources/base/media/,然后整个替换 entry/src/main/ets/pages/Index.ets:
@Entry
@Component
struct Index {
private comments: number[] = [1, 2, 3, 4, 5, 6, 7, 8];
aboutToAppear(): void {
// 坑三的解法:键盘弹起时整页"压缩"而不是默认的"上抬",输入类页面体验更好
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
// 底层:可滚动内容
Scroll() {
Column({ space: 12 }) {
Image($r('app.media.goods')) // 深色系头图效果最明显
.width('100%')
.height(280)
.objectFit(ImageFit.Cover)
.borderRadius(16)
Column({ space: 8 }) {
Text('无线降噪耳机 Pro').fontSize(22).fontWeight(FontWeight.Bold)
Text('六周年纪念版 · 黑色').fontSize(14).fontColor('#999999')
Text('¥999').fontSize(26).fontColor('#E84026').fontWeight(FontWeight.Bold)
TextInput({ placeholder: '有想法?写条评论' })
.width('100%')
}
.width('100%')
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Start)
ForEach(this.comments, (i: number) => {
Row() {
Text(`用户评价 ${i}`).fontSize(15)
}
.width('100%')
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
}, (i: number) => i.toString())
}
.width('100%')
.padding(12)
}
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
.align(Alignment.Top)
// 坑一的解法:滚动容器的绘制区延伸到状态栏和导航条,
// 滚动时内容从它们"底下"穿过——注意属性设在滚动容器本体上
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 顶层:底部购买栏——坑二的解法
Row() {
Column({ space: 2 }) {
Text('¥999').fontSize(20).fontColor('#E84026').fontWeight(FontWeight.Bold)
Text('到手价').fontSize(11).fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
Blank()
Button('立即购买')
.width(140)
.height(44)
.fontSize(17)
.type(ButtonType.Capsule)
.backgroundColor('#E84026')
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
// 只有背景色延伸到导航条底下;按钮本体仍布局在安全区内,
// 天然不会被小白条挡住——这正是官方"底部页签场景"的标准姿势
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
}
.width('100%')
.height('100%')
}
}
2. 状态栏字色:深色头图上的最后一块拼图
头图是深色的,状态栏黑字会看不清。在 EntryAbility.ets 的 onWindowStageCreate 里(模板其余生命周期保持不变)加一段:
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
// onWindowStageCreate 内,loadContent 的回调里:
const mainWindow = windowStage.getMainWindowSync();
mainWindow.setWindowSystemBarProperties({ statusBarContentColor: '#FFFFFF' })
.catch((err: BusinessError) => {
console.error(`setWindowSystemBarProperties failed: ${err.code}`);
});
3. 跑起来,逐项验证
- Run 到模拟器或真机:头图从状态栏下缘开始(这是正常的——组件安全区方案下,未滚动时内容不进避让区);
- 向上滚动:内容从状态栏和小白条底下穿过,背景色
#F1F3F5一直铺到物理屏幕边缘,白边消失; - 看底部购买栏:白色背景一直延伸到屏幕物理底边,小白条"浮"在白底上,按钮完好无损,怎么点都不会误触返回;
- 点评论输入框:键盘弹起时整页被压缩(把
RESIZE改回OFFSET再跑一次,感受"上抬"和"压缩"的差别); - 状态栏文字在深色头图上清晰可读。
如果五步全过,这个页面的沉浸式就完整了——总共改了三处,各一行核心代码。
三、路线二:setWindowLayoutFullScreen 全手动(真需要时才用)
当产品要求"自定义标题栏直接画进状态栏区域"这种可交互控件进避让区的需求时,才走这条路。代价是:系统不再帮你避让,所有避让自己算。
EntryAbility.ets 的 onWindowStageCreate 里:
const mainWindow = windowStage.getMainWindowSync();
// 1. 全屏布局:从此系统不管避让,UI 画满整块屏幕
mainWindow.setWindowLayoutFullScreen(true);
// 2. 取初始避让区高度(注意:单位是 px!)
let area = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
AppStorage.setOrCreate('topRectHeight', area.topRect.height); // 状态栏
area = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('bottomRectHeight', area.bottomRect.height); // 导航条/三键
// 3. 动态监听:旋转、折叠屏开合、进出分屏都会变,不做这步必炸
mainWindow.on('avoidAreaChange', (data) => {
if (data.type === window.AvoidAreaType.TYPE_SYSTEM) {
AppStorage.setOrCreate('topRectHeight', data.area.topRect.height);
} else if (data.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
AppStorage.setOrCreate('bottomRectHeight', data.area.bottomRect.height);
}
});
页面里用 @StorageProp 接住,手动 padding 出安全区:
@Entry
@Component
struct Index {
@StorageProp('topRectHeight') topRectHeight: number = 0;
@StorageProp('bottomRectHeight') bottomRectHeight: number = 0;
build() {
Column() {
Text('自定义标题栏(画进状态栏区域了)')
.width('100%')
.height(56)
.textAlign(TextAlign.Center)
.fontSize(18)
.fontColor('#FFFFFF')
.backgroundColor('#0A59F7')
// ……中间内容随便放
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
// 关键:getWindowAvoidArea 返回 px,布局单位是 vp,必须转
.padding({
top: this.getUIContext().px2vp(this.topRectHeight),
bottom: this.getUIContext().px2vp(this.bottomRectHeight)
})
}
}
附带场景——游戏/播放页彻底全屏:全屏布局之上再把避让区本体藏掉,用户底部上滑可唤出:
mainWindow.setSpecificSystemBarEnabled('status', false); // 隐藏状态栏
mainWindow.setSpecificSystemBarEnabled('navigationIndicator', false); // 隐藏导航条
四、踩坑实录(每条都有官方文档背书)
坑 1:expandSafeArea 设在滚动容器里的子组件上,不生效。
父容器是滚动容器时,子组件设置后自身不会延伸。正确姿势:把属性设在滚动容器本体上(本文 demo 就是这么写的);或者用 clipContent(ContentClipMode.SAFE_AREA) 把内容裁剪区扩到避让区。非要把中间某层也设置时,从当前节点到滚动祖先之间的每一层都要设,否则滚动后可能失效。
坑 2:px 和 vp 没换算,padding 大出一截。
getWindowAvoidArea 返回的是 px,而布局系统用 vp。不调 px2vp() 直接用,状态栏高度会凭空放大 2~3 倍,页面顶部出现一条"假天空"。
坑 3:组件不贴边,extendSafeArea 静默无效。
生效条件是组件边界与安全区重合(比如状态栏高 100px,组件的 y 必须 ≤ 100)。弹窗、半模态、设了 position 的组件边界不在安全区边上,设了也没用——这种场景只能手动调坐标。
坑 4:避让区里的点击事件会被系统拦截。
把可交互控件画进状态栏/导航条区域后,那块区域的点击优先给系统响应。所以路线一才把"背景延伸、按钮留在安全区"作为默认设计——不是保守,是规则。
坑 5:固定宽高的组件只能向上/向前扩展。
设置固定宽高或 auto 时,扩展方向只支持 TOP 和 START,且尺寸不变。想四边自由延伸,用百分比宽高。
坑 6:避让区高度可能是 0。
平板全屏、状态栏隐藏等场景下 topRect.height 就是 0。逻辑里别写死"状态栏一定有高度",padding 为 0 时页面要自然贴合。
坑 7:CUTOUT(挖孔)类型默认不生效。
SafeAreaType.CUTOUT 需要在 module.json5 里添加 Metadata 配置项才会生效,否则不避让挖孔。真机有挖孔、模拟器没有,别只在模拟器上验证。
坑 8:两个状态栏字色属性打架。
statusBarContentColor 一旦设置,isStatusBarLightIcon 就无效了。统一用前者,别两个都配。
五、回头看
那晚改完,三个走查问题半小时清零。第二天我在团队 Wiki 里写下了这次复盘的结论,其实就三句话:
- 先想清楚要不要"控件进避让区"——不要就用
expandSafeArea,一行解决;要,才开setWindowLayoutFullScreen,并且做好自己管避让的觉悟; expandSafeArea只扩展绘制,不扩展布局——理解了这一句,"背景延伸但按钮不被挡"就不再是玄学;- px 转 vp、避让区会变、高度可能是 0——路线二的三个隐藏条款,漏一个就是线上事故。
从 Android 的 fitsSystemWindows 到 ArkUI 的 expandSafeArea,变的不是 API,是思路:前者是"我布局错了,系统帮我修正",后者是"系统先保证你不出错,你想突破安全区,明说"。说实话,我更喜欢后者——把默认做对,把突破留成显式声明,这大概就是声明式范式教给我最有价值的一课。
更多推荐

所有评论(0)