鸿蒙沉浸式与安全区适配,一次讲透【鸿蒙心迹】

转鸿蒙的第一周,自我感觉良好:声明式 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. 跑起来,逐项验证

  1. Run 到模拟器或真机:头图从状态栏下缘开始(这是正常的——组件安全区方案下,未滚动时内容不进避让区);
  2. 向上滚动:内容从状态栏和小白条底下穿过,背景色 #F1F3F5 一直铺到物理屏幕边缘,白边消失;
  3. 看底部购买栏:白色背景一直延伸到屏幕物理底边,小白条"浮"在白底上,按钮完好无损,怎么点都不会误触返回;
  4. 点评论输入框:键盘弹起时整页被压缩(把 RESIZE 改回 OFFSET 再跑一次,感受"上抬"和"压缩"的差别);
  5. 状态栏文字在深色头图上清晰可读。

如果五步全过,这个页面的沉浸式就完整了——总共改了三处,各一行核心代码。

三、路线二: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 里写下了这次复盘的结论,其实就三句话:

  1. 先想清楚要不要"控件进避让区"——不要就用 expandSafeArea,一行解决;要,才开 setWindowLayoutFullScreen,并且做好自己管避让的觉悟;
  2. expandSafeArea 只扩展绘制,不扩展布局——理解了这一句,"背景延伸但按钮不被挡"就不再是玄学;
  3. px 转 vp、避让区会变、高度可能是 0——路线二的三个隐藏条款,漏一个就是线上事故。

从 Android 的 fitsSystemWindows 到 ArkUI 的 expandSafeArea,变的不是 API,是思路:前者是"我布局错了,系统帮我修正",后者是"系统先保证你不出错,你想突破安全区,明说"。说实话,我更喜欢后者——把默认做对,把突破留成显式声明,这大概就是声明式范式教给我最有价值的一课。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐