〖共创稿事节〗HarmonyOS 7 新特性实战(41):地图何时全屏——分栏里的临时合并与退出复原
# 〖共创稿事节〗HarmonyOS 7 新特性实战(41):地图何时全屏——分栏里的临时合并与退出复原
平板分栏做好之后,点「在地图上选」打开 3D 地图,屏幕上出现了三样东西:左栏被地图盖住,右栏还挂着另一张地图页,中间那条 4vp 的拖动手柄还骑在分隔线上。
一张地图,两个窗口,一条缝。
这件事的难点不在「怎么让地图变大」。把覆盖层的容器宽度从 948 改成整窗是一行的事。难的是三个问题:什么时候该变大、变大之后怎么回去、以及变大期间那些按宽度摆位的东西怎么办。
一、分栏之后,地图变成了两张
先把现场说清楚。首页(探索页)和右栏那张地图页是两个不同的组件:左栏是 ShanhaiWorldPageContent,地图覆盖层长在它内部;右栏是 MAP_PAGE 这个 destination,渲染的是 ShanhaiWorldMapPage。
分栏状态下点开地图,覆盖层只铺满它所在的容器,也就是左栏。于是:
| 位置 | 打开地图前 | 打开地图后 |
|---|---|---|
| 左栏(navBar) | 探索页 | 地图覆盖层(948vp 宽) |
| 右栏(content) | 地图常驻页 | 地图常驻页(没变,栈没动过) |
| 中间 | 4vp 拖动手柄 | 4vp 拖动手柄(还骑着) |
屏幕宽 1440vp,左栏 948vp,右栏 492vp,中间一条线。用户点的是「看地图」,看到的是一张被切成两半的地图。
二、先回答「谁该全屏」
分栏不是全局开关,是逐页决定的。这个应用里各页的归属很明确:
| 页面 | 该不该全屏 | 理由 |
|---|---|---|
| 3D 地图(沉浸探索) | 全屏 | 看的是整幅地貌,切一半就没法读 |
| 异兽详情 | 分栏 | 左栏列表要一直在,方便连点着看 |
| 古籍目录 / 篇章 | 分栏 | 目录是导航,正文是被导航的内容 |
| 主题弹层 / 超分查看器 | 全屏 | 弹层,天然盖住一切 |
判据其实只有一句:这一页的内容是不是「一张图」。是图就全屏,是「列表 + 内容」就分栏。
地图属于前者,而且是唯一一个「内容在左栏、右栏反而是另一张地图」的页面——这也是它最先暴露问题的原因。
三、为什么不把覆盖层搬出去
最直接的想法是把覆盖层从首页里搬出来,挂到整窗那一层。试过三条路,都不划算。
搬到 Navigation 外面:覆盖层的云幕过渡、区域停留定时器、场景就绪回调全绑在首页组件上,搬出去等于把这些状态一起搬家,云幕那套动效要重写。
做成 NavDestinationMode.DIALOG 的 destination:主题弹层和超分查看器就是这么做的,hideNavBar(true) 一收,内容区拿到整窗宽度,DIALOG 自然满屏。机制是现成的,但同样要把云幕和停留定时器搬进 destination,而且云幕是首页的招牌动效,回归风险不小。
清空栈再切 Stack 模式:语义最直白,但退出时重建栈会重放转场动画,观感容易花。
最后选的是第四条:不搬组件,把分栏临时合并。左栏拉满整窗、右栏让到 0,覆盖层还铺在自己的容器里,只是这个容器此刻就是整窗。
代价是三个布局属性都要跟着改,而且退出时必须能原样回去。好处是覆盖层的代码一行没动。
四、三个属性缺一不可
三个都是 Navigation 的布局属性,语义见 Navigation 组件参考:navBarWidth 是导航栏宽度,navBarWidthRange 是它的合法区间,minContentWidth 是内容区的最小宽度。三者叠在一起才决定最终谁占多宽。
.navBarWidth(this.effectiveNavBarWidth())
.navBarWidthRange([SPLIT_MIN_NAV_BAR, this.effectiveNavBarUpperBound()])
.minContentWidth(this.effectiveMinContentWidth())
三个都走取值函数,函数内部判断「现在是不是沉浸态」。少改任何一个,都会留一条缝:
| 属性 | 平时 | 沉浸时 | 漏改的表现 |
|---|---|---|---|
navBarWidth | 用户拖出来的 948 | 窗口宽 1440 | 地图停在 948,右边半屏还是地图页 |
navBarWidthRange 上限 | 1000 | 窗口宽 | 1440 被夹回 1000,右边剩 440 |
minContentWidth | 360 | 0 | 右栏被硬留 360,左栏最多到 1080 |
第二条最容易被忽略:navBarWidth 传了整窗宽,但 navBarWidthRange 的上限还是 1000,框架会把它夹回去,表现是「明明写了 1440,量出来 1000」。第三条同理,minContentWidth 是硬约束,不归零就永远差一截。
三条改完,实测布局树里 NavBar [0,86][2879,1920](窗口 2880 宽),右栏 NavDestination 数量从 1 变成 0——那一栏被压到零宽,连节点都不渲染了。
五、合并的时机在云幕里
「沉浸态」用什么判据,是这个改动里最容易写错的地方。
第一版用的是 showWorldMap,也就是用户点下「在地图上选」那一刻。结果打开地图的瞬间,右栏先被抽走、左栏先撑满,云幕还没升起来——一次没有遮挡的重排,肉眼就是「屏幕闪了一下」。
正确的判据是 mapOverlayMounted,覆盖层的实际挂载态:
private isImmersiveMapActive(): boolean {
return this.mapOverlayMounted && this.shouldSplitLayout();
}
这个值由云幕过渡在 CLOUD_CLOSE_GATE_MS(760ms)那一刻翻转,而云幕本身的上升动画是 1100ms。也就是说合并发生在云幕已经铺了大半的时候:


两张图对比就能看出差别:400ms 时云幕只盖左栏、分隔线还在;900ms 时云幕已经是整幅。合并这一下没有单独的视觉事件,它搭在云幕上升的过程里。
关闭时同理,方向相反:showWorldMap 先翻 false,云幕升起,760ms 后才卸载覆盖层并复原分栏——复原也在云幕里。
第二个条件是 shouldSplitLayout():手机单栏时左栏本来就是整窗,再去拉满只是白改一次布局属性。它只看窗口宽高,不看测量结果。
六、开关不能读结果
判据里不能用 isSplitLayout()。
那个方法量的是「两栏是不是真的都在屏幕上」:Navigation 宽减去 navBar 宽,差值超过 100vp 才算分栏。合并之后 navBar 就等于整窗,差值是 0,它会翻成 false——然后「是不是沉浸态」变成 false,合并被取消,navBar 缩回去,差值又超过 100,又变成沉浸……在两帧之间来回抖。
这是这个项目里第二次栽在同一件事上。第一次是分栏开关本身:拿测量结果去驱动 mode(),Stack 态两栏等宽、判据恒为假,永远进不了 Split。结论是一样的——开关读窗口,结果读测量,两者不能混。
七、复原不需要代码
一开始打算「记住合并前的宽度,退出时恢复」。写的时候发现根本不需要:
/**
* 左栏实际生效的宽度。
*
* 沉浸时拉满整窗;其余时候就是用户拖出来的 splitNavBarWidth。
* **这里只读不写** —— 退出沉浸不需要「恢复」动作,因为用户那个值从头到尾没被动过。
*/
private effectiveNavBarWidth(): number {
if (this.isImmersiveMapActive()) {
return this.navigationWidthVp;
}
return this.splitNavBarWidth;
}
/** 左栏宽度上限。沉浸时必须放到整窗,否则 navBarWidth 会被 range 夹回 1000。 */
private effectiveNavBarUpperBound(): number {
if (this.isImmersiveMapActive()) {
return this.navigationWidthVp;
}
return SPLIT_MAX_NAV_BAR;
}
/** 右栏最小宽度。沉浸时归零,否则 Navigation 一定给右栏留 360vp。 */
private effectiveMinContentWidth(): number {
if (this.isImmersiveMapActive()) {
return SPLIT_IMMERSIVE_MIN_CONTENT;
}
return SPLIT_MIN_CONTENT;
}
沉浸时返回窗口宽,但不把它写回 splitNavBarWidth。用户拖出来的那个值从头到尾没被动过,退出时函数走 else 分支,自然就是原值。
「不写回」这件事必须守住,写回去的代价是永久的:splitNavBarWidth 会在下次拖动手势结束时被 saveShanhaiSplitWidth() 落盘,存进去的就是那个被污染的满宽,之后每次冷启左栏都是满的。
实测退出后 NavBar [0,86][1896,1920],与沉浸前的 1896 一致。

八、还有两样东西杵在那儿
左栏拉满之后,有两样东西不会自动消失,因为它们不是按 navBar 宽度排版的。
拖动手柄。它按 splitNavBarWidth 定位,合并后那个位置正好在地图中间;而且它还能拖,一拖就从地图里拽出一条缝。条件里加一条:
@Builder
SplitHandle() {
// 沉浸地图开着时不画手柄:此刻左栏被拉满整窗,手柄仍按 splitNavBarWidth
// 定位,会明晃晃地骑在地图中间;而且它还能拖,一拖就从地图里拽出一条缝。
if (this.isSplitLayout() && !this.isOverlayLayerVisible() && !this.isImmersiveMapActive()) {
Column() {
Column()
.width(this.splitDragging ? SPLIT_HANDLE_GRIP_WIDTH + 1 : SPLIT_HANDLE_GRIP_WIDTH)
.height(this.splitDragging ? SPLIT_HANDLE_GRIP_HEIGHT + 16 : SPLIT_HANDLE_GRIP_HEIGHT)
.borderRadius(SPLIT_HANDLE_GRIP_WIDTH / 2)
.backgroundColor(this.isDarkMode ? '#8CF2E9D8' : '#663B3229')
}
.width(SPLIT_HANDLE_WIDTH)
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#00000000')
.position({ x: this.splitNavBarWidth - SPLIT_HANDLE_WIDTH / 2, y: 0 })
.gesture(
PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 1 })
.onActionUpdate((event: GestureEvent) => {
const next: number = this.splitDragStartWidth + event.offsetX;
this.splitNavBarWidth = Math.min(
this.splitNavBarUpperBound(),
Math.max(SPLIT_MIN_NAV_BAR, next)
);
})
)
}
}
宽屏侧栏。侧边导航是 109vp 固定宽,在 SideShell() 里无条件渲染,底栏那个 hideBottomNav 管不到它。不处理的话,「全屏」就只是「地图变宽了」,左边还立着一条导航。
这里没有复用 hideBottomNav:它在宽屏下还代表「二级页」,接进来会让所有二级页在平板上都丢掉侧栏,改动面远大于这件事。新加一个 hideSideNav,只在地图挂载时传 true:
@Prop hideSideNav: boolean = false;
@Builder
private SideShell() {
Row() {
if (!this.hideSideNav) {
ShanhaiSideNav({
activeTab: this.activeTab,
isDarkMode: this.isDarkMode,
onSwitch: (index: number) => {
this.onSwitch(index);
}
})
Column() {}
.width(1)
.height('100%')
.backgroundColor(this.dividerColor)
.opacity(0.55)
}
Column() {
// 当前 tab 的内容
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.backgroundColor(this.shellBackground)
}
九、返回键把应用退了
前面几节都改完,地图能铺满整窗、能复原、手柄和侧栏都不见了。然后按了一下返回键——应用退到桌面。
排查过程很短,因为栈里的东西很清楚:右栏那张常驻地图页仍然在栈里(只是被压成零宽,节点还在),系统返回键先派给它,而它的 onBackPressed 干的第一件事是 terminateSelf():
.onBackPressed(() => {
// 右栏常驻页不吃「出栈」这一下:出栈只会让右栏变成一片白。
// 栈里只剩它时按返回 = 离开应用。
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
context.terminateSelf();
} catch (err) { /* ... */ }
return true;
})
它的设计前提是「栈里只剩我 = 用户在主界面按返回」。分栏之后这个前提不成立了:左栏才是主界面,右栏只是常驻页,而地图覆盖层盖在两者之上。
修法是在它前面补一句判据:
if (this.consumeOverlayBack()) {
return true;
}
阅读流那四个 destination(目录 / 篇章 / 专题 / 异兽详情)早就调了这个方法,只有这一页漏了——它是分栏之后才有的页面,写的时候没考虑「上面还可能压着一个浮层」。
改完再按返回:地图收起、分栏复原、应用还在。
十、验收:三条退出路径
地图有三条退出路径,每条都要能把分栏复原。
| 退出方式 | 判据 | 结果 |
|---|---|---|
| 点页头返回箭头 | 云幕收起后 NavBar 回到 1896,右栏 NavDestination 回到 1 个 | 通过 |
| 按系统返回键 | 同上(改之前是 terminateSelf()) | 通过 |
| 点地图上的区域 | 停 900ms → 自动收起 → 分栏复原 → 当前区域变「西山」 | 通过 |


这些约束固化成了门禁,六组断言:合并条件必须同时看挂载态与窗口断点、三个布局属性都必须走取值函数、满宽不得写回状态值、手柄必须排除沉浸态、常驻页的返回键必须先消费浮层、宽屏侧栏必须由独立开关控制。
反向验过两处:删掉手柄的沉浸判据、把 minContentWidth 换回常量 360,门禁都直接报错。全量 45 个校验脚本复跑 45/45 通过。
还剩什么没验
模拟器上 3D 场景不可用,走的是平面地图回退。平面图按 ImageFit.Contain 保持比例,所以在 1440vp 宽的窗口里,地图本身只占中间一条竖带,两侧是云海天幕——这不是分栏的问题,是回退模式的取景方式,但「3D 场景铺满整窗」这件事没有证据。
手机档没上机。单栏时判据恒为 false,三个布局属性与改前完全一致,逻辑上不会有变化,但没有截图。
还有一处已知的不一致:沉浸期间 isSplitLayout() 会翻成 false(右栏宽度是 0,测量结果自然不成立)。目前受影响的只有「栈空补地图」那个守护,而沉浸期间栈里本来就有常驻页,不需要补。哪天有别的逻辑开始依赖这个判据,得回来重新看一遍。
更多推荐

所有评论(0)