折叠屏应用的分栏适配,最容易被忽略的并不是左右两列的宽度,而是窗口形态变化后,用户原来选中的那条业务记录还在不在。界面从单栏切回双栏,列表可以重新排布,详情却不应该被重新压栈。本文用一个工单工作台说明这层边界:布局模式交给 Navigation,业务选择交给状态对象,页面栈只表达导航历史。

一、先把可复现的问题界定清楚

示例工程叫 CaseSplit,入口页为 Workbench.ets,详情页为 CaseDetail.ets。列表里有 C-201 至 C-204 等模拟工单,本篇固定观察 C-204,名称是“设备验收”,状态“待交付”,校验项 5/5。图片使用 10:24 的静态演示时刻,所有数字都是为说明逻辑而设定的样例,不是折叠屏性能测量值。

期望的交互非常具体:窄窗口点击 C-204 后进入详情;展开窗口时变成导航栏加详情区;再折叠回窄窗口,选中 ID 不能丢,也不能多出第二个相同的 CaseDetail。如果用户只是重复点同一项,业务上应视为重复选择,而不是一次新的导航动作。

这与此前按 GridRow/GridCol 调整工单栅格的工作不同。栅格解决“控件如何占位”,这里讨论“路由栈和选中态是不是同一回事”。列表排布完全正确,也可能因多次 pushPath 导致返回按钮要按两遍,或者切换一条工单后看到了上一条的旧数据。

二、让 Navigation 管显示,让业务对象管选择

HarmonyOS ArkUI 的 Navigation 支持单栏、分栏和自适应模式,配合 NavPathStack 管理 NavDestination。官方文档写明,自适应由 Navigation 宽度驱动,不能据此推断铰链状态,也不能将其当作“设备是否展开”的传感器。此处选择 NavigationMode.Auto,不另写一套设备型号判断。

设计里只有两种稳定条件:没有选中工单时,栈可以为空;有选中工单时,当前业务详情只保留一条。切换不同工单,采用替换栈顶,而非不断压入同名目的页。为避免组件重建导致本地字段重复初始化,控制对象由宿主入口持有;业务状态是否跨 Ability、跨进程恢复,则属于另一个持久化问题,不由本篇自动承诺。

下面的状态门卫处理的是同一工单的重复进入,以及不同工单的替换。它只依赖已核对的 NavPathStack.size()、pushPath() 和 replacePath()。返回 false 表示不需要进行导航,但不等同于“数据已经同步到服务端”。

// entry/src/main/ets/model/CaseSelectionGate.ets
export class CaseSelectionGate {
  private stack: NavPathStack;
  private selectedId: string = '';

  constructor(stack: NavPathStack) {
    this.stack = stack;
  }

  open(id: string): boolean {
    if (id.length === 0) { return false; }
    if (id === this.selectedId && this.stack.size() === 1) {
      console.info(`CaseSplit duplicate=${id} ignored`);
      return false;
    }
    const next = { name: 'CaseDetail', param: id };
    if (this.stack.size() > 0) {
      this.stack.replacePath(next, false);
    } else {
      this.stack.pushPath(next, false);
    }
    this.selectedId = id;
    console.info(`CaseSplit select=${id} stack=${this.stack.size()} mode=AUTO`);
    return true;
  }
}

selectedId 是业务标识,stack.size() 是路由数量;两者刻意不混为一个字段。replacePath 处理的是路由实例替换,不保证详情里的网络请求自动取消。使用 false 关闭本次转场动画只是为了容易观察状态,并非适配所必需。在更复杂的应用中,已有多个合法业务详情页时,不能直接套用“总是替换栈顶”的规则,应按页面所有权和用户的返回预期调整。

三、在入口处连上列表、路由与详情

Workbench.ets 需要解决第二个问题:业务对象的 open() 返回后,ArkUI 的选中高亮也要改变;反过来,页面只是从双栏变单栏时,不能再次主动调用 open()。下面保留与问题直接相关的片段,列表数据在演示里写为静态项,生产工程应交给仓储层加载。

// entry/src/main/ets/pages/Workbench.ets
import { CaseSelectionGate } from '../model/CaseSelectionGate';
import { CaseDetail } from './CaseDetail';

@Entry
@Component
struct Workbench {
  private stack: NavPathStack = new NavPathStack();
  private gate: CaseSelectionGate = new CaseSelectionGate(this.stack);
  @State selectedId: string = '';

  private selectCase(id: string): void {
    if (this.gate.open(id)) { this.selectedId = id; }
  }

  @Builder
  private destination(name: string, param: Object) {
    if (name === 'CaseDetail') {
      NavDestination() {
        CaseDetail({ caseId: param as string })
      }.title('工单详情')
    }
  }

  build() {
    Navigation(this.stack) {
      Column({ space: 12 }) {
        Text('工单工作台').fontSize(23).fontWeight(FontWeight.Bold)
        Button('C-201 网络设备巡检').onClick(() => this.selectCase('C-201'))
        Button('C-204 设备验收').onClick(() => this.selectCase('C-204'))
        Text(`当前选中:${this.selectedId || '无'}`)
      }.padding(16)
    }
    .mode(NavigationMode.Auto)
    .navDestination(this.destination)
    .title('CaseSplit')
  }
}

实际工程需要把 CaseDetail 组件按项目结构正确导入;此片段着眼于导航组合,省略全量样式、列表仓储和资源文件。API 枚举应写 NavigationMode.Auto,不是大写 AUTO;日志里的 mode=AUTO 是开发者自定义的展示字符串,并非 SDK 枚举常量。即使开发画面里放了红色标注,也不能拿示意截图代替这项核对。

四、详情组件只消费参数,不掌握窗口切换

第三个关注点是详情销毁与数据回流。NavDestination 负责承载子页,用户点击列表时传入 ID;详情组件只把 ID 显示出来,不在 aboutToAppear 里重复入栈。官方明确提示不要在该时机贸然进行栈操作,否则容易出现构建时序问题。

// entry/src/main/ets/pages/CaseDetail.ets
@Component
export struct CaseDetail {
  @Prop caseId: string = '';
  @State detailState: string = '待交付';

  aboutToAppear(): void {
    console.info(`CaseSplit detail=${this.caseId} onAppear`);
  }

  aboutToDisappear(): void {
    // 此处释放属于详情页的计时器、订阅或异步回调句柄。
    console.info(`CaseSplit detail=${this.caseId} release`);
  }

  build() {
    Column({ space: 12 }) {
      Text(`工单 ${this.caseId}`).fontSize(22)
      Text('设备验收')
      Text(`状态:${this.detailState}`)
      Text('校验项:5/5')
    }.padding(20)
  }
}

演示刻意没有发起网络访问;如果后来加入远端详情请求,应通过请求序号或取消令牌阻止旧响应覆盖新 ID。aboutToDisappear 的日志也只是生命周期观察点,不能在此修改即将销毁组件的响应式状态,更不能断言应用切后台就一定触发组件销毁。

五、用状态序列验收,而不是只看界面

这次约定的演示序列是:进入工作台,选择 C-204,重复选择一次,再从窄窗口切换到较宽布局。期望日志出现 CaseSplit select=C-204 stack=1 mode=AUTO,随后出现 CaseSplit duplicate=C-204 ignored;窗口重排不会新增 select 日志。C-204 仍指向“设备验收”,校验项保持 5/5。这些是供实际测试验证的断言,不是声称已经在真机复现的结果。

验收时不要只录一张截图。至少记录进入前后的 getAllPathName() 或 size(),并同时检查返回行为:单栏退回列表是否一步完成、双栏点击第二个 ID 是否替换原来的详情、迅速连续点击时会不会出现错乱。建议在窄屏、展开态、横屏窗口和可自由缩放的窗口各走一遍,而且把模拟器和真机的表现分开归档。

对于 C-204 这张手机图,诊断条上的“NavigationMode.Auto | stack=1”属于样例可视化信息。屏幕状态栏 10:24、5G、Wi-Fi 和 100% 电量只是视觉演示要素,与实际运行环境没有因果关系。图里没有展示真实折叠铰链传感器,也不证明设备宽度已经跨过某个阈值。

还有一组更容易遗漏的逆向用例:先选择 C-201,马上改选 C-204,在窗口切换的动画尚未结束时迅速点击返回。此时真正需要核对的不是动画有没有闪一下,而是导航栈是否仍由一个活动详情构成、返回后是否能看到列表、业务选中态是否同步清空。本文的门卫尚未监听系统返回事件,所以完整产品仍需把返回结果和 selectedId 的回收连接起来,否则可能出现“栈已经为空但高亮仍保留”的残留状态。这个补充需求应该通过 Navigation 的页面生命周期或路由回调处理,而不是把清理动作塞进窗口宽度回调。

分栏还会改变焦点路径:双栏时焦点可以从列表移动到右侧详情,单栏时详情成为当前可见内容。对于键盘导航、读屏和焦点恢复,界面测试要检查焦点是否跟随可见区域,而不能仅凭 stack=1 推断无障碍路径已经正确。布局是展示层的职责,但业务可达性和操作完成反馈仍需要由产品自己验收。

六、把适用边界写在交付前

这个实现适合“列表只维护一个活动详情”的轻量工单场景。如果产品需要多级详情、转派流程、草稿页或深链恢复,单详情栈模型就不够了。应该把 selectedId、草稿状态和页面历史明确拆开:前者决定现在关注谁,中间项说明用户输入了什么,后者才决定返回去哪里。

还要留意宽度触发的分栏阈值属于组件行为,不是固定适配机型名单;布局外层的约束、导航栏宽度以及最小内容宽度,都会影响最终显示。解决方法不是继续写“宽于某个机型就是双栏”,而是让窗口真的变化后观察 Navigation 是否满足预期。

最终的判断并不复杂:形态变化不等于业务再进入,重复点击不等于新的详情历史。 只要把这两件事分清楚,折叠屏适配就不需要用越来越多的条件分支去掩盖导航状态问题。

参考文档:

  • HarmonyOS Navigation 组件 API:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-basic-components-navigation
  • Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
  • NavDestination 生命周期:https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-navigation-navdestination

图片均为本批次独立制作的界面演示;不构成真实 IDE 截图、真机调试日志或性能测试证据。

Logo

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

更多推荐