HarmonyOS Navigation 分栏首屏占位与目录路由衔接【共创稿事节】
在大屏阅读应用里,双栏布局最容易被忽略的并不是左右宽度,而是右侧还没有任何详情页面时应该显示什么。目录已经加载完毕,读者尚未选择文章,右侧留着一大片空白,会让整个页面看起来像内容缺失。若为了消除空白而在启动时强行打开第一篇文章,又会把“默认展示”误写为“读者已经选择”。这两种处理都可能影响后续的阅读位置恢复、返回路径和目录高亮。
阅读工作台采用一份目录数据驱动导航列表,右侧详情则交给 Navigation 管理。初始状态用占位内容表达“请选择一篇文章”;发生实际点击时,业务层记录文章标识,再由 NavPathStack 打开相应的 NavDestination。这样设计的关键是把尚未选择与选择后进入详情分成两种状态,而不是让布局容器推测读者的意图。

一、空白右栏背后的状态问题
Navigation 的分栏模式让导航栏与详情区域并排显示。目录所在的 NavBar 可以长期留在左边,详情页通过路由栈进行切换。组件本身负责页面的承载与切换,业务上哪篇文章被视为当前选择,则需要应用保存明确的文章标识。两者属于不同层级的状态,不能用“右侧当前可见的文字”反过来判断业务选择。
本文的阅读目录有三条记录:“峡谷行记”“城市光谱”“云端书签”。每条记录有稳定的 id、标题、摘要和正文。最初 selectedId 为空字符串,栈中也没有详情页。点击“峡谷行记”后,selectedId 改为 canyon,再向栈压入名为 ReaderDetail 的路由,参数只携带 id。随后界面显示文章详情,左侧对应目录项增加高亮。
把整个 Article 对象都塞进路由参数看起来省事,但它会让打开中的详情携带一份过期的数据快照。后续目录更新、搜索过滤、文章修改时,详情中的文本可能与列表不一致。路由仅保存稳定主键,更利于后续从数据仓库重新查询。这里先使用进程内目录数组,正式应用可替换为持久化数据源,选中语义无需改变。
二、先让目录拥有明确的数据身份
代码从一个小而稳定的模型开始。数据定义包含 id,而非依赖数组下标定位。数组重排时第一个元素的位置会改变,业务标识不应变化。标题负责展示,摘要负责目录预览,正文用于详情视图。这一层保持普通 ArkTS 类,不引入页面生命周期,方便后续给数据层增加分页和缓存。
export class ReaderArticle {
constructor(
public id: string,
public title: string,
public summary: string,
public body: string
) {}
}
export const catalog: ReaderArticle[] = [
new ReaderArticle('canyon', '峡谷行记', '关于山谷与河道的观察', '清晨的河道与岩层构成阅读正文。'),
new ReaderArticle('metro', '城市光谱', '城市步行路线与光影', '傍晚沿街区行走可以看到不同的光照。'),
new ReaderArticle('cloud', '云端书签', '跨设备阅读清单整理', '文章记录了书签整理与检索习惯。')
];
代码没有把业务 id 生成为随机字符串。对于已有内容库,id 应由内容源稳定分配;对于本地新建内容,也应在创建时确定并持久化。不能把列表顺序当成 id,因为搜索和排序都会改变顺序。若后续接入远端更新,应在导入时先处理重复主键,再发布新的目录集合,避免同一个标识对应两篇文章。
有了稳定目录,页面只需考虑两个事件:读者点击目录,以及详情退出后如何恢复视觉反馈。当前这篇聚焦第一次进入与第一次选择,不把未来的返回恢复、重复点击防护一股脑写进首屏逻辑。系列后续会在相同的目录模型上逐步加入这些能力,保持主干工程可延续。
三、占位内容与路由栈各管一件事
右侧默认提示并不应该是伪造的详情页面。Navigation 提供 splitPlaceholder 属性,可以为分栏且内容栈为空的情形设置占位 UI。占位内容通过 ComponentContent 承载;真正的文章详情仍由 navDestination 和 NavPathStack 管理。下面把三者放在同一个页面里,省去另外创建隐藏页面的复杂度。
import { ComponentContent } from '@kit.ArkUI';
import { catalog, ReaderArticle } from '../model/ReaderArticle';
class ReaderRoute {
constructor(public id: string) {}
}
@Builder
function EmptyReadingPane() {
Column({ space: 14 }) {
Text('从目录选择文章').fontSize(26).fontWeight(FontWeight.Bold)
Text('右侧将显示所选文章的完整内容').fontSize(16)
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
}
@Entry
@Component
struct Index {
private pathStack: NavPathStack = new NavPathStack();
private placeholder = new ComponentContent(this.getUIContext(), wrapBuilder(EmptyReadingPane));
@State selectedId: string = '';
@Builder
pages(name: string, param: object) {
if (name === 'ReaderDetail') {
ReaderDetail({ articleId: (param as ReaderRoute).id });
}
}
build() {
Navigation(this.pathStack) {
List({ space: 8 }) {
ForEach(catalog, (item: ReaderArticle) => {
ListItem() {
Column({ space: 6 }) {
Text(item.title).fontSize(19).fontWeight(FontWeight.Medium)
Text(item.summary).fontSize(13).fontColor('#64748B')
}
.width('100%').padding(16)
.backgroundColor(this.selectedId === item.id ? '#E8F0FF' : '#FFFFFF')
.onClick(() => {
this.selectedId = item.id;
this.pathStack.pushPathByName('ReaderDetail', new ReaderRoute(item.id));
})
}
}, (item: ReaderArticle) => item.id)
}.width('100%').height('100%')
}
.title('阅读工作台')
.navDestination(this.pages)
.mode(NavigationMode.Split)
.splitPlaceholder(this.placeholder)
}
}
selectedId 仅用于左侧高亮,pathStack 承担页面路由。两者在一次点击回调中更新,但并不混合为同一个变量。当栈为空时,占位内容负责提示;入栈后由 NavDestination 占据详情区域。该属性的使用条件值得注意:占位页面只承担内容展示,不属于路由栈中的可返回页面,也不需要在其中做点击跳转或焦点管理。
.mode(NavigationMode.Split) 适合作为本阶段对分栏行为的明确选择。进入多尺寸适配阶段后,可以将模式调整为 NavigationMode.Auto,把单双栏切换交给 Navigation 的布局机制,但业务 selectedId 的语义仍应保持一致。不要把实际窗口宽度、分栏宽度阈值和路由标识组合成一个混杂字段。

四、详情页只消费标识,不复制目录状态
详情容器应按 id 查询当前数据,找不到时给出可理解的空状态,而不是使用列表中的上一次选中对象。这样即使内容被删除、数据暂时不可读,也不会继续渲染错误的文章。示例采用数组查找;当内容量增加,可以由索引表或持久化接口取代,但入口参数形式无需变化。
import { catalog, ReaderArticle } from '../model/ReaderArticle';
@Component
struct ReaderDetail {
@Prop articleId: string = '';
build() {
NavDestination() {
Column({ space: 18 }) {
if (catalog.find((item: ReaderArticle) => item.id === this.articleId)) {
Text(catalog.find((item: ReaderArticle) => item.id === this.articleId)?.body ?? '')
.fontSize(18).lineHeight(30)
} else {
Text('文章暂不可用').fontSize(18).fontColor('#64748B')
}
}.padding(24).width('100%')
}
.title(catalog.find((item: ReaderArticle) => item.id === this.articleId)?.title ?? '阅读详情')
}
}
为了清晰呈现分栏主体,这里没有放入注释、附件或进度栏。find() 在短目录中足够直观,页面数据量变大以后应将查找结果缓存为一次读取,避免标题和正文分别遍历集合。详情不存在时给出“文章暂不可用”,同时应提供返回目录的操作路径;它属于数据空缺处理,不应误当作导航容器的页面构建失败。
需要进一步考虑资源释放:ComponentContent 绑定 UIContext,页面结束时应依据实际拥有关系销毁对应内容对象;异步订阅目录变化时,应在组件消失或对应状态结束时解除订阅。当前占位页只有静态文本,没有定时器和外部监听,因此不会引入额外的后台任务。若未来改为推荐流或实时预览,这一点将成为重要的生命周期约束。
五、用可复核的状态序列检查页面行为
按照上述模型,可以设计四个直接观察的状态点。初始进入时 selectedId 为空,路由栈也为空,左侧显示三篇目录,右侧是“从目录选择文章”。点击“峡谷行记”后,selectedId 变为 canyon,路由参数携带 canyon,右侧标题和正文对应这篇文章。再次把详情替换为“城市光谱”时,应该以 metro 为主键读取内容,而不是使用数组中的第二个位置作为页面身份。

检查重点不在于特定的毫秒耗时,而在于语义是否一致:右侧出现详情之前,不应存在人为的默认选中;用户明确选择之后,标题、正文、目录高亮都应对应同一 id;目录数据缺项时,应稳定呈现空状态而不是残留上一篇文章。页面关闭与返回流程还需要结合路由栈的实际生命周期继续处理,这会在后续的阅读位置恢复与多形态切换章节中展开。
如果产品要求首次打开就直接显示上次阅读内容,可以在持久化恢复完成后主动打开该文章。这与单纯为了填补空白而打开目录第一条有本质区别:前者有明确的用户历史状态,后者没有。恢复失败时仍应回到占位内容,以免误导读者当前阅读位置。
六、将结构留给后续连续迭代
这套工作台目前形成了稳定的三层结构:目录负责展示和选择,Navigation 负责详情页面生命周期,数据模型负责根据主键找到正文。首屏占位被处理成独立的显示状态,因此后面无论加折叠屏窗口切换、左栏搜索还是草稿保留,都不需要推翻第一步的交互语义。
实际工程中还应增加路由重复入栈处理、返回后的选中态同步、目录变更时的失效标识处理。它们分别对应不同的状态迁移,不适合用一段笼统的点击处理覆盖。先把首屏与首次选中的关系定义清楚,后续调试就能围绕稳定的 id 和路由状态定位问题。
可参考华为开发者文档中的 Navigation 分栏开发、Navigation 分栏右侧留白问题 与 组件导航概述。
更多推荐



所有评论(0)