HarmonyOS 7 平行视界实战 03:导航固定 + 覆盖切换重构“双栏研读台”大屏阅读体验【鸿蒙心迹】
第一篇里,我先把“双栏研读台”的第一版双栏骨架搭了起来;第二篇继续顺着“连续浏览”这条线,把左侧目录、右侧详情和相关阅读的连续跳转组织成了一条真正的阅读链。到第三篇,我又遇到了一个更偏产品体验层的问题:如果左侧目录本来就承担“稳定导航”的职责,那右侧内容是不是不一定非要一直整页推进?
这篇我想解决的,就是大屏阅读里另一个非常典型的模式:左侧导航固定,右侧内容区按覆盖方式切换。 换句话说,左边这块始终像目录锚点一样稳定存在,右边则专注承载“我当前正在看什么”。相比第二篇的连续推挤,这一篇更强调“结构稳定”“切换明确”和“返回恢复”。

一、为什么第三篇要从“连续推进”转向“固定导航 + 覆盖切换”
第二篇写完以后,我专门拿平板连续读了十几篇文章。整体感觉没问题,但有个体验越来越明显:
- 当左侧列表承担的是“文章入口”时,连续推挤很自然;
- 但当左侧区域开始承担“分类导航、栏目切换、主入口定位”时,右侧如果还总是一路 push,就会显得有点重。
比如我在“双栏研读台”里,把左侧从单纯文章列表逐步改成下面这种结构:
全部文章
技术解读
设计思考
开发实践
行业观察
产品体验
开源生态
这时候,左侧已经不是“某一篇文章的列表”了,而更像一套稳定导航体系。
用户在左边点“开发实践”,右边出现这一类文章的列表;再点其中一篇文章,右边展示详情;点击返回时,右边应该回到刚才那一组列表状态,而不是把左边也跟着切掉。
我慢慢意识到,第三篇要解决的问题,其实和第二篇不一样:
第二篇的核心是“阅读链不断向前推进”;第三篇的核心是“导航区保持不动,内容区在右侧完成覆盖切换”。
HarmonyOS 当前对平行视界的典型场景描述里,除了左右推挤,还强调了导航固定 + 内容覆盖切换这一类模式。对于阅读、内容浏览、分类检索这类应用来说,这种组织方式往往更稳定,也更符合用户对大屏的直觉预期。
二、先把产品规则说清楚:左侧固定,右侧切换,返回只恢复右侧
第三篇开始以前,我先给“双栏研读台”定了三条明确规则:
规则 1:左侧导航不参与右侧详情的页面切换
也就是说,左边是左边,右边是右边。
无论我在右侧打开多少篇文章、切换多少次详情,左侧导航区域都应该保持原状:
- 当前栏目还在;
- 选中的导航项还在;
- 用户对目录的定位感不能丢。
规则 2:右侧详情以“覆盖方式”打开,而不是替换整个双栏结构
这点非常关键。
如果把整个界面都当成一张页面,每次点文章详情都让整个路由往前走,就很容易把左侧导航一起卷进去。第三篇我更想要的是:
- 左侧导航像一个固定底座;
- 右侧内容区像一个可以被覆盖更新的工作区。
规则 3:返回只恢复右侧内容区状态
第三篇的返回不再是“整页退回”。
它更接近:
左侧固定导航不变
右侧从详情返回到列表
或者从新详情返回到旧详情
这种“局部返回”的感觉,是第三篇最核心的体验之一。
三、第三篇的项目目录开始显得更工程化了
因为要支持“固定导航 + 覆盖切换”,我把项目目录继续做了一次拆分。相比第二篇,这一版开始明确区分:导航结构、右侧内容区、覆盖路由、阅读状态。
DualReadDemo
├─ entry
│ ├─ src/main/ets
│ │ ├─ pages
│ │ │ ├─ Index.ets
│ │ │ ├─ ArticleListPage.ets
│ │ │ ├─ ArticleDetailPage.ets
│ │ │ └─ CategoryHomePage.ets
│ │ ├─ components
│ │ │ ├─ FixedNavLayout.ets
│ │ │ ├─ ArticleListItem.ets
│ │ │ ├─ RelatedArticleCard.ets
│ │ │ └─ TagChip.ets
│ │ ├─ route
│ │ │ ├─ OverlayRouteService.ets
│ │ │ ├─ RouteHistoryStore.ets
│ │ │ └─ ReadingSessionStore.ets
│ │ ├─ model
│ │ │ ├─ ArticleItem.ets
│ │ │ └─ NavItem.ets
│ │ ├─ mock
│ │ │ └─ MockData.ets
│ │ └─ common
│ │ └─ Logger.ets
│ └─ resources
└─ module.json5
这一版目录里,我最看重三个文件:
FixedNavLayout.ets:左侧固定导航;OverlayRouteService.ets:右侧覆盖切换的路由组织;ReadingSessionStore.ets:记录“我从哪个导航入口来、当前正在读哪篇”。
有了这三个角色以后,整个大屏结构才真正稳下来。
四、Index.ets 的职责变了:它不只是分栏,而是总装配台
第一篇的 Index.ets 更像一个起点页;第二篇里它负责把 NavPathStack 和 RouteService 串起来;第三篇里,它开始承担“总装配”的角色。
我希望它同时表达三层东西:
- 左侧导航是固定区;
- 右侧主体是内容区;
- 内容区之上还可以叠加覆盖层。
所以 Index.ets 里,我会把整体结构组织成:
import { router } from '@kit.ArkUI'
import { FixedNavLayout } from '../components/FixedNavLayout'
import { ArticleListPage } from './ArticleListPage'
import { ArticleDetailPage } from './ArticleDetailPage'
import { RouteContext } from '../route/RouteContext'
@Entry
@Component
struct Index {
@State private navPathStack: NavPathStack = new NavPathStack()
@State private selectedNavId: string = 'all'
@State private overlayVisible: boolean = false
@State private overlayArticleId: string = ''
private routeContext: RouteContext = new RouteContext()
build() {
Navigation(this.navPathStack) {
Row() {
FixedNavLayout({
selectedId: this.selectedNavId,
onSelect: (id: string) => {
this.selectedNavId = id
this.onNavSelect(id)
}
})
.width('280vp')
.backgroundColor('#F7F8FA')
Stack() {
ArticleListPage({
navId: this.selectedNavId,
onOpenArticle: (articleId: string) => {
this.openOverlayArticle(articleId)
}
})
if (this.overlayVisible) {
ArticleDetailPage({
articleId: this.overlayArticleId,
onClose: () => this.closeOverlay(),
onOpenRelated: (id: string) => {
this.openOverlayArticle(id)
}
})
}
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
.title('双栏研读台')
}
}
和第二篇相比,这里的核心变化不是 Navigation 本身,而是右侧内容区从单一“详情页容器”变成了一个 Stack:
- 底层是当前栏目下的文章列表;
- 上层在需要时叠加文章详情覆盖层。
这一步做完以后,“固定导航 + 覆盖切换”就从想法变成真正可落地的页面结构了。

五、固定导航不要贪多,它的职责只有一个:稳定用户方向感
第三篇里,我刻意把 FixedNavLayout 做得很克制。
因为左侧固定导航不是为了塞很多功能,而是为了持续给用户一个“我现在在哪个栏目里”的方向感。
它承担的职责只有三个:
- 展示导航项;
- 记录当前选中项;
- 把选中变化通知给右侧内容区。
代码结构我大致写成这样:
@Component
export struct FixedNavLayout {
@Prop selectedId: string = 'all'
onSelect: (id: string) => void = () => {}
private navItems: NavItem[] = [
{ id: 'all', title: '全部文章', icon: $r('app.media.ic_all') },
{ id: 'tech', title: '技术解读', icon: $r('app.media.ic_tech') },
{ id: 'design', title: '设计思考', icon: $r('app.media.ic_design') },
{ id: 'practice', title: '开发实践', icon: $r('app.media.ic_practice') },
{ id: 'industry', title: '行业观察', icon: $r('app.media.ic_industry') }
]
build() {
Column() {
this.buildHeader()
List({ space: 6 }) {
ForEach(this.navItems, (item: NavItem) => {
ListItem() {
Row({ space: 12 }) {
Image(item.icon).width(18).height(18)
Text(item.title).fontSize(16)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.borderRadius(12)
.backgroundColor(this.selectedId === item.id ? '#EAF3FF' : '#FFFFFF')
}
.onClick(() => this.onSelect(item.id))
}, (item: NavItem) => item.id)
}
.layoutWeight(1)
}
.padding(16)
.height('100%')
}
}
写到这里时,我最在意的一点是:左侧导航不能因为右侧文章不断切换就闪来闪去。
它应该像书架、目录、索引一样,稳定存在。
六、右侧覆盖切换的关键,不是“能弹出来”,而是“能解释状态”
很多人一提覆盖层,第一反应是 Dialog、半屏页、浮层、抽屉。但第三篇这里的“覆盖切换”其实更接近:
右侧内容区维持主工作面,文章详情在这个工作面之上以覆盖方式展开。
也就是说,它不是一个随便弹一下的轻提示,而是一个真正承担阅读任务的主内容层。
为了把这个关系说清楚,我专门做了一个 OverlayRouteService,把右侧覆盖层的打开、关闭、层级和恢复,都收口在一起。
@Singleton
export class OverlayRouteService {
private historyStore: RouteHistoryStore = RouteHistoryStore.getInstance()
private rootArticleId: string = ''
private currentArticleId: string = ''
private overlayDepth: number = 0
public pushOverlay(articleId: string, source: string = 'related') {
if (this.overlayDepth === 0) {
this.rootArticleId = this.currentArticleId || articleId
}
this.overlayDepth++
this.currentArticleId = articleId
this.historyStore.push({
id: articleId,
source: source,
overlayDepth: this.overlayDepth,
timestamp: Date.now()
})
Router.pushUrl({
url: `/pages/ArticleDetailPage?articleId=${articleId}&overlay=1`
})
console.info(`[Overlay] push article=${articleId} depth=${this.overlayDepth}`)
}
public popOverlay(): void {
if (this.overlayDepth > 0) {
this.overlayDepth--
this.historyStore.pop()
console.info(`[Overlay] pop, depth=${this.overlayDepth}`)
if (this.overlayDepth === 0) {
this.restoreReadingContext()
} else {
Router.back()
}
}
}
}
这段代码的重要性在于,它明确区分了两件事:
- 当前是不是还处于右侧覆盖层阅读状态;
- 退回时到底该恢复哪一层内容。

七、第三篇真正的重点:返回以后,右侧要恢复到“刚才的样子”
第三篇里,如果用户点击一篇文章,再从文章里点相关阅读,然后按返回,最糟糕的情况不是回不去,而是回去以后状态不对。
比如:
- 回到了错误的文章;
- 回到了列表,但滚动位置丢了;
- 左侧导航突然跳回默认栏目;
- 右侧本来应该是当前栏目下的文章列表,却变成了另一个页面。
所以我给 ReadingSessionStore 增加了几个关键状态:
export class ReadingSessionStore {
private navId: string = 'all'
private listScrollOffset: number = 0
private currentArticleId: string = ''
private overlayDepth: number = 0
updateNav(navId: string) {
this.navId = navId
}
updateListScroll(offset: number) {
this.listScrollOffset = offset
}
openArticle(articleId: string, depth: number) {
this.currentArticleId = articleId
this.overlayDepth = depth
}
clearOverlay() {
this.overlayDepth = 0
this.currentArticleId = ''
}
}
它看起来很普通,但它解决的是第三篇最核心的体验问题:
用户从哪个栏目来、当前右侧打开了哪篇文章、现在覆盖到了第几层、退回以后应该回到什么状态。
如果没有这层状态,第三篇所有“固定导航”和“覆盖切换”都只是表面形式。
八、文章列表页不再只是列表,它要给覆盖层提供“稳定底板”
第三篇里,ArticleListPage 的角色也和前两篇不一样了。
前两篇里它更多负责“点一下,打开详情”;现在它还要承担一个隐藏职责:成为覆盖层下面那块始终可恢复的底板。
所以我会让它主动保存列表状态,比如滚动位置和当前筛选条件。
@Component
export struct ArticleListPage {
@Prop navId: string = 'all'
@State keyword: string = ''
@State scrollOffset: number = 0
onOpenArticle: (articleId: string) => void = () => {}
build() {
Column() {
this.buildToolbar()
List({ space: 10 }) {
ForEach(this.filteredArticles(), (item: ArticleItem) => {
ListItem() {
ArticleListItem({ item })
}
.onClick(() => {
this.onOpenArticle(item.id)
})
}, (item: ArticleItem) => item.id)
}
.onScroll((x: number, y: number) => {
this.scrollOffset = y
ReadingSessionStore.getInstance().updateListScroll(y)
})
.layoutWeight(1)
}
}
}
为什么这一层值得专门写?
因为第三篇的“返回恢复”不是一句口号,而是确实要落到组件状态上。
如果列表页自己都不知道刚才滚到了哪、当前是什么筛选条件,那覆盖层再优雅,返回体验也还是假的。
九、ArticleDetailPage 也要配合“覆盖模式”改写,不然返回逻辑会很别扭
如果说第三篇前半段在解决“外部结构”,那到了 ArticleDetailPage,真正要解决的就是“内部交互”。
因为右侧详情页现在不再是一个孤立页面,它必须知道自己可能处于两种状态:
- 普通详情页;
- 右侧覆盖层详情页。
这两种状态在 UI 上可能看起来差不多,但交互完全不同。
举个最直接的例子:顶部返回按钮。
- 如果它是普通详情页,返回可能意味着退出整个详情页面;
- 如果它是右侧覆盖层,返回只应该关闭当前覆盖层,恢复下面那层内容。
所以我会给 ArticleDetailPage 补一个明确的入参:
@Component
export struct ArticleDetailPage {
@Prop articleId: string = ''
@Prop isOverlay: boolean = false
onClose: () => void = () => {}
onOpenRelated: (articleId: string) => void = () => {}
@State article?: ArticleItem
aboutToAppear() {
this.article = MockData.getArticleById(this.articleId)
Logger.info(`[Detail] appear article=${this.articleId}, overlay=${this.isOverlay}`)
}
build() {
Column() {
this.buildHeader()
this.buildContent()
this.buildRelated()
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.borderRadius(this.isOverlay ? 20 : 0)
.shadow(this.isOverlay ? ShadowStyle.OUTER_DEFAULT_MD : ShadowStyle.NONE)
}
}
然后在标题栏里,返回按钮的行为就能变得非常清楚:
@Builder
private buildHeader() {
Row({ space: 12 }) {
Button({ type: ButtonType.Normal }) {
Image($r('app.media.ic_back')).width(20).height(20)
}
.onClick(() => {
if (this.isOverlay) {
this.onClose()
} else {
router.back()
}
})
Text(this.article?.title ?? '')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
}
}
这段代码看上去没多复杂,但它其实是在告诉读者一件很重要的事:
第三篇的“覆盖切换”不是视觉遮盖,而是一种明确的页面工作模式。
十、我还专门处理了一个细节:左侧换栏目时,要不要把右侧覆盖层一起关掉
这个问题在实际体验时非常容易被忽略。
假设我现在在左侧选中了“开发实践”,右侧打开了一篇文章详情。此时如果我再点左侧“行业观察”,右侧应该发生什么?
我最终给 Demo 定的规则是:
- 左侧栏目一旦变化,右侧当前覆盖层全部关闭;
- 右侧主内容区切换到新栏目对应的文章列表;
- 新栏目成为新的阅读起点。
这样做的原因很简单:栏目切换本质上是在改变“工作上下文”。
如果继续保留旧覆盖层,用户很容易看到一种别扭状态:左边已经是“行业观察”,右边却还在读“开发实践”的文章。
所以 onNavSelect() 里,我会把逻辑写得很明确:
private onNavSelect(id: string) {
this.selectedNavId = id
ReadingSessionStore.getInstance().updateNav(id)
if (this.overlayVisible) {
this.overlayVisible = false
this.overlayArticleId = ''
OverlayRouteService.getInstance().reset()
}
Logger.info(`[Nav] switch nav=${id}, overlay cleared`)
}
我特别喜欢这段逻辑,因为它把产品规则直接翻译成了代码:
- 导航切换优先级高于当前详情;
- 栏目切换意味着重新开始一条阅读路径;
- 右侧覆盖层只是当前栏目下的局部阅读状态,而不是全局状态。
十一、第三篇其实还顺手完成了一次“局部状态机”设计
写到这里,我越来越觉得第三篇的重点已经不只是 ArkUI 页面搭建了。
因为“左侧固定导航 + 右侧覆盖切换 + 返回恢复”这整套过程,本质上很像一个小型状态机。
我把它抽象成下面这几种状态:
State A:左侧导航 + 右侧文章列表
State B:左侧导航 + 右侧文章详情(覆盖层深度 1)
State C:左侧导航 + 右侧文章详情(覆盖层深度 2+)
State D:左侧导航切换后,右侧重新回到对应栏目列表
它们之间的转移关系其实非常清晰:
A --点击文章--> B
B --点击相关阅读--> C
C --返回--> B
B --返回--> A
A/B/C --切换左侧导航--> D
D ≈ 新的 A
一旦你用这个视角看第三篇,会发现很多看似零散的代码,其实都在服务同一件事:
FixedNavLayout负责触发导航状态变化;ArticleListPage负责从列表进入覆盖层;OverlayRouteService负责组织 B/C 两类覆盖层状态;ReadingSessionStore负责让 A/B/C/D 之间的恢复变得可靠。
我觉得这也是社区文章里特别值得强调的一点:
真正有工程味的 Demo,不是页面越多越厉害,而是状态关系越来越清楚。
十二、纯大屏效果图里,这一篇最该展示的不是详情,而是“结构稳定”
如果只看截图,第三篇最容易被误解成“还是个左右双栏页”。
但我最希望读者看到的是这三个事实:
- 左侧导航保持固定,不跟着右侧变;
- 右侧当前正在展示详情;
- 返回以后,右侧能够回到原来的列表上下文。
所以第三篇的无设备边框大屏图,我特意让“导航稳定感”更突出。左边是清楚的栏目体系,右边是当前打开的文章详情,中间关系非常明确。

这张图其实就是第三篇最核心的“结果证明”。
十三、类型二综合讲解图,特别适合把第三篇的结构关系讲透
第三篇我继续保留类型二综合讲解图,而且我觉得它比前两篇更必要。
因为“固定导航 + 覆盖切换”这件事,单看某一张 DevEco 截图、某一张运行图,都很难一眼看懂完整关系。
但放到一张综合图里,就很容易讲清楚:
- 第一块:DevEco 里怎么搭左侧固定导航;
- 第二块:大屏最终运行效果是什么样;
- 第三块:路由和交互流程怎么走;
- 第四块:真机平板上长什么样;
- 第五块:覆盖切换和返回恢复的关系。
这种图非常适合社区技术文章。它不像营销海报那么花,但信息密度高,而且读者一张图就能建立结构理解。

十四、我怎么判断第三篇是不是写对了
第三篇的验收标准,我比前两篇更在意“局部正确”。
1)左侧导航必须真正固定
不管我在右侧打开多少篇文章、怎么返回,左侧导航都不能乱跳、不能被重建得像换了一整页。
2)右侧文章详情必须以覆盖方式打开
覆盖层打开时,用户应该明确感受到:
- 左侧没动;
- 右侧进入了新的内容状态。
而不是像整页跳转一样把整个界面都带走。
3)返回只恢复右侧,不影响左侧导航上下文
这是第三篇最关键的一条。
我会重点看这条链路:
选择“开发实践”
↓
打开某篇文章
↓
打开相关阅读
↓
返回
↓
再返回
这时候,左侧仍然应该停留在“开发实践”,右侧则按顺序恢复到上一层文章、再恢复到文章列表。
4)列表滚动位置和筛选状态不能白丢
如果文章列表滚到一半,用户点了一篇文章,再返回,结果列表从头开始,那第三篇这套覆盖切换就只做到了表面。
5)日志能准确还原右侧覆盖状态变化
我会特别看底部日志:
[Overlay] push article=002 depth=1
[Overlay] push article=005 depth=2
[Overlay] pop depth=1
[Overlay] pop depth=0
[Route] restore root article id=001
这种日志非常有说服力,因为它证明你不是“碰巧切回去了”,而是真正组织好了覆盖层状态。
十五、为了让第三篇更可信,我专门补了一组调试日志与验收用例
我后来发现,像这种“左侧固定、右侧覆盖”的场景,只靠截图其实很难说明问题。因为截图只能告诉别人“看起来是这样”,却不能证明状态流转真的没问题。
所以第三篇我刻意补了一组更工程化的验收用例:
用例 1:默认进入首页,左侧应选中“全部文章”,右侧显示文章列表。
用例 2:点击“开发实践”,左侧高亮切换,右侧列表刷新,覆盖层为空。
用例 3:点击某篇文章,右侧打开详情覆盖层,左侧导航不变。
用例 4:在详情页点击相关阅读,右侧继续覆盖切换,overlayDepth + 1。
用例 5:返回一次,应恢复上一层详情;返回第二次,应恢复文章列表。
用例 6:详情打开时切换左侧栏目,应清空覆盖层并进入新栏目列表。
同时我也会用日志去核对:
[Nav] switch nav=practice
[List] open article=article-003
[Overlay] push article=article-003 depth=1
[Overlay] push article=article-007 depth=2
[Overlay] pop depth=1
[Overlay] pop depth=0
[Nav] switch nav=industry, overlay cleared
这套日志最大的价值,不是为了“好看”,而是为了帮助我确认两件事:
- 左侧导航状态和右侧覆盖状态有没有串线;
- 返回与栏目切换是否真的遵守了第三篇设定的产品规则。
我越来越觉得,像这种平行视界类文章,适当把测试思路写出来,读者会更容易相信这个 Demo 不是摆拍,而是真跑过、真调过的。
十六、实机上最大的感受,是阅读更像“查资料”,而不是“钻页面”
第三篇在真机平板上跑起来以后,我的感受比模拟器里更明显。
因为在大屏设备上,左侧固定导航真的会给人一种“工作台”的感觉:
- 左边像目录;
- 右边像阅读区;
- 我始终知道自己在什么栏目;
- 右边可以放心一层层读,不怕迷路。
这和手机上那种“点进去—返回—再点进去”的节奏是很不一样的。
更准确地说,第三篇让“双栏研读台”开始从“一个双栏页面”变成“一个有工作面结构的阅读工具”。

十七、第三篇解决了什么,也给第四篇留下了什么
如果把前三篇串起来看,我会这样理解:
- 第一篇解决“能不能先把双栏骨架搭起来”;
- 第二篇解决“阅读链能不能连续往前走”;
- 第三篇解决“导航区和内容区能不能真正分工”。
而且从文章组织角度看,第三篇也是一个很好的分界点:从这一篇开始,这个 Demo 不再只是“页面能不能显示”,而是开始进入“交互规则是否完整、状态恢复是否可靠、工程拆分是否清楚”的阶段。
第三篇做到这里,我已经比较满意了,因为:
- 左侧导航稳定下来;
- 右侧内容切换方式明确下来;
- 返回恢复逻辑也开始靠谱。
但它还没解决另外几个非常现实的问题:
- 不同屏幕宽度下,左侧固定导航到底该占多少比例;
- 平板和折叠屏展开态,右侧覆盖区宽度要不要一致;
- 横竖切换时,左侧和右侧状态怎么同步保留;
- 设备被系统回收以后,整套阅读状态怎么恢复。
这些问题其实都很适合下一篇继续写。
十八、本文小记
如果第二篇更像“继续往里读”,那第三篇更像“开始把工作台真正搭像了”。
这篇最重要的变化,不在于我多写了几个 ArkTS 文件,而在于我终于把这套 Demo 里的导航职责和内容职责分开了:
- 左侧不再只是顺手放一个列表,而是真正的固定导航区;
- 右侧也不再只是单纯的详情页,而是支持覆盖打开、返回恢复的内容工作区。
我很喜欢这种变化。因为它让“双栏研读台”更像一个会被真实使用的大屏应用,而不是一个为了演示双栏而硬拼出来的示例。
第三篇做到这里,我觉得这一条产品线已经顺了。
下一篇,我准备继续往更工程化的问题上走:
不同分栏比例、不同设备尺寸和状态同步,如何让“双栏研读台”在平板与折叠屏上都看起来合理?
更多推荐


所有评论(0)