消息中心的双栏已经摆好了,问题却往往从“返回”开始。左侧列表点开一条通知,右侧进入详情,再进入编辑页面;如果用户连续打开另外几条消息,返回键到底应该回到上一条详情、回到编辑前的详情,还是直接回到列表?页面能显示出来,仍需专门设计回退路径。尤其是在平板、折叠屏展开窗口里,左栏一直存在,肉眼不容易发现右栏的路由栈正在增长。

这里沿用消息中心的列表与详情关系,把导航操作统一收进一个控制器。业务需要先区分“逐级返回”“返回指定层级”和“切换当前详情”,再分别使用 NavPathStack 的方法,而不是让每个按钮随意调用 pop()。NavigationMode.Auto 负责布局选择,路由历史仍由同一份 NavPathStack 维护;系统级平行视界配置与应用内双栏布局也应分开管理。

消息中心的页面栈与不同返回路径

一条操作链里,藏着三种不同的返回语义

以“消息列表 → 消息详情 → 编辑备注”为例,用户按系统返回键时,应先退出编辑,再回到当前消息,最终回到列表。这里的每一步都属于正常历史,采用 pop() 就能表达。但用户在编辑完成后选择“回到详情”,如果中间插入了帮助页、附件页等临时页面,逐次弹栈会产生不必要的中间状态。此时应指定目标层级。

还有一种情况:左侧连续点击消息 A、B、C。对于邮件式浏览器,阅读不同内容可能需要保留历史;对于待办处理台,点击 B 通常意味着替换右侧当前详情。如果采用后者,连续 pushPathByName() 会把“选择新内容”变成“创建新历史”,返回键就会沿着 A、B、C 逐条倒退。这个选择不能交给框架猜测。

业务动作栈操作对用户的含义
详情打开备注编辑pushPathByName()增加一个可返回层级
编辑取消、返回详情pop()逐级返回上一个页面
编辑完成,回到已有详情popToName()跨过中间页面,返回指定目标
同一位置切换消息内容replacePathByName()用新详情替换当前详情
检查当前层级getAllPathName()决策与排错,不承担数据存储

popToName() 根据页面名称定位目标。如果栈里有多个同名详情,需要先明确目标实例的选择策略;本文的阅读台采用“详情只保留一个当前实例”的约束,以免同名页面的歧义影响用户操作。页面名称是路由标识,消息 ID 才是业务标识,二者不能混用。

把路由操作集中到一个控制器

以下类与上篇的 Navigation(this.stack) 共用同一个栈对象。它将普通打开、编辑进入和回到详情封装为三个方法,页面层只表达意图。MailDetail、MailEdit 分别对应在 navDestination 中注册的页面名称;参数用小型类承载,避免把页面组件或网络对象放入路由参数。

export class MailRouteParam {
  mailId: number = 0;
  constructor(mailId: number) {
    this.mailId = mailId;
  }
}

export class MailNavigator {
  private stack: NavPathStack;
  constructor(stack: NavPathStack) {
    this.stack = stack;
  }

  openDetail(mailId: number): void {
    const paths: string[] = this.stack.getAllPathName();
    const top: string = paths.length > 0 ? paths[paths.length - 1] : '';
    if (top === 'MailDetail') {
      this.stack.replacePathByName('MailDetail', new MailRouteParam(mailId));
      return;
    }
    this.stack.pushPathByName('MailDetail', new MailRouteParam(mailId));
  }

  openEditor(mailId: number): void {
    this.stack.pushPathByName('MailEdit', new MailRouteParam(mailId));
  }

  returnToDetail(): void {
    const paths: string[] = this.stack.getAllPathName();
    if (paths.includes('MailDetail')) {
      this.stack.popToName('MailDetail');
    } else if (paths.length > 0) {
      this.stack.pop();
    }
  }
}

这个控制器有一个特意保留的前提:它负责单一 Navigation 容器内的消息流程,不把其他功能模块的历史也一起清理。对包含多层子路由的复杂工程,应由模块拥有自己的导航控制边界;比如点击列表切换不同详情时,可在主从阅读场景下通过路由替换实现,也可把当前 ID 交给状态容器并保持一个固定的详情页。无论选哪条路,都要避免同一个点击事件既替换栈顶又重复入栈。

把这段接到主页面只需要几个明确调用。下面的 Index 保留列表与详情注册,并为编辑页增加路由映射;消息集合和 MailDetail 的读取逻辑沿用前一篇,核心是所有操作都指向 this.stack。

@Entry
@Component
struct Index {
  private stack: NavPathStack = new NavPathStack();
  @State currentMailId: number = 0;

  @Builder
  pageMap(name: string) {
    if (name === 'MailDetail') {
      MailDetail();
    } else if (name === 'MailEdit') {
      MailEdit();
    }
  }

  openMail(id: number): void {
    this.currentMailId = id;
    new MailNavigator(this.stack).openDetail(id);
  }

  build() {
    Navigation(this.stack) {
      Column({ space: 12 }) {
        Button('查看产品方案').onClick(() => this.openMail(1));
        Button('查看设计评审').onClick(() => this.openMail(2));
      }.width('100%').padding(16)
    }
    .title('消息中心')
    .mode(NavigationMode.Auto)
    .navDestination(this.pageMap)
    .width('100%').height('100%');
  }
}

导航栈控制器在开发环境中的代码位置

返回键遇到未保存内容,应该谁来决定

NavDestination 的 onBackPressed 能让子页面优先处理返回行为。编辑页有未提交输入时,直接放行系统返回会丢失填写内容;每次都强制拦截,又会让用户无法正常退出。比较清楚的规则是:数据未修改时交给系统继续处理;内容已修改时展示确认框,用户选择放弃后才主动弹栈。

下面只呈现编辑与返回控制。业务保存操作应由仓库层完成并确认成功,再将 dirty 置为 false;这里的“放弃并返回”负责导航,不会偷偷提交输入。路由上下文从 onReady 获取,避免编辑页单独创建一个无关的导航栈。

@Component
struct MailEdit {
  private pathStack: NavPathStack = new NavPathStack();
  @State draft: string = '';
  @State dirty: boolean = false;

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        Text('备注编辑').fontSize(24).fontWeight(FontWeight.Bold);
        TextInput({ text: this.draft, placeholder: '输入备注' })
          .onChange((value: string) => {
            this.draft = value;
            this.dirty = true;
          });
        Button('放弃并返回').onClick(() => this.pathStack.pop());
      }.padding(24).width('100%')
    }
    .title('编辑备注')
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack;
    })
    .onBackPressed((): boolean => {
      if (!this.dirty) {
        return false;
      }
      AlertDialog.show({
        title: '离开当前编辑?',
        message: '尚有未提交的备注内容。',
        primaryButton: { value: '继续编辑', action: () => {} },
        secondaryButton: {
          value: '放弃修改',
          action: () => this.pathStack.pop()
        }
      });
      return true;
    });
  }
}

这里有两个行为需要特别核对。其一,return true 表示这次返回已经由页面处理,弹框确认后再主动执行一次 pop();其二,return false 交由导航框架处理原本的返回。不能在同一分支里先调用 pop(),又返回 false,否则可能出现连续退出两级的结果。对表单输入较多的页面,还应区分“用户改过又恢复成原值”和“存在真实未提交更改”,避免仅凭一次 onChange 永久锁定返回。

跨级返回还有一个容易遗漏的回调问题

许多工程把编辑完成后的刷新写在上一个页面注册的 onPop 回调中。逐级返回时,这样安排比较直观;但调用 popToName() 跨越多个页面时,被跨越的中间页面所绑定的 onPop 并不保证逐个触发。华为开发者关于 Navigation 参数传递的 FAQ 明确区分了 onPop 与返回目标页面的 onResult 行为,因此复杂业务不应把跨级返回等同于连续多次普通返回。

对于消息编辑,稳妥的处理是保存动作由业务仓库先完成,导航只负责把用户带回目标页面。详情页可在重新进入可见状态或接收到业务数据更新时读取最新内容,避免刷新逻辑依赖某条特定的弹栈路径。若确实需要页面间携带返回结果,可以按目标 API 版本选取 onResult,并为来源和结果设置类型明确的参数结构。

系统级平行视界、Split、Stack 也不会自动替你定义这些业务返回规则。宽屏右侧编辑视图消失,左侧列表保持不动,这在视觉上十分自然;但路由实际弹出的层级仍要通过栈查询和页面事件确认。大屏外观越稳定,越需要主动检查隐藏的路由状态。

手机编辑页面的返回确认交互

用操作路径核对回退设计

建议把路由回归写成可重复的步骤。逐级返回:列表打开 A,进入编辑,按返回,确认回到 A;跨级返回:详情进入编辑,再进入帮助页,选择“完成”,确认回到原详情;快速切换:从 A 切换 B,观察 getAllPathName() 的层级数是否符合“只保留当前详情”的约定;空栈保护:没有任何详情时触发返回目标操作,界面不应出现意外页面。调整窗口宽度后复查相同步骤,确保单栏和双栏使用同一份业务意图。

日志宜只记录操作名称、路由页名与层数。不要将备注正文、联系人信息等敏感内容直接输出到 HiLog。关注的判断标准也不是某一种转场动画看起来流畅,而是每一次返回是否能解释“用户回到哪里、哪些数据应保留、哪些输入需要确认”。

更多接口细节可查阅华为开发者文档:Navigation 传参及返回结果 FAQ、NavDestination 子页面与生命周期、基于 Navigation 的页面路由。

本篇把双栏背后的回退历史从视觉结构中独立出来:Navigation 决定呈现,NavPathStack 管理历史,业务仓库保证内容变更。接下来处理列表与详情的状态同步时,才可以专注于“数据变了谁来更新”,而不是继续追查究竟跳回了哪个页面。

Logo

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

更多推荐