双栏阅读工作台已经能够在目录中打开文章,并且能够识别重复点击,避免相同的详情页面连续压入导航栈。不过,读者在“峡谷行记”“城市光谱”“云端书签”之间来回浏览时,另一类状态偏差会出现:右侧详情已经退回前一篇,左侧高亮仍停留在离开的文章上。视觉上的选中项与实际阅读位置分离,接下来的点击、搜索和阅读记录保存都会受到影响。

从实现上看,目录高亮由 selectedId 控制,详情历史则交给 NavPathStack 保存。前两阶段已经把文章身份固定为 canyon、metro、cloud,也将打开文章集中到 openReader()。本阶段不改变目录模型和路由名称,而是增加一条明确的规则:完成导航返回后,以当前栈顶的文章标识重新决定高亮;栈为空时恢复未选择状态。这样屏幕上显示的选择关系才能跟随实际导航历史变化。

阅读工作台返回链路

一、返回不是简单地清空选中项

假设读者依次打开“峡谷行记”“城市光谱”“云端书签”。此时路由历史对应 canyon → metro → cloud,目录高亮为 cloud。返回一次后详情恢复“城市光谱”,此时应高亮 metro;再返回一次应高亮 canyon;退出最后一篇后,分栏内容区展示首屏占位,目录中没有任何高亮条目。这个变化是一个连续的路径回收过程,而非单次清空状态。

若返回时直接把 selectedId 改为空,读者还停留在某篇文章上时,目录会失去位置提示。若返回时改成上一次点击记录,又会在历史路径发生变化时出现错误:用户可能从检索页跳入文章,也可能通过其他入口打开详情,点击历史与路由历史不一定重合。应用需要从导航容器读取当前有效的页面,而不是自行维护一条容易漂移的“上次点击”链。

还要区分页面名称与业务主键。ReaderDetail 是导航目的地名称,多篇文章共享这个名称;真正区分内容的是参数中的 id。因此仅检查路由栈顶是否为 ReaderDetail 不够,还必须读取参数并核对它是否仍属于目录。否则某个失效主键会让高亮停留在不存在的列表项上,界面也无法提示读者下一步应该如何选择。

这一阶段保留已有的三个目录条目和右侧占位组件。页面中的返回按钮、系统返回操作和未来的键盘快捷键,都应该汇入同一套返回状态同步逻辑。分栏模式不会自动知道哪个目录条目应高亮,这仍是业务层需要完成的职责。

二、建立从栈顶读取文章身份的方法

此前的 ReaderRoute 只携带文章主键,正适合继续使用。读取栈顶时使用 getAllPathName() 得到页面名称序列,再由 getParamByIndex() 取得对应参数。两者均属于 NavPathStack 提供的路径查询方法。查询结束后还要用目录中的稳定标识进行一次有效性判断,防止已删除内容留下失效选择。

export class ReaderRoute {
  constructor(public id: string) {}
}

const availableIds: Set<string> = new Set(['canyon', 'metro', 'cloud']);

export function topReaderId(stack: NavPathStack): string {
  const names: string[] = stack.getAllPathName();
  if (names.length === 0) {
    return '';
  }
  const last: number = names.length - 1;
  if (names[last] !== 'ReaderDetail') {
    return '';
  }
  const route = stack.getParamByIndex(last) as ReaderRoute | undefined;
  return route && availableIds.has(route.id) ? route.id : '';
}

这个函数的返回值有三种意义:有效主键表示当前详情可与目录建立关联;空栈返回空字符串,指向初始占位状态;栈顶页面不属于阅读详情或参数已失效,也返回空字符串。统一采用字符串返回值,是为了让 @State selectedId 不必额外处理可空对象,同时把不适合高亮的情况收敛到同一个入口。

需要注意,目录内容未来可能改为网络分页或者本地数据库。到那时,availableIds 应由当前可阅读的数据集合生成,不能长期写死三个主键。查验主键时既要考虑目录中的可见项,也要考虑尚在历史栈内但暂时被筛选隐藏的文章。筛选隐藏并不等于文章删除,若把两者混在一起,会导致返回时错误清除仍然有效的阅读位置。

三、把返回动作与高亮更新放在同一个位置

最容易维护的方式是在承载 Navigation 的组件中保留唯一的状态同步入口。打开详情后调用同步方法,返回时完成 pop() 再调用同一方法。这里不在目录行组件里保存独立的选中副本;目录条目的颜色始终从 selectedId 计算,避免列表刷新后产生第二套状态来源。

下面是承载组件中的核心方法,和上一阶段的 openReader() 配合使用。stack 与 selectedId 是阅读工作台已有的字段,页面构建部分仍沿用此前的 Navigation、ReaderDetail 与首屏占位配置。

private stack: NavPathStack = new NavPathStack();
@State selectedId: string = '';

private syncSelection(): void {
  this.selectedId = topReaderId(this.stack);
}

private openReader(id: string): void {
  if (!availableIds.has(id) || topReaderId(this.stack) === id) {
    return;
  }
  this.stack.pushPath({ name: 'ReaderDetail', param: new ReaderRoute(id) });
  this.syncSelection();
}

private returnToPrevious(): void {
  if (this.stack.getAllPathName().length === 0) {
    return;
  }
  this.stack.pop();
  this.syncSelection();
}

第二段代码的重点并不是 pop() 这一行,而是状态更新的先后顺序:先改变路由历史,再读取新的栈顶。若反过来,在出栈前读取参数,就会把刚刚离开的文章继续保留为选中项。按钮事件需要调用 returnToPrevious();如果详情页自己处理返回,也应通过同一套规则核对栈内主键,而非在组件销毁时直接把高亮改为空。

NavDestination 提供页面返回相关的回调能力,例如 onBackPressed,可用于业务页面处理返回操作;onResult 则用于接收其他目的地返回的结果。两类回调有不同语义,不能把收到返回结果误认为选中态必然已经同步。对阅读工作台而言,决定高亮的数据始终是当前导航历史对应的文章 id。

目录选中态与导航回收的开发环境

四、把连续返回序列写成可重复检查的输入

只靠手动点击一次不容易发现状态漂移。更直接的办法是将目录和路径变化整理为一组输入序列,对每一步的可见文章与高亮主键进行比较。业务规则可以用独立的 JavaScript 检查脚本复用,避免把界面是否重绘与规则本身是否正确混为一谈。

const valid = new Set(['canyon', 'metro', 'cloud']);
const history = ['canyon', 'metro', 'cloud'];
const current = () => valid.has(history.at(-1)) ? history.at(-1) : '';
const steps = [];
while (history.length) {
  history.pop();
  steps.push(current());
}
console.log(steps.join(' -> ') || '(empty)');
console.log(steps.join('|') === 'metro|canyon|' ? 'PASS' : 'CHECK');

按照这个输入,三个返回节点依次得到 metro、canyon 和空字符串。最后一个空值并非异常,而是回到双栏默认占位的业务状态。另一组值得加入的序列是 canyon → canyon:前一阶段已去除连续重复打开,因此它应该保持一条详情记录,返回后直接进入占位状态,不能凭重复点击次数产生额外返回层级。

需要把“返回后高亮正确”与“返回前数据有效”分开检查。若数据仓库删除 metro,但其历史仍保留在栈中,详情应由读取层给出内容失效处理,高亮应避免指向错误的条目。若目录只是按关键词过滤掉 metro,底层文章仍存在,详情继续保持业务主键,在搜索条件恢复时可以重新定位目录项。

五、多入口和布局变化下的处理原则

在大屏阅读场景中,返回动作可能来自标题栏按钮、侧滑操作、系统返回以及键盘快捷键。它们最后都影响同一个 NavPathStack,业务页不能把高亮更新限定在某一个按钮的回调内。对于应用自己控制的返回按钮,可在操作后立即读取路径;对于框架控制的返回流程,应结合目的地回调或导航变化监听,在路径完成调整后刷新选中项。

如果使用 onBackPressed 自行接管返回,需要明确该回调的返回值与默认返回行为之间的关系,避免既主动执行 pop() 又让框架继续出栈造成双重返回。可在完整的导航页面里统一确定返回所有权:自定义处理就消费事件,交由框架处理就通过导航状态更新机制同步业务选择,不混合两套出栈动作。

另一个容易被忽视的问题是动画时序。selectedId 更新后目录会重新绘制,但右侧详情可能仍处于转场阶段。高亮语义应该依据确定的路径状态,而不是通过固定延迟等待动画结束。人为设置等待时间会随着设备负载变化而变得脆弱。对阅读工作台来说,栈状态是逻辑真相,动画只是视觉表现。

当设备从宽屏切到窄屏时,Navigation 可能采用不同的布局模式。详情是否与目录同时可见会变化,但文章的稳定主键不应变化。进入单栏后目录暂时不可见,也不能据此把 selectedId 清空;返回到目录时,应仍能找到与当前路径相符的那篇文章。这也是后续多形态章节可以复用本次同步方法的原因。

六、让检查结果具备明确的可读性

在应用页面里,可把左栏条目的选中底色与右栏标题一起作为观察点。依次进入三篇文章,然后逐层返回,期望的对应关系为:“云端书签”高亮 cloud,“城市光谱”高亮 metro,“峡谷行记”高亮 canyon,最终回到没有高亮项的占位布局。只有路径和界面使用同一个主键,后续滚动位置与草稿归属才不会产生错配。

返回后目录与详情的对应关系

可以把回归用例拆为正常返回、连续快速返回、空栈返回、删除文章后的返回、目录过滤后的返回,以及不同入口到达同一详情后的返回。空栈返回应保持状态不变;快速操作不能造成越界查询;非法主键应停止高亮更新并交给内容失效流程处理。检查日志只需要记录路径名称、业务主键和状态变化,不必输出完整阅读正文。

从维护成本看,本阶段没有新增另一套导航历史,而是把已有路由历史用于推导目录选中态。读者打开什么、返回到哪里、目录高亮谁,都围绕同一个文章 id 展开。将这层关系固定下来后,下一阶段可以继续处理折叠屏窗口宽度切换和分栏位置变化,而不需要重写返回逻辑。

参考华为开发者文档:Navigation 分栏开发、Navigation 参数与返回、NavDestination 生命周期。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐