目录

元服务沉浸式布局与系统安全区避让的适配方案

问题现象

在开发沉浸式的元服务过程中,开发者若选择自行设计开发导航栏(包括头部导航栏和底部页签栏),而非使用官方提供的标准控件(AtomicServiceNavigation以及AtomicServiceTabs)时,容易出现界面布局不符合UX体验标准的情况。常见问题包括:

  1. 顶部遮挡:页面顶部的自定义标题栏文字或功能图标被系统状态栏覆盖,导致无法看清或点击。
  2. 底部遮挡:页面底部的页签栏被系统底部手势导航条覆盖,导致底部按钮点击不灵敏或视觉被截断。
  3. 右上角遮挡:元服务特有的胶囊按钮常驻悬浮在应用最上层,导致自定义标题栏右侧的内容(如长标题、搜索图标、功能按钮)被胶囊遮挡,产生视觉重叠或触控热区冲突。

效果预览

背景知识

解决方案

为实现使用自定义导航栏和Tab栏并符合UX标准的元服务沉浸式体验,对于这种需要动态调整的场景推荐使用getWindowAvoidArea以及使用getBarRect方法对两个安全区进行针对性的布局适配:

  1. 顶部状态栏的避让适配:

    顶部适配需同时兼顾系统状态栏的透明沉浸效果,以及元服务胶囊的视觉平衡与交互避让。

    • 背景沉浸与高度计算:
      • 全屏布局:调用window.setWindowLayoutFullScreen(true)实现应用内容延伸至屏幕边缘。
      • 导航栏高度:由于元服务胶囊固定悬浮于右上角,自定义导航栏的总高度建议大于等于胶囊底部绝对坐标(rect.y + rect.height)。这确保了导航栏背景能完整覆盖状态栏及胶囊区域,形成一体化的头部视觉。
    • 标题与胶囊的水平对齐:按照元服务UX设计规范,标题元素均需与元服务胶囊水平中心对齐。
      • 垂直居中策略:为了让标题文字与胶囊在水平线上严格居中,将导航栏容器的上内边距(Padding-Top)设为胶囊Y坐标,并将容器对齐方式设为VerticalAlign.Center,利用布局系统自动居中。
      • 防遮挡规范:由于胶囊悬浮于右上角,自定义标题栏需预留右侧空间。根据元服务UX设计规范,应严格限制标题文本长度,避免产生视觉重叠。此外,胶囊左侧及下方热区范围内禁止放置任何功能按钮或交互组件,以防止内容被胶囊遮挡或引发触控热区冲突。
  2. 底部导航条的避让适配:
    • 获取高度:通过window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR接口获取类型为导航条的避让区域高度。根据UX标准,底部导航条通常具有固定的预留高度(如28vp)。
    • 布局调整:在底部页签栏容器的底部增加内边距(Padding-Bottom),数值等于系统导航条的高度。
    • 尺寸规范:在设计自定义底部页签栏时,除去导航条避让高度外,页签内容本身的高度应控制在40vp至70vp之间,避免过高占用屏幕或过低导致点击困难。
    • 页签数量:为保证导航简洁且符合元服务UX设计标准,底部页签数量配置最多不超过5个,最少不少于2个。

完整示例参考如下:

import { AtomicServiceBar } from '@kit.ArkUI';
import window from '@ohos.window';
import common from '@ohos.app.ability.common';
import { hilog } from '@kit.PerformanceAnalysisKit';

// ----------------配置常量----------------
const BLUE = '#0A59F7';
const BG_BOTTOM_COLOR = '#E6F0FF'; // 背景底部的浅蓝色

@Entry
@Component
struct ImmersiveMetaService {
  // ----------------状态变量----------------
  @State SYSTEM_BAR_HEIGHT: number = 0; // 顶部导航栏高度
  @State bottomSafeHeight: number = 0; // 底部导航条避让高度
  @State currentIndex: number = 0; // 当前选中的 Tab 索引
  @State barOffsetY: number = 0; // 对应 rect.y

  private topSafeHeight: number = 0; // 顶部状态栏避让高度
  private barHeight: number = 0; // 对应 rect.height
  private tabsController: TabsController = new TabsController();
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  // ----------------生命周期----------------
  aboutToAppear() {
    window.getLastWindow(this.context).then((windowClass) => {
      // 1. 设置全屏布局
      windowClass.setWindowLayoutFullScreen(true);

      // 2. 获取避让区域
      let typeSystem = window.AvoidAreaType.TYPE_SYSTEM; // 状态栏区域
      let typeNav = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR; // 底部导航区域

      this.topSafeHeight = this.getUIContext().px2vp(windowClass.getWindowAvoidArea(typeSystem).topRect.height);
      this.bottomSafeHeight = this.getUIContext().px2vp(windowClass.getWindowAvoidArea(typeNav).bottomRect.height);
      hilog.info(0x0000, 'testTag', 'avoidAreaSize: topSafeHeight = ' + this.topSafeHeight + ', avoidAreaSize: bottomSafeHeight = ' + this.bottomSafeHeight);

      // 3. 监听屏幕变化
      windowClass.on('avoidAreaChange', (data) => {
        if (data.type === window.AvoidAreaType.TYPE_SYSTEM) {
          this.topSafeHeight = this.getUIContext().px2vp(data.area.topRect.height);
        } else if (data.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
          this.bottomSafeHeight = this.getUIContext().px2vp(data.area.bottomRect.height);
        }
      });
    });
  }

  onPageShow() {
    setTimeout(() => {
      try {
        let uiContext = this.getUIContext();
        let currentBar: Nullable<AtomicServiceBar> = uiContext.getAtomicServiceBar();
        if (currentBar !== undefined) {
          let rect = currentBar.getBarRect(); // 获取元服务menuBar相对窗口的布局信息
          hilog.info(0x0000, 'testTag', 'Get AtomicServiceBar Successfully. x:' + rect.x + ' y:' + rect.y + ' width:' + rect.width + ' height:' + rect.height);
          // 保存胶囊位置信息
          this.barOffsetY = rect.y; // 胶囊距离顶部的高度
          this.barHeight = rect.height; // 胶囊自身高度
          hilog.info(0x0000, 'testTag', 'barOffsetY: ' + this.barOffsetY + 'barHeight: ' + this.barHeight);
          this.SYSTEM_BAR_HEIGHT = rect.y + rect.height; // 计算顶部导航栏高度
        }
      } catch (error) {
        console.error(`[Immersive] Failed to get AtomicServiceBar`);
      }
    }, 50);
  }

  // ----------------UI 构建器----------------

  @Builder TabItem(index: number, title: string, icon: ResourceStr) {
    Column() {
      Image(icon)
        .width(24)
        .height(24)
        .objectFit(ImageFit.Contain)
        .fillColor(this.currentIndex === index ? BLUE : Color.Gray)
        .margin({ bottom: 0 })

      Text(title)
        .fontSize(10)
        .fontColor(this.currentIndex === index ? BLUE : Color.Gray)
        .fontWeight(this.currentIndex === index ? FontWeight.Medium : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.currentIndex = index;
      this.tabsController.changeIndex(index);
    })
  }

  // ----------------主布局----------------
  build() {
    RelativeContainer() {

      // Layer 1: 背景层 (ID: bg_layer)
      Column()
        .width('100%')
        .height('100%')
        .linearGradient({
          angle: 180,
          colors: [[0xFFFFFF, 0.0], [BG_BOTTOM_COLOR, 1.0]]
        })
        .alignRules({
          top: { anchor: '__container__', align: VerticalAlign.Top },
          bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
          left: { anchor: '__container__', align: HorizontalAlign.Start },
          right: { anchor: '__container__', align: HorizontalAlign.End }
        })
        .id('bg_layer')

      // Layer 2: 主体内容区 Tabs (ID: content_layer)
      Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
        // --- Tab 1 ---
        TabContent() {
          List() {
            ListItem().height(this.SYSTEM_BAR_HEIGHT)
            ForEach([1, 2, 3, 4, 5, 6], (item: number) => {
              ListItem() {
                Column() {
                  Row().width('100%').height(150).backgroundColor('#DCEAFE').borderRadius(12)
                  Text(`元服务卡片 - ${item}`).fontSize(16).fontWeight(FontWeight.Bold).margin({ top: 12 })
                  Text('即开即用、纯净轻量、高效触达').fontSize(14).fontColor(Color.Gray).margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start).padding(16).backgroundColor(Color.White)
                .borderRadius(16).shadow({ radius: 20, color: 'rgba(0,0,0,0.05)', offsetY: 8 })
              }.margin({ left: 16, right: 16, bottom: 16 })
            })
            // 底部占位 (重要:高度 = 底部安全区 + 导航栏高度,防止最后一条显示不全)
            ListItem().height(this.bottomSafeHeight + 50)
          }
          .width('100%').height('100%').scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
        }

        // --- Tab 2 ---
        TabContent() {
          Column() {
            Button('立即创作').type(ButtonType.Capsule).backgroundColor(BLUE)
              .width(200).height(56).shadow({ radius: 20, color: 'rgba(10, 89, 247, 0.3)', offsetY: 8 })
          }.width('100%').height('100%').justifyContent(FlexAlign.Center)
        }

        // --- Tab 3 ---
        TabContent() {
          Column() {
            Blank().height(this.SYSTEM_BAR_HEIGHT)
            Row() {
              Circle({ width: 64, height: 64 }).fill('#E6F0FF')
              Column({ space: 6 }) {
                Text('HarmonyOS').fontSize(20).fontWeight(FontWeight.Bold)
              }.alignItems(HorizontalAlign.Start).margin({ left: 16 })
            }.width('90%').padding(24).backgroundColor('rgba(255,255,255,0.9)')
            .borderRadius(20)
          }.width('100%').height('100%')
        }
      }
      .vertical(false)
      .barHeight(0)
      .scrollable(false)
      .onChange((index: number) => { this.currentIndex = index; })
      .alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Top },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      })
      .id('content_layer')

      // Layer 3: 顶部导航栏 (ID: top_nav)
      Row() {
        Text('沉浸式元服务')
          .fontSize(20)
          .fontWeight(FontWeight.Medium)
          .fontColor(Color.Black)
          .textAlign(TextAlign.Start)
          .width('100%')
          .padding({
            left: 16
          })
      }
      .alignItems(VerticalAlign.Center)
      .width('100%')
      .height(this.SYSTEM_BAR_HEIGHT) // 顶部导航栏高度
      .padding({ top: this.barOffsetY }) // 保证Text与胶囊中心水平对齐
      .backgroundColor('rgba(255, 255, 255, 0.7)')
      .backdropBlur(20)
      .alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Top },
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      })
      .id('top_nav')

      // Layer 4: 底部自定义 Tab 栏 (ID: bottom_nav)
      Row() {
        this.TabItem(0, '探索', $r('app.media.more'));
        this.TabItem(1, '创作', $r('app.media.paperplane'));
        this.TabItem(2, '我的', $r('app.media.person'));
      }
      .width('100%')
      .padding({
        top: 6,
        bottom: this.bottomSafeHeight > 0 ? this.bottomSafeHeight : 0
      })
      .backgroundColor('rgba(230, 240, 255, 0.95)')
      .backdropBlur(40)
      .alignRules({
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      })
      .id('bottom_nav')
    }
    .width('100%')
    .height('100%')
  }
}