〖共创稿事节〗HarmonyOS 7 新特性实战(54):把光感还给底栏——沉浸式系统材质的作用域边界与 1.125 倍几何补偿
# 〖共创稿事节〗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.sdkApiVersion | 26 | 版本满足 |
uiMaterial.isImmersiveMaterialSupported() | true | 设备支持 |
getMaterialInfo().state | ENABLE(1) | 已使能 |
getMaterialInfo().type | IMMERSIVE(2) | 类型正确 |
Material inactive: out of scope | 出现 2 次 | 作用域不对 |

二、正确的挂载点:回到 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。把两组数据摆在一起才看出规律:
| 请求 | 实测 | 比值 |
|---|---|---|
| 336 | 378 | 1.125 |
| 294 | 331 | 1.126 |
| 64 | 72 | 1.125 |
| 56 | 64 | 1.143(≈1.125 取整) |
| 32 | 36 | 1.125 |
| 28 | 31 | 1.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。
更多推荐
所有评论(0)