〖共创稿事节〗HarmonyOS 7 新特性实战(40):把异兽图鉴放进平行视界,Navigation 主从页面与连续浏览
# 〖共创稿事节〗HarmonyOS 7 新特性实战(40):把异兽图鉴放进平行视界,Navigation 主从页面与连续浏览
平板上分栏已经能用了:左边神兽列表,右边原本是地图。点一只白泽,右栏一动不动,左栏整块换成了白泽的详情——列表没了,滚动位置也没了,按一次返回才退回来。
这就是「分栏开了,主从没做」。左边那栏本该是常驻的列表,右边那栏才是随点击变化的内容;实际发生的是整个页面被替换,只是恰好页面被劈成了两半。
从点击到详情上屏,中间其实只有一个方法。改这个方法的思路不复杂,麻烦全在周边:状态有几份、栈顶可能是谁、相邻的三条路径各自怎么走。这篇按这条线记一遍。
一、分栏开了,图鉴还是整页替换
改之前,点一只异兽只做三件事:
private openBeast(beastId: string): void {
this.selectedBeastId = beastId;
this.syncDiscovery(beastId);
this.refreshAiContext(beastId);
}
详情不在栈里,而是由主壳组件的一个布尔值决定渲不渲染——selectedBeastId 非空就渲染,位置是 tab 内容区。单栏时这是对的:详情本来就该是一张二级页,盖住首页、收起底部 tab 栏,返回即关。
分栏时同一个逻辑同时坏在三处:
- 右栏没有任何变化,导航栈从头到尾没被碰过,地图一直躺在那儿;
- 左栏被详情顶掉,
showDetailContent为真,列表整个让位; - 底部 tab 栏跟着收起,因为它和详情吃的是同一个状态。
顺带说一件当时没注意的事:异兽详情作为 destination 的组件(READER_PAGE_BEAST)其实早就存在,阅读流里的「推荐互链」一直在用它。图鉴这条路从来没接上去过——同一个详情页,两条入口,一条走栈、一条走壳层状态。
二、「打开一只异兽」里其实有两件事
改动的第一步不是加分支,而是先看清这个方法里装了什么。
三行代码里,只有第一行和「显示在哪一栏」有关。后两行——记「已发现」、刷新馆长导览的上下文——和显示位置完全无关,无论详情出现在左栏还是右栏都该执行。
把它们拆出来:
private openBeast(beastId: string): void {
// 分栏态走主从:详情进右栏,左栏的图鉴列表原地不动。
if (this.isSplitLayout()) {
this.showBeastInRightPane(beastId);
return;
}
this.selectedBeastId = beastId;
this.markBeastOpened(beastId);
}
/**
* 打开异兽时与「显示在哪一栏」无关的那部分:已读打点 + 讲解上下文。
*
* 拆出来是因为分栏态下不能动 selectedBeastId(一动左栏就被详情顶掉),
* 但这两件事照样要做,否则图鉴的已读计数和馆长导览都停在上一只。
*/
private markBeastOpened(beastId: string): void {
this.syncDiscovery(beastId);
this.refreshAiContext(beastId);
}
不拆会怎样:分栏分支里只推栈、不调用后两行,于是右栏的详情换了一只又一只,左栏列表上那排「已发现」的戳一动不动,首页「已读篇章」旁边那个计数也停在上一只。界面看着正常,只有计数对不上——这类问题最难发现,因为它不报错。
三、壳层那份详情必须先关掉
右栏路径的第一行不是推栈,是清状态:
private showBeastInRightPane(beastId: string): void {
this.selectedBeastId = '';
this.markBeastOpened(beastId);
const names: string[] = this.pageStack.getAllPathName();
const top: string | undefined = names.length > 0 ? names[names.length - 1] : undefined;
if (top === READER_PAGE_BEAST) {
this.pageStack.replacePath({ name: READER_PAGE_BEAST, param: beastId }, READER_PAGE_ANIMATED);
return;
}
this.pageStack.pushPath({ name: READER_PAGE_BEAST, param: beastId }, READER_PAGE_ANIMATED);
}
selectedBeastId = '' 这一行是整段的关键。不清它,主壳那边的详情照样渲染,左栏照样被顶掉——右栏确实多了一张详情,但左栏的列表已经不在了,主从退化成「两边都显示详情」。第一次上机就是这个现象:右栏出来了,左栏空了。
清掉之后左右两栏才各归各位:左栏是列表,右栏是详情,两者互不影响。
反方向也有同一个坑。右栏的详情出栈时,壳层那份状态同样要清:
private closeBeastDetailDestination(): void {
this.selectedBeastId = '';
this.popReaderPage();
}
不清的话,详情页弹掉之后壳层里那份详情会压在下面的文章页上面——右栏已经退到文章了,左栏却还顶着一张异兽详情,返回键行为跟着乱。
还有一处更细的:详情正文的 beastId 必须走参数传进去,不能读 this.selectedBeastId。因为出栈要清那个状态,如果正文读的是活状态,pop 动画还在播的时候状态已经被清成空串,页面会在动画里跳回列表里的第一只——看起来像是退场动画卡了一下。
四、连点十只,返回键不该按十次
剩下的判断是推栈还是替换。
列表上连点十只异兽,用户的意思是「换一只看看」,不是「叠十层」。如果每次都 pushPath,返回键要按十次才回到列表,栈里躺着九个没人想回去的页面,内存和转场动画都白花。
所以先看栈顶是谁:栈顶已经是异兽详情,说明右栏正在看详情,那就 replacePath 换掉它;栈顶是别的(补的地图、目录页、文章页),才 pushPath。
getAllPathName() 拿的是整个栈的路径名数组,取最后一个就是栈顶。空栈时数组为空,取不到就按「不是详情」处理,走 push——空栈本来就意味着右栏什么都没有。
这两个方法的语义差别就一句话:pushPath 是往上压一层,replacePath 是把栈顶那一层换掉,下面已有的层都不动。按 Navigation 组件参考 里 NavPathStack 的说明,替换不改变栈深,所以返回时仍然回到「详情下面那一层」,不会多出一层空壳。
| 右栏此刻栈顶 | 走哪条 | 按一次返回回到 |
|---|---|---|
| 空栈(分栏补的地图页) | pushPath | 地图 |
| 目录页 | pushPath | 目录 |
| 文章页 | pushPath | 那篇文章 |
| 异兽详情 | replacePath | 详情下面那一层 |
这张表里只有最后一行是新增行为,前三行是 push 天然就对的。判断条件写反(该 replace 时 push)不会立刻露馅,要连点两三只之后按返回才看出来。
五、三条相邻路径各自的分栏分支
图鉴不是进详情的唯一入口。同一个 openBeast 被四处调用,另外三条路径原本都有自己的出栈逻辑,分栏之后都得跟着分开走。
| 路径 | 单栏(保持原样) | 分栏(新增分支) |
|---|---|---|
| 详情页里的推荐互链 | openBeast + 自己再 replacePath 一次 | openBeast 内部已替换,跳过第二次 |
文章页 → 异兽(openArticleBeast) | 设壳层状态 + pushPath | 只 pushPath,不设壳层状态 |
篇章页 → 异兽(openChapterBeast) | 按两级逻辑 pop / replace / clear | 直接替换栈顶,且不能清空栈 |
第一条最容易漏:详情页底部那排推荐异兽,点击时会先调 openBeast,然后自己再 replacePath 一次——因为单栏下壳层详情是就地换内容,栈里那层也得跟着换。分栏之后 openBeast 内部已经替换过了,外面再来一次就是连替换两遍,转场动画抖两下,肉眼可见。
this.openBeast(nextBeastId);
// 分栏态:openBeast 走 showBeastInRightPane,栈顶本来就是详情,会自己 replace,
// 这里不能再 replace 一次(重复替换会让转场抖两下)。
if (!this.isSplitLayout()) {
this.pageStack.replacePath({ name: READER_PAGE_BEAST, param: nextBeastId }, READER_PAGE_ANIMATED);
}
第三条最危险。篇章页底部的反向互链(读完一篇,点文中提到的异兽)原本的逻辑是「收起篇章、露出详情」,做法是清空整个阅读栈。分栏态下右栏此刻就是篇章页,清空栈会让右栏直接变白,然后补一张地图——用户点了一只异兽,看见的是地图。
private openChapterBeast(beastId: string): void {
const names: string[] = this.pageStack.getAllPathName();
const below: string | undefined = names.length >= 2 ? names[names.length - 2] : undefined;
// 分栏态:右栏此刻就是篇章页,「收起篇章、打开详情」= 把栈顶这一层换掉。
if (this.isSplitLayout()) {
this.selectedBeastId = '';
this.markBeastOpened(beastId);
this.pageStack.replacePath({ name: READER_PAGE_BEAST, param: beastId }, READER_PAGE_ANIMATED);
return;
}
this.openBeast(beastId);
if (below === READER_PAGE_BEAST) {
this.popReaderPage();
this.pageStack.replacePath({ name: READER_PAGE_BEAST, param: beastId }, READER_PAGE_ANIMATED);
return;
}
this.clearReaderPages();
}
替换栈顶的语义正好等于「收起篇章、打开详情」,而且返回仍回到篇章下面那一层(目录页,或者空栈补的地图),不会多出一层。单栏那段一行没动。
六、右栏按左栏的宽度排版,横向裁掉一大块
改完第一次上机,详情确实进右栏了,左栏列表也还在。但内容横向溢出一大片:
- 「出处与定位」卡片右侧被切在屏幕边缘;
- 「古籍原文」那一块只露出左半。
不是裁切样式的问题,是排版宽度算错了。
应用里所有页面都靠一个窗口宽变量算内容宽度。分栏之后,这个变量量的是导航栏(左栏)的内容宽——默认比例 0.658,2880 宽的窗口下左栏约 947vp,右栏只剩 492vp。详情页拿 947vp 算版心、算卡片宽、算推荐位分几列,排出来的版面比右栏宽将近一倍,Navigation 直接裁掉超出的部分。
这个错法很隐蔽:单栏时它一直是对的,因为那时左栏就是整个窗口。只有把页面塞进右栏才会暴露,而且暴露出来的现象像是「样式写死了宽度」。
七、pane 一组:单栏回落,分栏自量
修法是让右栏的页面自己量一次宽度,不猜。
@State private destinationWidthVp: number = 0;
.onAreaChange((_oldValue: Area, newValue: Area) => {
const width: number = Math.round(Number(newValue.width));
if (width > 0 && width !== this.destinationWidthVp) {
this.destinationWidthVp = width;
}
})
onAreaChange 挂在 destination 的根容器上,分栏态量到的是右栏宽(实测 492vp),单栏时就是窗口宽。
然后让一个取值函数决定用哪个:
/**
* 右栏页面的排版宽度。
*
* 单栏时就是窗口宽(screenWidthVp);分栏时换成右栏自己量到的宽度 ——
* 拿左栏宽度给右栏排版会横向溢出,被 Navigation 直接裁掉。
* 量不到(首帧、或还在单栏)时回落到 screenWidthVp,不会算出 0 宽。
*/
private paneWidthVp(): number {
if (this.isSplitLayout() && this.destinationWidthVp > 0) {
return this.destinationWidthVp;
}
return this.screenWidthVp;
}
两个条件缺一不可。少了 isSplitLayout(),单栏时万一残留了一个旧值,手机上的详情会按平板的右栏宽排版;少了 > 0,首帧量不到宽度时会拿 0 去算,内容直接不显示。
上层再包四个派生函数,把原来那套按窗口宽算的参数一一对应过来:
private paneContentWidth(): number {
return ShanhaiResponsive.pageContentWidth(this.paneWidthVp(), this.screenHeightVp);
}
private paneSideInset(): number {
return ShanhaiResponsive.pageSideInset(this.paneWidthVp());
}
private paneIsTwoPane(): boolean {
return ShanhaiResponsive.profileLayoutMode(this.paneWidthVp(), this.screenHeightVp) === 'TWO_PANE';
}
private paneRecommendationColumns(): number {
return ShanhaiResponsive.recommendationColumns(this.paneWidthVp(), this.screenHeightVp);
}
详情页接收的四个版式参数(是否双栏、内容宽、左右内边距、推荐位列数)全部换成 pane* 系列:
ShanhaiBeastDetailContent({
beast: this.getBeast(beastId),
// 分栏态下详情在右栏:排版宽度必须按右栏实测宽度算(paneWidthVp),
// 沿用左栏宽度会横向溢出被裁掉。
isTwoPane: this.paneIsTwoPane(),
contentWidth: this.paneContentWidth(),
horizontalInset: this.paneSideInset(),
recommendationColumns: this.paneRecommendationColumns(),
// ...
})
改完溢出消失,492vp 的右栏里详情按 492vp 排版,卡片整宽落在栏内。单栏路径完全没变——paneWidthVp() 回落成窗口宽,算出来和改前一模一样,所以手机档不需要重新验版式。
这也顺带说明一件事:这套 pane* 不是给详情页专用的。任何会被放进右栏的页面都要走它,否则同样的溢出会在下一页重演。
八、验收:四步操作,四张图
设备是 tablet 档模拟器,2880×1920,密度 2x。操作序列只有四步:冷启 → 点左栏「图鉴」→ 点「白泽」→ 点「九尾狐」→ 返回一次。

第一张确认基线:图鉴在左栏,右栏还是地图。这时栈是空的,右栏那张地图是「栈空就补」补进来的。

第二张是主从成立的样子:左栏列表纹丝不动,右栏换成「No.001 白泽」,左栏里白泽那张卡上盖了「已发现」的戳,图鉴计数从 0 变 1。戳和计数是 markBeastOpened 打的——它跟显示位置无关,所以右栏路径里也得调。

第三张验连续替换:右栏变成「No.003 九尾狐」,左栏两只都盖了戳、计数变 2。栈没有变深,只是栈顶那一层被换掉了。

第四张验返回:按一次返回,右栏回到地图,左栏还是那份列表,滚动位置也没跳。连点两只只花一次返回,这就是 replace 换来的。
| 项 | 判据 | 结果 |
|---|---|---|
| 图鉴在左栏 | 左栏是图鉴列表,右栏仍是山海地图 | 通过 |
| 详情进右栏 | 左栏不动,右栏出「No.001 白泽」 | 通过 |
| 右栏不溢出 | 详情整宽落在右栏内,无横向裁切 | 通过 |
| 连续替换不叠栈 | 点九尾狐后右栏变「No.003」,左栏计数 2 | 通过 |
| 返回一次回列表 | 一次返回右栏回地图,左栏滚动位置不变 | 通过 |
这几条约束后来固化成了门禁断言,一共五组,每组都写明破坏它会怎样:分栏分支必须走右栏路径且单栏分支保留、右栏路径必须清壳层状态且按栈顶二选一、推荐互链在分栏态跳过第二次替换、右栏页面必须自量宽度且四个版式参数不得再用壳层那套、篇章互链在分栏态必须替换且不得清栈。
反向验过两处:删掉 selectedBeastId = '',门禁报「必须清 selectedBeastId」;把 paneContentWidth() 换回按窗口宽算的那个函数,门禁报「必须用 paneContentWidth」。全量 44 个校验脚本复跑 44/44 通过。
还剩什么没验
手机档没上机。单栏路径的逻辑与改前一致(paneWidthVp() 回落窗口宽),但「手机上图鉴点进去仍是整页二级页」这条只有代码保证,没有截图。
其他阅读页在右栏仍会溢出。篇章页、专题页、目录页的内容宽度还是按窗口宽算的,分栏态进右栏会重现第六节那个现象。主从这一刀只修了异兽详情,其余按同一套 pane* 方式跟进,是下一批的事。
深链 OPEN_BEAST(从桌面卡片直接打开某只异兽)走的是同一个 openBeast,逻辑上同样进右栏,但没有单独截图验过。
更多推荐
所有评论(0)