HarmonyOS 7 Navigation + NavPathStack:自适应分栏收拢时的详情路由合并、占位页恢复与重复入栈抑制【鸿蒙心迹】
这次处理的是“宽屏变窄”才会暴露的问题。SplitRouteLab 在展开态使用 Navigation 自适应分栏,左边 Notes,右边 NoteDetail;收拢成 STACK 时,用户应该继续停留在当前详情。
真正的问题出在连续点击和模式切换同时发生时:同一个 note_208 可能重复入栈;收拢后返回两次才回列表;右侧空白时如果把 splitPlaceholder 当成真实路由,又会额外污染返回栈。
所以本文只处理三件事:重复入栈抑制、分栏收拢时详情栈归一、占位页保持“只展示不入栈”。
本次数据:
- Session:
nav_merge_20261001_18 - Mode Change:
SPLIT → STACK - Master:
Notes - Selected:
note_208 - Stack Before:
2 - Stack After:
1 - Duplicate Push Blocked:
2 - Placeholder Used:
true - Mode Changes:
3 - State:
MERGED - Last Merge:
18:58:24

一、splitPlaceholder 只负责“右侧别空着”,不要把它当成路由页
分栏右侧没有 NavDestination 时,可以用 splitPlaceholder 提供默认 UI。最容易犯的错误,是把占位页也 push 进 NavPathStack;这样收拢成单栏后,它就会变成一个可返回页面。
正确做法是让占位页只存在于 Navigation 的展示层:
@State pathStack: NavPathStack = new NavPathStack()
build() {
Navigation(this.pathStack) {
NotesList({
onSelect: (id: string) => {
this.selectNote(id)
}
})
}
.mode(NavigationMode.Auto)
.navDestination(this.PageMap)
.splitPlaceholder(() => {
Column() {
Text('选择一条笔记查看详情')
.fontSize(20)
Text('当前右侧没有已选内容')
.fontColor('#808080')
}
})
}
splitPlaceholder 只解决“分栏右侧别空着”,不对应业务 route name,也不进入返回栈。
二、列表点击先做业务级去重,再交给 NavPathStack
第二个问题是重复点击。系统不会知道两次 pushPathByName('NoteDetail', ...) 在业务上其实是同一条笔记,所以我先用 selectedNoteId + 当前栈内容 做幂等判断:
@State selectedNoteId: string = ''
@State duplicateBlocked: number = 0
private selectNote(noteId: string): void {
const names = this.pathStack.getAllPathName()
const detailExists =
names.includes('NoteDetail')
if (this.selectedNoteId === noteId &&
detailExists) {
this.duplicateBlocked++
return
}
this.selectedNoteId = noteId
if (detailExists) {
this.pathStack.removeByName('NoteDetail')
}
this.pathStack.pushPathByName(
'NoteDetail',
{ id: noteId }
)
}
对这种 Master → 单一 Detail 结构,新的 selection 到来时先移除旧 NoteDetail 再推入新详情。本次两次重复点击被挡掉,所以 Duplicate Push Blocked=2。如果业务允许详情继续跳详情,就必须换成更精细的 route key 策略。
三、SPLIT 收拢成 STACK 时,要做一次路由归一
真正的问题出现在模式切换。收拢成单栏后,如果当前选中 note_208,栈顶只应该保留一个 NoteDetail(note_208)。所以我把“模式变化”转换成业务状态,再统一走一次 merge:
private mergeForStack(): void {
const names = this.pathStack.getAllPathName()
const detailCount =
names.filter(name => name === 'NoteDetail').length
this.stackBefore = detailCount
if (detailCount > 1) {
this.pathStack.removeByName('NoteDetail')
}
const afterRemove =
this.pathStack.getAllPathName()
if (this.selectedNoteId &&
!afterRemove.includes('NoteDetail')) {
this.pathStack.pushPathByName(
'NoteDetail',
{ id: this.selectedNoteId }
)
}
this.stackAfter =
this.pathStack.getAllPathName()
.filter(name => name === 'NoteDetail')
.length
this.state = 'MERGED'
}
这次 Stack Before=2,归一之后 Stack After=1。
重点不是 removeByName(),而是模式切换后重新表达业务意图:selection 是 note_208,详情栈就只能留下这一份。
四、窗口变窄时,我不让多个回调同时改栈
折叠或窗口拖动可能连续触发;如果每次都调用 mergeForStack(),路由栈可能一边 remove、一边又被下一轮 push。
所以我给模式收敛加了 generation:
private modeGeneration: number = 0
private onExpectedModeChanged(
nextMode: 'SPLIT' | 'STACK'
): void {
const generation = ++this.modeGeneration
this.modeChanges++
setTimeout(() => {
if (generation !== this.modeGeneration) {
return
}
if (nextMode === 'STACK') {
this.mergeForStack()
} else {
this.state = 'SPLIT_READY'
}
}, 80)
}
80 ms 只是 Demo 的收敛窗口,用来确保同一轮折叠或窗口拖动里只执行最后一次栈整理。
五、占位页恢复的关键,是“不要恢复路由”
重新进入宽屏且没有 selectedNoteId 时,右侧自然回到 splitPlaceholder。我不会 push 一个 Placeholder 路由,也不会把“上次看见占位页”持久化成路由快照;真正需要持久化的是业务 selection。
六、调试页只展示和“栈是否收敛”有关的数据
工程只保留 NotesHome、NoteDetail、RouteMergeCoordinator 和 NoteSelection。HiLog 输出:
mode=SPLIT placeholder=true
select note_208
duplicate push blocked=1
mode change -> STACK
remove NoteDetail count=2
push final NoteDetail id=note_208
State: MERGING -> MERGED

七、最终结果看的是“返回一次应该去哪”
最终诊断页里 Mode Change=SPLIT → STACK、Stack Before=2、Stack After=1、Selected=note_208。收拢后详情只剩一份,返回一次就应回到 Notes 列表。Placeholder Used=true 只说明空分栏曾展示占位 UI,不代表它进入过路由栈。

八、正式项目还要考虑几个边界
正式项目还要处理 route key 精确合并、单双栏切换协调、deep link 统一入口,以及 selection 失效后的清空。
九、这次修的不是 Navigation,而是“模式变化时业务路由怎么收口”
最终链路是 SPLIT → MERGING → STACK → MERGED。Navigation 管布局,NavPathStack 管真实路由,业务层只回答:当前应该有几个详情页。
更多推荐




所有评论(0)