# 〖共创稿事节〗HarmonyOS 7 新特性实战(54):把光感还给底栏——沉浸式系统材质的作用域边界与 1.125 倍几何补偿

「山海万灵」的窄屏底部是一条浮动导航栏——336×64vp 的圆角胶囊,浮在手势指示条上方,不占布局。它一直有一层玻璃感。

用户的反馈很具体:现在的 tab 栏应该就是沉浸光感的,只是效果在最近的沉浸式改造里损失了一些,需要优化。

先去仓库里查「最近那次改造」。答案是那次「移除浮动导航底部渐变遮罩」的提交:它把浮动栏从 HdsTabs 加 barFloatingStyle({...}) 换成了手绘的 Stack 加 Row。动机写在当年的安全区记录里——自定义浮动导航不使用 HdsTabs,是为了避开其运行时无法关闭的底部渐变遮罩,让导航预留区直接透出沉浸壳层已铺满的页面底图。

也就是说:当年退出 HdsTabs 是为了甩掉那个会压暗内容的遮罩,而「光感」是搭着它一起被丢掉的。换成手绘栏之后,那一层只剩半透明底色加 1px 描边——底色还在,「活」的材质层没了。

这次的任务因此不是「打开一个开关」,而是把材质层找回来,同时不能再把遮罩带回来。

当前版本的浮动导航栏:圆角胶囊浮在手势区上方,不占布局

一、第一次尝试:给手绘栏挂上材质

最直接的做法是给手绘栏所在的容器加 .systemMaterial(...):

Column() { /* ... */ }
  .systemMaterial(new uiMaterial.ImmersiveMaterial({ style: uiMaterial.ImmersiveStyle.THIN }))

再加上 entry 模块配置清单里的模块级开关(只有 entry 类型 module 配了才生效):

"metadata": [
  { "name": "ohos.arkui.UIMaterial.state", "value": "enable" }
]

照做了,而且做得很规矩:

  • 版本护栏放在最前——应用 compatibleSdkVersion 是 6.0.0(20),在 API < 26 的设备上 uiMaterial 整个命名空间不存在,直接调会抛;
  • 读到官方约束「不建议和 backgroundColor / borderColor / borderWidth / shadow 同时使用,冲突时后设置的生效」,于是让手绘栏在材质生效时把底色与描边整体让位(透明色、零宽描边),底色改由 ImmersiveMaterial.materialColor 承载。

真机上跑同机 A/B(临时把材质分支短路),差异 0.00%。

起初以为是自己看错了——毕竟底色让位之后确实「变透」了。于是做了一次红探针:把 materialColor 设成高不透明红 #CCFF0000,看材质层到底出不出图。结果:整屏红色只占 0.29%,且全部落在页头的金色装饰里,与探针无关。

为了排除「是容器不支持」,又在同一个界面里并排放了一个 Column 探针(官方示例用的就是 Column)。两个探针都没有任何红色。

四条旁证都指向同一个结论:不是开关没配对。真机日志给了最后一锤:

W C03900/com.shanhai.spirits/Ace: Material inactive: out of scope.
    Use component in navigation title bar or Tabbar.

同一行出现了两次。

沉浸式系统材质只在 navigation 标题栏 / Tabbar 作用域内生效。 页面内容层里叠的 Row / Column 覆盖层,挂上去也一律不出图——这正是「浮动栏是 Stack 里的覆盖层」这个结构本身的限制。

顺手记一下当时的对照数据,它证明「开关确实全对」:

判据实测值结论
deviceInfo.sdkApiVersion26版本满足
uiMaterial.isImmersiveMaterialSupported()true设备支持
getMaterialInfo().stateENABLE(1)已使能
getMaterialInfo().typeIMMERSIVE(2)类型正确
Material inactive: out of scope出现 2 次作用域不对

三条泳道:同样的材质,只有标题栏与 Tabbar 会出图

二、正确的挂载点:回到 Tabs 自己的栏

既然只有 Tabbar 作用域认材质,那就回 Tabs 自己的栏上要——但不必回退到 HdsTabs。

翻 SDK 声明发现:原生 Tabs 自 API 26 起新增了 barFloatingStyle(style),其 FloatingTabBarStyle 里有一个字段:

interface FloatingTabBarStyle {
  barWidth?: FloatingTabBarWidth;     // smallBarWidth / mediumBarWidth / largeBarWidth
  barSideMargin?: Length;
  barBottomMargin?: Length;
  maskColor?: ResourceColor;          // ← 可选
  maskHeight?: Length;                // ← 可选
  adaptToHandedness?: boolean;
  systemMaterial?: UIMaterial.ImmersiveMaterial;   // ← 材质,@since 26.0.0
}

两个关键点:

1. systemMaterial 直接吃 ImmersiveMaterial,和前面构造的是同一个类型;2. maskColor / maskHeight 是可选字段——不配就没有遮罩。当年退出 HdsTabs 的理由(运行时无法关闭的渐变遮罩)在这里根本不成立。

于是改成两条互斥路径:

// 材质可用 ⇒ 把栏交还给 Tabs 自己(系统浮动栏),材质在合法作用域内出图。
// 不可用(老设备 / 被禁用)⇒ 维持零高度原生栏 + 原手绘覆盖层,零视觉回归。
.barHeight(this.useNativeFloatingBar() ? SHANHAI_NATIVE_BAR_HEIGHT : 0)
.barFloatingStyle(this.useNativeFloatingBar() ? this.floatingBarStyle() : undefined)

if (!this.hideBottomNav && !this.useNativeFloatingBar()) {
  Row({ space: 0 }) { /* 原手绘栏 */ }
}

两件事分开写:useNativeFloatingBar() 只回答「走哪一支」,floatingBarStyle() 只回答「这一支的参数怎么填」。门禁因此能分别盯住前者的不变量链与后者的字段清单,不会因为改了其中一头就把另一头的判据一起带红。

private useNativeFloatingBar(): boolean {
  // 三条硬前提:底栏在、材质可用、且没有被地图覆盖层收起。
  return !this.hideBottomNav && this.immersiveMaterialAvailable();
}

private floatingBarStyle(): Tabs.FloatingTabBarStyle {
  const style: Tabs.FloatingTabBarStyle = {
    barSideMargin: SHANHAI_NATIVE_BAR_SIDE_MARGIN,
    barBottomMargin: SHANHAI_NATIVE_BAR_BOTTOM_MARGIN,
    adaptToHandedness: true,
    systemMaterial: this.immersiveMaterial()
  };
  // 刻意不填 maskColor / maskHeight:那是当年退出 HdsTabs 要甩掉的东西,
  // 带回来等于把渐变遮罩一并召回,等于这次改造白做。
  return style;
}

private immersiveMaterial(): uiMaterial.ImmersiveMaterial {
  return new uiMaterial.ImmersiveMaterial({
    style: uiMaterial.ImmersiveStyle.THIN,
    materialColor: SHANHAI_FLOATING_NAV_BAR_COLOR
  });
}

同一个红探针再跑一遍:底栏区红色占比 38.51%(此前内容层探针是 0.00%)。材质这次真的出图了,选中项的 chip、图标、文字也都照常渲染。

这条分支后来还救过一次冷启动。上架自检在 API 20 设备上「启动」项失败,崩溃栈停在导航壳——早期写法是「一个 Tabs,barFloatingStyle 用三元表达式传参」,护栏只挡住了参数,没挡住方法调用;方法本身在低版本不存在,冷启动直接 undefined is not callable。改成 if/else 两条构建分支、低版本整条属性都不下发之后,自检通过:

if (this.useNativeFloatingBar()) {
  Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { /* 页面主体与 tabBar */ }
    .barHeight(SHANHAI_NATIVE_BAR_HEIGHT)
    .barFloatingStyle(this.floatingBarStyle())
} else {
  // API < 26:属性连方法名都不出现,避免「护栏只挡参数、没挡调用」。
  Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { /* 页面主体与 tabBar */ }
    .barHeight(0)
}

同一个 API 上有两件事要防:值不能用错,名字不能在低版本被求值。前者靠三元表达式能兜住,后者只能靠分支结构——一条属性只要出现在低版本的构建里,就可能在运行时变成一次对 undefined 的调用。

判断本身也要写成可读的三道闸,顺序不能换——版本最前,能力与状态在后:

private immersiveMaterialAvailable(): boolean {
  // ⚠️ 版本护栏必须放在最前:compatibleSdkVersion 是 6.0.0(20),
  // 在 API < 26 的设备上 uiMaterial 整个命名空间不存在,直接调会抛。
  if (deviceInfo.sdkApiVersion < 26) {
    return false;
  }
  if (!uiMaterial.isImmersiveMaterialSupported()) {
    return false;
  }
  return uiMaterial.getMaterialInfo().state === uiMaterial.MaterialState.ENABLE;
}

双分支决策:任一道闸不通过,就留在手绘栏这一支

三、踩坑:系统浮动栏会把请求尺寸放大 1.125 倍

材质通了,但几何变了。用 dumpLayout 量真实 bounds,第一版(请求 barWidth 336 / barHeight 64 / barBottomMargin 32)落屏是:

TabBar [63,2520][1197,2736]   →  378 × 72vp,底距 36vp

比设计值宽 42vp、高 8vp、底距多 4vp。这不只是「看着胖一点」——内容净空是按「栏顶距底 96vp」定的,栏一长高,净空就从 16vp 掉到 4vp,最后一屏内容几乎贴着栏。

第一次按固定差值补偿(336-42=294、64-8=56、32-4=28),重建后量出来:

TabBar [133,2560][1126,2750]   →  331 × 64vp,底距 31vp

高和底距准了,宽还是偏 5vp。把两组数据摆在一起才看出规律:

请求实测比值
3363781.125
2943311.126
64721.125
56641.143(≈1.125 取整)
32361.125
28311.107(≈1.125 取整)

不是加固定量,是按 1.125(= 9/8)等比放大。之前之所以「高和底距看着对」,纯粹因为设计值恰好是 64 和 32,×0.125 正好等于猜的差值——换个设计值就会错。

改成按比例除之后:

// 系统浮动栏统一按 1.125 落屏(三组实测:336→378、294→331、32→36)。
// 这里做的是反向补偿:先把设计值除以系数,再交给 Tabs,落屏才回到设计值。
const SHANHAI_NATIVE_BAR_SCALE: number = 1.125;

export const SHANHAI_NATIVE_BAR_WIDTH: number =
  SHANHAI_FLOATING_NAV_BAR_WIDTH / SHANHAI_NATIVE_BAR_SCALE;
export const SHANHAI_NATIVE_BAR_HEIGHT: number =
  SHANHAI_FLOATING_NAV_HEIGHT / SHANHAI_NATIVE_BAR_SCALE;
export const SHANHAI_NATIVE_BAR_BOTTOM_MARGIN: number =
  SHANHAI_FLOATING_NAV_BOTTOM_MARGIN / SHANHAI_NATIVE_BAR_SCALE;
export const SHANHAI_NATIVE_BAR_SIDE_MARGIN: number =
  SHANHAI_FLOATING_NAV_SIDE_MARGIN / SHANHAI_NATIVE_BAR_SCALE;

// 内容净空按「栏顶距底 96vp」定:336/1.125=299、64/1.125=57、32/1.125=28,
// 96-(57+28)=11,取整后实测净空仍回到 16vp。改这个系数前必须重新上机量。

再量:

TabBar [126,2556][1134,2748]   →  336 × 64vp,底距 32vp

和手绘栏的设计几何完全一致,内容净空回到 16vp。补偿系数和实测表都写进了布局常量的注释,并注明「改这个系数前必须重新上机量」。

同一个请求值,差值法在宽度上永远差一截,比值法一次到位

四、回归验证:改底栏不能把地图带崩

这次改动动了底栏的渲染路径,而底栏的收放和探索页地图覆盖层的视口是绑死的,所以验了三件事。

1)门禁 38 PASS / 0 FAIL。

其中三条判据是这次新增或升级的:

  • 地图原型门禁里两条字面量判据升级为不变量链判据。原来钉的是 if (!this.hideBottomNav) { 和 .barHeight(0);现在钉的是更强的两件事:useNativeFloatingBar() 必须内含 !hideBottomNav,且非接管路径的 barHeight 必须为 0。这样「覆盖层挂载 ⇒ 底栏收起 ⇒ 视口不重排」这条链依然锁死,但不再依赖某个写法。
  • 导航兼容门禁里加了三条反向钉:材质必须走 barFloatingStyle;源码里不得再出现内容层 .systemMaterial((那条路已被证明不出图,防止后人加回来);浮动栏不得配 maskColor / maskHeight(那是当年退出 HdsTabs 的原因,不能带回来)。

> 顺带踩了一个老坑:doesNotMatch(文件, /标识符/) 会被源码注释命中。注释里写了 .systemMaterial( 这个字面量,门禁直接红。注释措辞已改。

三条反向钉写成什么样,直接决定它们能不能拦住「改回去」这种动作:

// 材质必须挂在合法的栏作用域上,不能回退到内容层。
assertMatch(shell, /barFloatingStyle\(this\.floatingBarStyle\(\)\)/, '材质必须走 barFloatingStyle');
// 内容层挂材质已被真机证明不出图,源码里不得再出现这个调用。
assertNoMatch(shell, /\.systemMaterial\(/, '内容层不应再出现 systemMaterial');
// mask 一旦配回来,当年的渐变遮罩就会跟着回来。
assertNoMatch(shell, /maskColor|maskHeight/, 'barFloatingStyle 不得配 mask');

反向钉的价值在于它拦的是动机,不是写法。一句「这里不要用材质」的注释会被删掉、会被新同学忽略;一条让构建直接红的断言不会。三条钉各对应一次真实的决策,删掉任何一条,就等于把那个决策重新交给下一个人去猜。

2)模拟器(材质不可用)回退路径无回归。 模拟器 isImmersiveMaterialSupported() 为假,走的是手绘栏那一支:336vp 胶囊、米金底色、金色描边、选中项 chip、弹跳动画——与改造前逐项一致。

3)真机地图锚点 9/9 全命中。 这是最关键的一条:地图覆盖层挂载时 hideBottomNav 为真 ⇒ useNativeFloatingBar() 为假 ⇒ barHeight 归零 ⇒ Tabs 视口不变。九个区域钉逐个点选,信息页选中的区域与所点钉位全部一致:

所点钉位点击坐标(px)信息页选中区域结果
昆仑(308,1198)昆仑命中
北冥(649,1331)北冥命中
青丘(980,1309)青丘命中
西山(190,1612)西山命中
大荒(629,1697)大荒命中
东海(1032,1673)东海命中
幽都(165,1936)幽都命中
南山(617,1891)南山命中
羽民国(1056,2022)羽民国命中

其他:构建 BUILD SUCCESSFUL,仅既有 API 版本告警——uiMaterial / barFloatingStyle 标 @since 26.0.0,而兼容版本是 20,与既有告警同类。

五、这一轮真正值钱的地方

不在「材质调出来了」,而在把「效果丢了」这句话拆成了一条可验证的因果链:

1. 「效果丢了」要先问「它当初是靠什么实现的」。如果只盯着「给现在的栏挂个材质」,会一直卡在「开关全对但不出图」上;追到 HdsTabs.barFloatingStyle 才知道,光感原本就不在这个组件上。2. 框架日志比文档更早知道真相。Material inactive: out of scope. Use component in navigation title bar or Tabbar. 这一行直接给出了作用域限制——而 API 文档只说了「不建议和 backgroundColor 同用」,没提作用域。3. 别用「减固定差值」去拟合一个等比关系。第一次补偿宽偏 5vp,就是因为设计值 64/32 恰好让差值法看起来成立。两组独立数据点摆在一起,比值才露出来。4. 拿掉一个东西时,要问「它旁边还有什么会跟着走」。当年为了甩掉遮罩退出 HdsTabs,代价是搭上了材质——如果当时顺手在记录里写一句「材质也一并没了」,这次就不用重新排查一遍。

官方对 Tabs 沉浸式栏的说明见 ArkUI Tabs 沉浸式 FAQ。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐