本文给出的是一套可移植的工程实现思路。截图均为人工制作的界面示意图,模拟日志与尺寸为预期示例,不代表已经使用 DevEco Studio 编译或在实体设备上测得数据,非真机测试结果。

同一个详情,为什么返回键要按两次

做素材审阅类应用时,左边是条目列表,右边是详情,本来很符合展开态折叠屏的操作习惯。真正麻烦的不是把两栏摆在一张大屏里,而是用户持续点选不同条目时,导航栈会悄悄变厚:从 N-204 点到 N-205,再点击 N-205,一些实现每次都 push 一个新的详情页。眼前仍是 N-205,看似没问题;返回后却突然出现前一个详情,甚至要多次返回才能回到列表。普通手机上问题表现成返回链很长,在分栏布局中则像右侧内容莫名其妙回退。我把这个问题拆成“选中对象”“导航页面”和“设备形态”三个变量,目的是让右栏每次只承载一个当前详情,而不让点击次数变成路由历史。

Demo 取名 SplitLedger,测试输入固定为四个业务编号 N-203、N-204、N-205、N-206。文章只用这四项说明交互,不把它描述为来自服务器的真实清单。用例 PS-202 先点 N-204、再点 N-205、再点一次 N-205,目标轨迹是 PUSHED → REPLACED → SKIP_SAME,路由栈长度始终保持 1。这个约束是产品决策:审阅详情之间的横向切换不需要保留浏览足迹。如果做的是文章阅读史或工单审批追溯,历史页面需要被返回键逐级访问,那就不应该套用这里的替换策略。

官方《Navigation分栏开发》把 Navigation 描述为可以使用单栏、分栏和自适应模式的路由容器,分栏时左侧 NavBar 常驻、右侧通过 NavDestination 呈现内容。这里的“平行视界”采用 Navigation 分栏风格实现双栏业务体验,不等于调用了某个独立的系统级平行视界服务。展示宽度、窗口是否有资格分栏,以及横竖屏切换效果,都需要在目标设备上复验。框架文档的分栏模式没有承诺能帮应用自动做业务 ID 去重;这是本文自行补的一层规则。

把业务选择和页面历史分开

第一处代码放在 model/RouteGate.ets。它不碰页面布局,只做三项判定:空栈时首次打开详情、已有详情时替换栈顶、相同编号重复点击则跳过。getAllPathName() 返回的是路由名称集合,并不是业务编号集合,所以“当前是不是同一条”要依赖另存的 selectedId;不能靠页面名恒等于 Detail 来判断。为便于阅读,这段示例约定路由栈只用于当前详情,其他业务页面不要共用此栈。

// model/RouteGate.ets:仅管理详情这一类 NavDestination。
export type SelectAction = 'PUSHED' | 'REPLACED' | 'SKIP_SAME';
export interface DetailParam { id: string }

export function selectDetail(stack: NavPathStack,
  selectedId: string, nextId: string): SelectAction {
  const hasDetail = stack.getAllPathName().length > 0;
  if (hasDetail && selectedId === nextId) return 'SKIP_SAME';
  const param: DetailParam = { id: nextId };
  if (hasDetail) {
    stack.replacePath({ name: 'Detail', param });
    return 'REPLACED';
  }
  stack.pushPath({ name: 'Detail', param });
  return 'PUSHED';
}

注意返回动作会清空详情栈,界面不应该再把上一次的 selectedId 当作当前已打开页面。下一次选择同编号时,空栈检查在前,可以重新 PUSHED,不至于卡在只更新高亮却没打开详情的状态。另一个容易遗漏的约束是点击速度:这里的 API 是直接同步提交导航栈操作的写法,如果项目同时引入网络查询与动效,就需要把并发的网络结果按请求序号过滤,不允许旧请求回来时覆盖新选中的 N-205。路由去重解决不了请求竞态,二者不能混成一个布尔锁。

在 Navigation 里让宽屏分栏、窄屏单栏

页面主体不用手工把两个 Navigation 叠在 Row 里。将列表放在 Navigation 的 NavBar 内容区,详情挂到 navDestination 构建器;折叠态和手机窄屏可采用 Auto 模式自动呈现单栏。下面的写法突出路由动作与状态同步,省略了图标、国际化和后台数据请求。每个 ListItem 都用业务 ID 做稳定 key,筛选顺序变化时不会错把列表位置当作条目身份。例子里的日志是为了说明埋点字段,不代表 IDE 已实际输出。

import { selectDetail, SelectAction } from '../model/RouteGate';

@Entry
@Component
struct Index {
  private pathStack: NavPathStack = new NavPathStack();
  @State selectedId: string = '';
  private ids: string[] = ['N-203', 'N-204', 'N-205', 'N-206'];

  @Builder detailMap(name: string) {
    if (name === 'Detail') DetailPage();
  }

  build() {
    Navigation(this.pathStack) {
      List() {
        ForEach(this.ids, (id: string) => {
          ListItem() {
            Text(id + (this.selectedId === id ? ' · 已选中' : ''))
              .padding(20).onClick(() => {
                const action: SelectAction =
                  selectDetail(this.pathStack, this.selectedId, id);
                this.selectedId = id;
                console.info(`PS-202 ${id} ${action} depth=${this.pathStack.getAllPathName().length}`);
              });
          }
        }, (id: string) => id)
      }
    }
    .mode(NavigationMode.Auto)
    .navBarWidth('42%')
    .minContentWidth(320)
    .navDestination(this.detailMap)
    .width('100%').height('100%');
  }
}

navBarWidth('42%') 是示意的产品选项,并非所有窗口下都适合用百分比;窄到无法满足内容区最小宽度时框架会受到空间约束,应以实际窗口尺寸和无障碍文字缩放测试为准。本文还刻意不使用绝对像素作为“展开态”的唯一判据。浮窗、桌面模式、屏幕旋转都可能改变应用真实可用宽度,而手机型号名称不会告诉我们窗口还剩多少。即使大屏进入分栏,右边的空态也应该能解释“从左侧选一项”,而不是先加载某个隐形默认详情,让用户误以为已选择了它。

子页面只认参数,不反向猜列表索引

DetailPage 通过 NavDestination.onReady() 接收路由参数,显示完整编号。替换路由时应拿到新的 DetailParam,页面不要把上次请求中的序号、图片位置或数组下标当成稳定身份。为了让工程结构清晰,例子用 NavDestinationContext.pathInfo.param,这是官方导航参数传递文档列出的接收方式。未来若改用单例路由复用,需考虑 onReady 只在首次建立时触发的边界,并按官方文档使用对应的参数更新机制;不要在同一篇示例里混用两种启动语义。

// pages/DetailPage.ets:演示数据由任务 PS-202 固定提供。
@Component
export struct DetailPage {
  @State currentId: string = '';
  build() {
    NavDestination() {
      Column({ space: 18 }) {
        Text('审阅详情').fontSize(26)
        Text(this.currentId).fontSize(32).fontWeight(FontWeight.Bold)
        Text('宽屏右栏 / 窄屏独立详情页')
      }.padding(24).width('100%')
    }
    .title('SplitLedger')
    .onReady((context: NavDestinationContext) => {
      const p = context.pathInfo.param as DetailParam;
      this.currentId = p.id;
    });
  }
}

组件示例涉及跨文件时,应在目标工程中把 DetailParam 的导入补齐,并根据工程中的 ArkTS 严格检查修正类型引用;这里展示的是关键的职责划分,不能替代可直接构建的完整工程模板。另一个细节是用户在右侧详情未加载完时又点下一条,页面应把响应绑定到 currentId 而不是单纯判断“组件还在屏幕上”。分栏界面里的右侧容器可能一直存在,存在不代表内容属于当前请求。若后台返回 N-204,当前值却是 N-205,就应该丢弃旧响应,并记录 staleId=N-204 activeId=N-205 便于定位。

如何验证:把返回键和筛选放到同一组测试里

建议先用模拟数据执行五条手工路径,再追加窗口宽度变化回归。第一条是空列表选择 N-204,预期 PUSHED depth=1;第二条是从 N-204 切到 N-205,预期 REPLACED depth=1;第三条是重复点击 N-205,预期 SKIP_SAME depth=1。第四条通过系统返回离开详情,然后再次点 N-205,预期重新进入详情而不是空白。第五条是先筛选再恢复列表顺序,始终用 N-205 的业务 ID 对齐高亮,而不是保留旧索引。示意图固定展示第三条之后的状态,不把这些期望结果写成已测通过的数据。

HiLog 排查时不要只看最后一个 push 是否成功,应该同时记录任务 PS-202、目标条目 ID、动作 PUSHED/REPLACED/SKIP_SAME 和调用后的栈长度。建议再记录当前窗口的 dp 宽度与布局模式,但不能把一次截图里的数字当成硬编码阈值。若出现“右侧标题变了但内容没变”,先核对详情构建器接收到的 param.id;若出现“返回三次才走到列表”,先核对是否遗漏了 replacePath;若出现“快速点选时旧图盖住新图”,要检查异步结果是不是缺少 activeId 比较。

工程边界与取舍

这个方案适合“主列表始终代表当前入口,右侧只显示一个详情”的工作台、审阅台和目录工具。它不适合需要把每次详情切换都视为独立历史节点的浏览器,也不适合在同一个 NavPathStack 中再塞入弹窗、编辑页等多级流程,否则“有页面就 replace”可能误替换其他目的页面。真实项目应查询 getAllPathName() 最后一项并只在目标为 Detail 时进行此策略,或为其他页面设计清晰的路由协议。示例故意限定为专用详情栈,避免用一段过于神奇的通用封装掩盖前提。

最后要记住,布局模式是视图呈现,业务选中态是应用数据,导航栈是访问路径;三者相互关联,却不是同一对象。把去重放在路由入口,把选中 ID 保存在页面状态,把数据响应与当前 ID 绑定,即使从单栏切回分栏,逻辑也能被逐项推演。是否真正达成折叠设备上的连续体验,仍需在目标 SDK、真机/模拟器及文字放大场景中对照检查,本文没有声称完成这些测试。

官方资料(核对日期:2026-10-20)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐