Navigation 路由下的半模态生命周期:bindSheet 为什么会随子页面跳转消失,以及多 Navigation 并存怎么管

前言

半模态(bindSheet)和 Navigation 是 HarmonyOS 应用里出场率最高的两个 UI 能力。但把它们放在一起用,很多人会撞上同一个现象:列表页弹出筛选面板,面板里点一个按钮 pushPath 跳到子页面,返回之后面板没了;再点"筛选",面板也弹不出来,好像 bindSheet 彻底失效了。

这个现象在网上流传的答案基本是两条路线。路线一是用 onNavBarStateChange 判断"是否在栈顶",被覆盖时把状态置 false 并记一个 needRestore 标记,返回时用 setTimeout 延迟一帧把状态置回 true。路线二是跳转前主动关闭面板,返回后由页面的 onPageShow 重新拉起。

这两条路线都能在某些机型上"看起来能跑",但它们的正确性是靠巧合维持的:前者误读了 onNavBarStateChange 的语义,在 Split 分栏下必然失效;后者依赖的 onPageShow 在 Navigation 内部跳转时压根不会被触发。更麻烦的是,这两条路线都引入了一个额外的 needRestore 标记,把"面板为什么关着"这件事变成了两个互相打架的状态源——于是你修好了 A 场景,B 场景又开始漂。

这篇文章要做的不是再给一份"能跑"的代码,而是把四件被跳过的事讲清楚。

第一是 bindSheet 的宿主归属。它不是一个悬浮在应用之外、与页面无关的独立窗口,而是绑定在某个页面级组件上的模态节点。"跳转后消失"不是框架的 bug,而是这个归属关系在 Navigation 推栈时的必然结果。理解这一点,你才能预判它在什么情况下会消失、什么情况下能幸存。

第二是 状态漂移。真正让"再点也弹不出来"的,不是 bindSheet 坏了,而是你持有的状态变量和面板的真实状态之间出现了分叉;而一旦分叉形成,靠重复一模一样的赋值是收敛不回来的。

第三是 可见性该怎么建模。把它拆成"用户意图"和"宿主是否可见"两个正交输入,让 sheet 的显示状态成为它们的派生结果,needRestore 那类互相打架的变量会直接失去存在必要。

第四是 多 Navigation 并存时的栈治理。主页面和子模块各持有一个 NavPathStack 是合理需求,但两个栈并存会带来返回键优先级、边界页面拦截点、以及 NavDestinationMode.DIALOG 目的页造成的生命周期盲区,这些都必须显式设计,不能指望框架替你兜底。

问题描述

业务场景

一个电商 App 的商品列表页,顶部有"筛选"按钮,点击后通过 bindSheet 弹出半模态面板,让用户选品类和价格区间。面板底部有一个"查看推荐"按钮,点击后通过 NavPathStack.pushPath 跳到推荐结果页。用户从推荐页返回列表页,期望筛选面板还在,或者至少再点"筛选"能正常弹出来。

三个现象

现象一:返回后面板不恢复。 从推荐结果页返回列表页,筛选面板没有回来。日志可以确认跳转过程中面板确实被收起了(sheet 的 onDisappear 触发了),但控制面板的 @State isSheetShowing 仍然是 true。UI 已经收起,变量还指着"打开"。

现象二:再点"筛选"弹不出来。 按钮的 onClick 里执行的是 this.isSheetShowing = true,而它的值本来就是 true,这次赋值不构成状态变化,不会触发任何刷新。手动做一次 falsetrue 也弹不出来——如果两次赋值在同一个同步执行栈里,框架做差分时看到的最终值仍然是 true,中间态被吃掉了。这就是"好像 bindSheet 失效了"的真正原因:不是失效,是没有形成一次有效变化。

现象三:面板内部的筛选条件也丢了。 已选品类、价格区间全部重置。这不是"面板恢复不了"的附带损失,而是另一个独立问题:条件如果只存在于面板的 @Builder 内部,面板节点被销毁时它就没有别的存身之处。

两条既有路线为什么不够

路线一:onNavBarStateChange + needRestore + setTimeout

它把 onNavBarStateChange 的参数命名成 isOnTop,按"是否在栈顶"来用:

.onNavBarStateChange((isOnTop: boolean) => {
  if (!isOnTop) {
    this.needRestore = true;
    this.isSheetShowing = false;
  } else if (this.needRestore) {
    this.needRestore = false;
    setTimeout(() => { this.isSheetShowing = true; }, 100);
  }
})

但官方签名是 onNavBarStateChange(callback: (isVisible: boolean) => void),文档说明是"导航栏显示状态切换时触发该回调",isVisibletrue 表示显示、false 表示隐藏。它描述的是 Navigation 组件自己的导航栏(navBar)的显隐,与路由栈顶没有任何关系。

这段代码之所以能在手机上跑通,是因为列表页恰好放在 navBar 里,而 NavigationMode.Stack 下 push 到目的页时 navBar 会被隐藏,isVisible 顺势翻成 false巧合地等价于"被覆盖"。巧合一旦被打破,代码就坏:

  • 任何一次 .hideNavBar(true),或者切页过程中的 navBar 显隐波动,都会让 isVisible 翻转,sheet 状态被误清;
  • 平板和折叠屏上以 NavigationMode.Split 运行时 navBar 常驻可见,这个回调根本不触发,返回列表页后面板不会恢复。这类只在特定设备形态上复现的 bug,排查成本极高。

路线二:返回后由 onPageShow 重新拉起。

onPageShow@Entry 页面的生命周期回调,只在页面级显示时触发。Navigation 内部的 NavDestination 推栈并不会创建新的 Page,"从推荐页返回列表页"这件事在页面层面从未发生过,onPageShow 不会重新触发。所以在"单 Page + 多 NavDestination"这个结构下,"返回后在 onPageShow 里重新拉起面板"这条路径没有执行机会。它不是延时不够,是不会被执行。

一个被忽略的前提

这两条路线之所以要绕道 navBar 回调或者页面回调,根因是同一个:它们都默认列表页是 Navigation 的 navBar 内容。navBar 内容不是 NavDestination,没有 onShown / onHidden 可用,所以只能去找别的信号源,找错了就是上面两个结果。

把列表页从 navBar 挪出来、用 NavDestination 承载,是后文所有方案成立的前提。这不是为了写法好看,而是因为只有 NavDestination 才提供"这个页面此刻是否可见"的权威信号。

细节解析

一、bindSheet 的宿主是谁:渲染层级不等于生命周期归属

先把一个常见的混淆拆开:半模态的"渲染层级"和"生命周期归属"是两件不同的事。

从渲染层级看,bindSheet 默认的 OVERLAY 模式会把模态节点放在当前 UIContext 的 overlay 上,视觉上盖在所有页面之上。很容易由此推出一个错误结论——“它在最上层,所以页面怎么跳都不影响它”。这个推论是错的。

从生命周期归属看,模态节点的归属关系是"宿主 → 模态":宿主是绑定它的那个页面级组件(Navigation 的 navBar 内容节点,或者 NavDestination 的根节点)。宿主进入不可见状态时,这个模态没有继续存在的理由,框架会主动销毁它。

我没有在文档里找到关于内部节点树的规范描述,所以不去猜它的具体实现,但从三处可观测证据可以确定这个归属关系是真实存在的:

  1. push 覆盖发生时,sheet 的 onDisappear 一定触发——说明节点真的被销毁了,而不是被隐藏;
  2. 绑定变量一定不被回写——说明这条销毁路径与用户交互路径是两条不同的代码路径;
  3. mode 换成 SheetMode.EMBEDDED 后行为改变——说明模态的挂载位置确实被改变了。

所以"sheet 随子页面跳转消失"不是 bug,是归属关系决定的必然结果。你唯一能选择的是让模态挂在哪一层,以及让宿主的可见性如何变化。

二、状态漂移:为什么"UI 收了,变量还是 true"

bindSheet 支持 $$ 双向绑定,形如 .bindSheet($$this.isSheetShowing, builder, options)。很多人由此建立了一个错误预期:变量和面板状态永远同步

实际情况是,$$ 的回写只在用户手势拖拽关闭、点击关闭按钮这类"用户主动关闭"的路径上发生。页面被 push 覆盖导致的系统收起不会回写。 于是一次跳转之后,isSheetShowing === true、面板实际不存在,状态漂移就产生了。

漂移为什么收敛不回来?因为 bindSheet 消费的是可见性的变化,不是可见性的。当变量为 true、面板已被销毁,你再赋 true 是零信息量的一次赋值,框架不会重建面板;而同步栈内的 false → true 两次赋值,在差分时只剩最终值 true,同样零信息量。这就是现象二的完整解释,也说明修复方向不是"想办法再赋一次值",而是让变量先真正回到 false,并在宿主可见时再派生回 true

顺带一个必须澄清的表述:有说法称"ArkUI 的状态批处理会把同一同步栈里的 false → true 合并掉"。这个描述不准确。@State 的赋值是同步写进变量的,两次赋值都真实生效了;被合并的是同一次渲染周期内的 UI 刷新,而 bindSheet 只看到最终值,自然感知不到中间态。区分这一点很重要,因为它决定了修复的落点:你要解决的是"变化没有被观察到",而不是"赋值被丢掉了"。

三、setTimeout 是伪解,真正需要的跨帧时机本来就存在

setTimeout(() => { this.isSheetShowing = true; }, 100) 这类写法的本质是用时间赌渲染时序。它在至少三种情况下会失效:

  • 低端机或后台负载高时,100 毫秒不够,面板弹不出来;
  • 分栏模式与 Stack 模式的转场时长不同,延时结束时宿主可能仍未进入可见状态;
  • 用户在延时窗口内再次操作,定时器与手势交叉,出现"关了又弹"的抖动。

更本质的问题是:它没有解决状态漂移,只是绕开了needRestore 仍然是第二个状态源,它和 isSheetShowing 之间没有约束关系,两者可以各自为政。而且这类游标定时器如果不做清理,还会在组件销毁后继续持有引用并对着已销毁的组件赋值。

关键判断在这里:onNavBarStateChangepushPath 处在同一个同步执行栈里,这才是"必须跨帧"的原因;而不是"框架需要时间把节点建好"。 只要你把"置回可见"的赋值放到下一个真实的生命周期事件里,天然就是跨帧的,不需要任何魔法延时。

四、正确建模:单一数据源派生可见性

与其维护 isSheetShowingneedRestore 这一对互相打架的变量,不如把它拆成两个正交输入,可见性作为派生结果:

// 用户意图:用户是否希望筛选面板处于打开状态
@State private sheetRequested: boolean = false;
// 宿主可见性:由 NavDestination 生命周期回调维护
@State private isDestinationShown: boolean = false;
// 派生结果:唯一被 bindSheet 消费的状态
@State private isSheetShowing: boolean = false;

private syncSheetVisibility(): void {
  const next: boolean = this.sheetRequested && this.isDestinationShown;
  if (next !== this.isSheetShowing) {
    this.isSheetShowing = next;
  }
}

这个模型有三个要点。

要点一:syncSheetVisibility() 是唯一的写入口。 全工程不允许在别处直接给 isSheetShowing 赋值。一旦出现第二个写入口,漂移就会重新出现。

要点二:只在值真正变化时赋值。 这个 if 不是性能优化,而是语义要求——它保证 isSheetShowing 的每一次变化都对应一次真实的可见性翻转。

要点三:sheetRequested 只表达意图,不表达"面板此刻在不在"。 面板被系统收起时,意图保持不变,这才是"返回后自动恢复"能够成立的原因。如果被收起时顺手把意图也归零了,那返回之后就永远恢复不了——这正是 needRestore 那个标记试图绕开、却没绕明白的地方。

意图的归零只应该发生在"面板真的消散了,且是用户主动关的"这一种情况下。这就是 onDisappear 需要配合宿主可见性做判断的原因:

onDisappear: () => {
  // 面板真的销毁了,派生值必须对齐到 false
  this.isSheetShowing = false;
  // 宿主仍在最上层 → 说明是用户主动关闭(手势 / 关闭按钮)→ 意图归零
  // 宿主已被覆盖 → 是系统收回,保留意图,等 onShown 时自动恢复
  if (this.isDestinationShown) {
    this.sheetRequested = false;
  }
}

这里的判别依据是回调的先后关系:onHidden 由 NavDestination 生命周期在推栈时同步派发,先于模态节点的销毁动画;因此当 onDisappear 触发时,isDestinationShown 是否已被置 false,正好区分了"被覆盖"与"用户关闭"两种来源。

五、生命周期锚点:NavDestination.onShown / onHidden

NavDestination 提供 onShown / onHidden(API 10 起),语义是"该目的页显示 / 隐藏时触发":

  • NavigationMode.Stack 下,STANDARD 目的页入栈会触发下层的 onHidden,出栈则触发下层的 onShown
  • 首次创建并显示时也会触发 onShown,所以派生值在首帧就能正确建立。

用它替换 onNavBarStateChange 的价值在于:它表达的是页面自身的可见性,而不是某个 UI 元素的显隐。因此它在 Stack、Split、分屏、悬浮窗等所有形态下都成立,onNavBarStateChange 的形态依赖问题被彻底消除。

有一个必须提前布置的结构性约束:onShown / onHidden 只存在于 NavDestination。如果你的列表页当前是 Navigation 的 navBar 内容,它拿不到这两个回调,也拿不到"自己是否可见"的任何权威信号。所以第一步是把列表页改成 NavDestination 承载。这一步不做,后面所有方案都无处落脚。

六、覆盖页的模式决定面板是"收起"还是"幸存"

这里有一个很多人没意识到的联动关系:派生可见性能不能保住面板,取决于覆盖页用的是哪种 NavDestinationMode

NavDestinationMode 是 API 11 引入的枚举,有两个值:

  • STANDARD:默认值,常规目的页。进出路由栈会影响下层 NavDestination 的可见性,即下层会收到 onShown / onHidden
  • DIALOG:目的页背景默认透明进出路由栈不影响下层 NavDestination 的可见性(不触发下层的 onShown / onHidden),只触发自身的 onActive / onInactive

于是同一套派生模型会推导出两种完全不同的行为,而这两种行为都是"正确"的,区别只在产品需求:

场景覆盖页模式下层是否收到 onHidden面板行为
覆盖页压上来,筛选面板应被收起,返回后自动恢复STANDARD收到收起 → 返回时派生回 true → 恢复
覆盖页压上来,筛选面板必须继续存在(如边看推荐边改筛选)DIALOG不收到isDestinationShown 保持 true → 派生值不翻转 → 面板存活

第二种场景正是 NavDestinationMode.DIALOG 在业务上的真正价值:它是"我要在一条路由栈里推一个不打扰下层生命周期的覆盖层"。但同时也带来一个代价——下层拿不到 onHidden,也就意味着下层无法通过生命周期感知自己被覆盖了。如果你的业务逻辑依赖这个感知(例如播放器要在被覆盖时暂停),就得换回 STANDARD,或者在推栈时显式通知。

七、NavDestinationMode.DIALOG 的硬约束清单

用 DIALOG 模式之前,必须先把这几条约束过一遍,每一条都可能导致"看起来没生效":

  • 版本NavDestinationModeAPI 11 引入(不是 12,排查版本问题时别记错)。API 13 之前,DIALOG 目的页默认没有系统转场动画;API 13 起才支持系统转场动画。 也就是说在 API 13 以下的设备上,DIALOG 目的页是"直接出现"的,一帧动画都不会有。想低版本也有动画,只能自己用 customNavContentTransition(API 11 引入,元服务从 API 12 起可用)叠加。
  • 动画叠加:API 13 及以上如果还要完全自定义时间曲线,先用 NavigationSystemTransitionType.NONE 关掉系统转场再叠自己的动画,否则两套动画会叠出"先弹一下再滑一下"的怪效果。
  • 生命周期盲区:DIALOG 目的页只走 onActive / onInactive,不走 onShown / onHidden。只有 STANDARD 才走 onShown / onHidden。这意味着:如果你的页面本身是 DIALOG 模式,它不能作为派生模型的宿主——它的 isDestinationShown 永远不会被更新。想同步播放器的暂停与继续却把模式选错,回调会静默不触发,而且不报错,非常难查。
  • 背景透明:DIALOG 的"背景默认透明"指的是容器,不是内容。你的内容根容器必须自己给底色,否则会看到下层内容透上来。另外 CustomBuilder 里的根容器加 .backgroundColor(Color.Transparent) 不会向下继承,每一层要各写一遍。
  • 转场代理customNavContentTransition 的返回类型是 NavigationAnimatedTransition | undefinedtimeout 必须大于动画最长耗时,否则系统超时后强制结束转场;transition 回调里必须调用 proxy.finishTransition()。而且 transition 的入参是 NavigationTransitionProxyNavContentInfo 只提供 nameindexmodeparam 这类描述信息,拿不到可渲染的组件句柄。正确做法是让目的页自己在 aboutToAppear 里注册动画处理器,用自身的 @State 驱动属性动画。

八、SheetMode.EMBEDDED 的硬约束清单

"让面板在子页面压上来时幸存"这个需求,很多人会先想到 SheetMode.EMBEDDED(API 12 起)。它的官方定位是:把半模态挂在当前 Page / NavDestination 内,新页面可以覆盖它,返回后半模态依旧存在、面板内容不丢失。默认的 OVERLAY 则是显示在当前 UIContext 的所有页面之上。

同样有一份硬约束清单:

  • 只支持挂载在 PageNavDestination 节点上,两者都在时优先挂到 NavDestination
  • 目标页面节点必须已经挂载上树之后才能拉起面板。 所以在 aboutToAppear 里直接打开 sheet 是弹不出来的,必须等首帧之后(按钮回调,或者 onShown 之后的下一次状态更新);
  • 不支持在面板显示期间动态切换 mode 不能"先以 OVERLAY 打开、再在不关闭的情况下改成 EMBEDDED",只能关闭后换模式重开;
  • EMBEDDED 不铺满全屏遮罩,用户点面板外部区域不再是"点遮罩关闭"的语义。如果现有筛选面板依赖这个交互,要么自己补一层蒙层,要么继续用 OVERLAY而用 OVERLAY 时"新起的页面可以盖住半模态"这个前提不成立——两种模式的行为不能兼得,这是选型时必须先做的取舍。

还有一个容易踩的挂载位置问题:bindSheet 绑定的那个节点必须稳定存在。不要把 bindSheet 挂在 if 分支里的节点、会被 ForEach 回收重建的节点、或者 NavDestination 根节点下的可变子节点上。bindSheet 挂到 NavDestination 的根节点上是最稳的。

九、"再点筛选也弹不出来"的定位方法

这是最值得单独查的一条,因为它说明失败发生在 bindSheet 的内部注册阶段,而不是状态值本身。方法是在四个回调里全部打日志,然后按"打到了哪几个"来判:

.bindSheet($$this.isSheetShowing, this.filterSheet(), {
  height: SheetSize.MEDIUM,
  // onWillAppear / onWillDisappear 为 API 12 起提供
  onWillAppear: () => { console.info('[sheet] onWillAppear'); },
  onAppear: () => { console.info('[sheet] onAppear'); },
  onWillDisappear: () => { console.info('[sheet] onWillDisappear'); },
  onDisappear: () => { console.info('[sheet] onDisappear'); }
})

判据很干脆:

  • 只打了 onWillAppear、没有 onAppear:面板在挂载过程中被打断,通常是 mode: EMBEDDED 的目标父节点此刻还没上树;
  • 四个回调一个都不打bindSheet 绑定的那个节点已经不存在了,状态怎么改都不会有反应。去查绑定节点是否处在 if 分支里、是否被 ForEach 回收重建、是否是 NavDestination 根节点下的可变子节点——把 bindSheet 挂到 NavDestination 的根节点上是最稳的;
  • onAppear 打了但界面看不到面板:面板被上层 Navigation / Stack 裁掉了,回到 EMBEDDED 的挂载约束上排查。

十、多 Navigation 并存:两个栈该怎么管

主页面一个 NavPathStack、子模块自己一个 NavPathStack,这在工程上是合理需求。但要先明确一个前提:只有在你确实需要"两个互不干扰的路由域"时才拆栈。如果只是为了做转场动画而把两个 Navigation 叠在 Stack 里,那是错的方向——两套转场时序没有任何机制可以对齐,底层的 Navigation 完全不知道上层推了什么,于是"先出底板、再出动画"就是必然结果,不是配置没写对。

栈管理的三条规则:

规则一:一个栈只能有一个归属组件。 谁创建 NavPathStack,谁就负责它的全部 push / pop。子栈不要写进 @Provide 给外层用,外层栈也不要被子栈修改。跨域跳转必须由"发起方 push 自己的栈",而不是"去动别人的栈"。

规则二:栈的 name 空间要隔离。 两个栈各自配自己的 navDestination 构建函数,路由名不要复用。popToNameremoveByName 这类按名操作只在自己的栈里有效,在子栈里写一个外层栈的页面名,只会静默失败,不会报错。

规则三:返回键必须有单一决策点。 多栈并存的经典 bug 是"返回键一次退了两层"。正确做法是把优先级固定下来,写在一个函数里:

  1. 当前页面有半模态/弹窗打开 → 先关闭它,消费这次返回;
  2. 子栈 size() > 0subStack.pop(),消费;
  3. 否则不消费,交回外层栈,由外层栈 pop 当前目的页。

NavDestinationonBackPressed 回调返回 booleantrue 表示"我消费了这次返回",false 表示继续向上冒泡。把这个判断放在承载子 Navigation 的那个 NavDestination,而不是放在子栈内部的每个目的页上——后者的行为受树深度影响,容易和上层的处理打架。

别忘了子栈也有生命周期。 子栈里的 NavDestination 同样有 onShown / onHidden。当外层的页面压到子模块之上时,子栈的当前目的页也会收到 onHidden如果子栈里也有 bindSheet,同样要用派生可见性建模,否则你会把同一个 bug 在子模块里再踩一遍。

十一、方案选型对照

需求覆盖页模式sheet 模式生命周期锚点
覆盖时收起面板,返回后自动恢复STANDARDEMBEDDEDOVERLAY下层 onShown / onHidden
覆盖时面板必须继续存在DIALOGEMBEDDED(必须)下层不更新,派生值不翻转
覆盖页自身也有 sheet任意EMBEDDEDDIALOG 用 onActive / onInactive;STANDARD 用 onShown / onHidden
需要点遮罩关闭的交互任意OVERLAY接受"被覆盖必然收起"

最后一条工程建议:如果产品上本来就希望"点查看推荐后就收起筛选面板"(这更符合大多数电商 App 的交互),那在推栈前显式把 sheetRequestedfalse 就好,整类状态同步问题会直接消失,不必为了让面板"复活"去和生命周期较劲。能通过产品决策消除的技术问题,不要用工程手段去兜。

示例代码

下面给出一个完整可运行的工程结构。为了覆盖全部要点,代码分成七个文件:模型定义、核心的列表页(派生可见性 + bindSheet)、两种覆盖页(STANDARD 与 DIALOG)、入口页、以及子模块的宿主页与详情页(多栈 + 返回拦截)。

entry/src/main/ets/
├── model/
│   └── FilterModel.ets          类型定义与路由名
└── pages/
    ├── Index.ets                @Entry + Navigation + PageMap
    ├── ListPage.ets             ★ 核心:NavDestination + bindSheet + 派生可见性
    ├── RecommendPage.ets        STANDARD 覆盖页(下层会被隐藏)
    ├── OverlayDetailPage.ets    DIALOG 覆盖页(下层不被隐藏)
    ├── SubModuleHost.ets        子模块宿主:独立 NavPathStack + 返回拦截
    └── SubDetailPage.ets        子模块内部目的页

第一步,类型与路由名。 筛选条件和路由名集中放在一处,FilterCondition 只承载筛选条件、不承载可见性,这是"面板被销毁也不丢条件"的关键。

// entry/src/main/ets/model/FilterModel.ets

/** 筛选条件:与面板可见性完全解耦,面板销毁重建时依然可用 */
export interface FilterCondition {
  category: string;
  priceRange: string;
}

export function createDefaultFilter(): FilterCondition {
  return { category: '全部', priceRange: '不限' };
}

/** 主栈路由名 */
export class RouteName {
  static readonly LIST: string = 'ListPage';
  static readonly RECOMMEND: string = 'RecommendPage';
  static readonly OVERLAY_DETAIL: string = 'OverlayDetailPage';
  static readonly SUB_HOST: string = 'SubModuleHost';
}

/** 子栈路由名,与主栈隔离,避免 popToName 跨栈误用 */
export class SubRouteName {
  static readonly SUB_DETAIL: string = 'SubDetailPage';
}

第二步,核心的列表页。 这里把派生可见性的三个变量、唯一的同步入口、EMBEDDED 挂载、四个回调探针、以及返回键的优先级处理全部落在同一个文件里。

// entry/src/main/ets/pages/ListPage.ets
import { FilterCondition, createDefaultFilter, RouteName } from '../model/FilterModel';

@Component
export struct ListPage {
  @Consume('mainStack') mainStack: NavPathStack;

  /** 用户意图:面板是否应该处于打开状态。只在用户操作时改变 */
  @State private sheetRequested: boolean = false;
  /** 宿主可见性:由 NavDestination 生命周期维护 */
  @State private isDestinationShown: boolean = false;
  /** 派生结果:唯一被 bindSheet 消费的状态 */
  @State private isSheetShowing: boolean = false;

  /** 筛选条件独立存放,面板被销毁不丢数据 */
  @State private filter: FilterCondition = createDefaultFilter();

  private categories: string[] = ['全部', '数码', '服饰', '食品', '家居'];
  private priceRanges: string[] = ['不限', '0-100', '100-500', '500-1000', '1000+'];

  /** 唯一的可见性推导入口:任何地方都不允许直接给 isSheetShowing 赋值 */
  private syncSheetVisibility(): void {
    const next: boolean = this.sheetRequested && this.isDestinationShown;
    if (next !== this.isSheetShowing) {
      this.isSheetShowing = next;
    }
  }

  private openSheet(): void {
    this.sheetRequested = true;
    this.syncSheetVisibility();
  }

  private closeSheet(): void {
    this.sheetRequested = false;
    this.syncSheetVisibility();
  }

  build() {
    NavDestination() {
      Column() {
        Row() {
          Text('商品列表').fontSize(20).fontWeight(FontWeight.Medium)
          Blank()
          Button(this.sheetRequested ? '收起筛选' : '筛选')
            .type(ButtonType.Capsule)
            .backgroundColor(this.sheetRequested ? '#F1F3F5' : '#007DFF')
            .fontColor(this.sheetRequested ? '#333333' : '#FFFFFF')
            .onClick(() => {
              if (this.sheetRequested) {
                this.closeSheet();
              } else {
                this.openSheet();
              }
            })
        }
        .width('100%')
        .height(56)
        .padding({ left: 16, right: 16 })

        Text(`当前筛选:${this.filter.category} / ${this.filter.priceRange}`)
          .fontSize(14)
          .fontColor('#666666')
          .margin({ top: 12, left: 16 })

        Blank()

        Row({ space: 12 }) {
          Button('推荐结果页(STANDARD)')
            .layoutWeight(1)
            .type(ButtonType.Capsule)
            .backgroundColor('#007DFF')
            .onClick(() => {
              // 产品决策点:这里选择"推栈前收起面板"。
              // 若希望面板在覆盖期间幸存,改用 OverlayDetailPage 并保留意图。
              this.closeSheet();
              this.mainStack.pushPath({ name: RouteName.RECOMMEND });
            })
          Button('覆盖详情页(DIALOG)')
            .layoutWeight(1)
            .type(ButtonType.Capsule)
            .backgroundColor('#F1F3F5')
            .fontColor('#333333')
            .onClick(() => {
              // 不收敛意图:DIALOG 目的页不影响下层可见性,派生值不会翻转,
              // 面板会自然存活,返回后也不需要任何恢复逻辑。
              this.mainStack.pushPath({ name: RouteName.OVERLAY_DETAIL });
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16 })

        Button('打开子模块(独立 NavPathStack)')
          .type(ButtonType.Capsule)
          .backgroundColor('#F1F3F5')
          .fontColor('#333333')
          .margin({ top: 12, bottom: 24 })
          .onClick(() => {
            this.mainStack.pushPath({ name: RouteName.SUB_HOST });
          })
      }
      .width('100%')
      .height('100%')
      // 关键:bindSheet 挂在 NavDestination 的根节点上,保证绑定节点永远存在
      .bindSheet($$this.isSheetShowing, this.filterSheet(), {
        height: SheetSize.MEDIUM,
        detents: [SheetSize.MEDIUM, SheetSize.LARGE],
        showClose: true,
        dragBar: true,
        // EMBEDDED:挂在当前 NavDestination 内,支持 API 12+
        mode: SheetMode.EMBEDDED,
        onWillAppear: () => { console.info('[sheet] onWillAppear'); },
        onAppear: () => { console.info('[sheet] onAppear'); },
        onWillDisappear: () => { console.info('[sheet] onWillDisappear'); },
        onDisappear: () => {
          console.info('[sheet] onDisappear');
          // 面板真的销毁了,派生值必须对齐到 false,否则会留下漂移
          this.isSheetShowing = false;
          // 宿主仍在最上层 → 用户主动关闭 → 意图归零
          // 宿主已被覆盖 → 系统收回 → 保留意图,onShown 时自动恢复
          if (this.isDestinationShown) {
            this.sheetRequested = false;
          }
        }
      })
    }
    .hideTitleBar(true)
    .onShown(() => {
      console.info('[dest] ListPage onShown');
      this.isDestinationShown = true;
      // 这一帧 isSheetShowing 仍是 onHidden 时写入的 false,赋 true 是有效变化
      this.syncSheetVisibility();
    })
    .onHidden(() => {
      console.info('[dest] ListPage onHidden');
      this.isDestinationShown = false;
      // 页面被覆盖,面板会被系统收起,派生值必须同步收敛
      this.syncSheetVisibility();
    })
    .onBackPressed(() => {
      // 返回键优先级第 1 级:先关面板
      if (this.isSheetShowing) {
        this.closeSheet();
        return true;
      }
      // 不消费,交回外层栈
      return false;
    })
  }

  @Builder
  filterSheet() {
    Column() {
      Text('筛选').fontSize(18).fontWeight(FontWeight.Medium)

      Text('品类').fontSize(14).fontColor('#333333').margin({ top: 16, bottom: 8 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.categories, (item: string) => {
          Text(item)
            .fontSize(14)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .margin({ right: 8, bottom: 8 })
            .borderRadius(16)
            .backgroundColor(this.filter.category === item ? '#007DFF' : '#F1F3F5')
            .fontColor(this.filter.category === item ? '#FFFFFF' : '#333333')
            .onClick(() => {
              // 直接改条件对象,可见性完全不受影响
              this.filter = { category: item, priceRange: this.filter.priceRange };
            })
        }, (item: string) => item)
      }
      .width('100%')

      Text('价格区间').fontSize(14).fontColor('#333333').margin({ top: 8, bottom: 8 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.priceRanges, (item: string) => {
          Text(item)
            .fontSize(14)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .margin({ right: 8, bottom: 8 })
            .borderRadius(16)
            .backgroundColor(this.filter.priceRange === item ? '#007DFF' : '#F1F3F5')
            .fontColor(this.filter.priceRange === item ? '#FFFFFF' : '#333333')
            .onClick(() => {
              this.filter = { category: this.filter.category, priceRange: item };
            })
        }, (item: string) => item)
      }
      .width('100%')

      Blank()

      Row({ space: 12 }) {
        Button('查看推荐')
          .layoutWeight(1)
          .type(ButtonType.Capsule)
          .backgroundColor('#007DFF')
          .onClick(() => {
            // 显式的产品决策:推栈即收起。意图归零后,返回时派生值不会回弹
            this.closeSheet();
            this.mainStack.pushPath({ name: RouteName.RECOMMEND });
          })
        Button('取消')
          .layoutWeight(1)
          .type(ButtonType.Capsule)
          .backgroundColor('#F1F3F5')
          .fontColor('#333333')
          .onClick(() => {
            this.closeSheet();
          })
      }
      .width('100%')
      .margin({ top: 24 })
    }
    .width('100%')
    .padding(16)
  }
}

第三步,STANDARD 覆盖页。 它入栈时会触发下层的 onHidden,因此配合派生可见性就能得到"覆盖即收起、返回即恢复"。注意它自己是 STANDARD,走的是 onShown / onHidden;如果它内部也要用 bindSheet,同样用派生模型即可。

// entry/src/main/ets/pages/RecommendPage.ets
import { RouteName } from '../model/FilterModel';

@Component
export struct RecommendPage {
  @Consume('mainStack') mainStack: NavPathStack;

  /** 覆盖页自己的面板也要用派生可见性,避免把同一个 bug 再踩一遍 */
  @State private sheetRequested: boolean = false;
  @State private isDestinationShown: boolean = false;
  @State private isSheetShowing: boolean = false;

  private syncSheetVisibility(): void {
    const next: boolean = this.sheetRequested && this.isDestinationShown;
    if (next !== this.isSheetShowing) {
      this.isSheetShowing = next;
    }
  }

  build() {
    NavDestination() {
      Column() {
        Text('推荐结果页').fontSize(20).fontWeight(FontWeight.Medium)
        Text('根据筛选条件推荐的商品').fontSize(14).fontColor('#666666').margin({ top: 8 })

        Row({ space: 12 }) {
          Button('返回列表页')
            .type(ButtonType.Capsule)
            .backgroundColor('#007DFF')
            .onClick(() => {
              this.mainStack.pop();
            })
          Button('再推一层')
            .type(ButtonType.Capsule)
            .backgroundColor('#F1F3F5')
            .fontColor('#333333')
            .onClick(() => {
              this.mainStack.pushPath({ name: RouteName.RECOMMEND });
            })
        }
        .margin({ top: 24 })

        Text(`当前栈深:${this.mainStack.size()}`)
          .fontSize(12)
          .fontColor('#999999')
          .margin({ top: 16 })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .bindSheet($$this.isSheetShowing, this.helperSheet(), {
        height: SheetSize.MEDIUM,
        mode: SheetMode.EMBEDDED,
        onDisappear: () => {
          this.isSheetShowing = false;
          if (this.isDestinationShown) {
            this.sheetRequested = false;
          }
        }
      })
    }
    .hideTitleBar(true)
    .onShown(() => {
      this.isDestinationShown = true;
      this.syncSheetVisibility();
    })
    .onHidden(() => {
      this.isDestinationShown = false;
      this.syncSheetVisibility();
    })
    .onBackPressed(() => {
      if (this.isSheetShowing) {
        this.sheetRequested = false;
        this.syncSheetVisibility();
        return true;
      }
      return false;
    })
  }

  @Builder
  helperSheet() {
    Column() {
      Text('推荐说明').fontSize(18).fontWeight(FontWeight.Medium)
      Text('基于品类与价格区间计算').fontSize(14).fontColor('#666666').margin({ top: 12 })
      Button('知道了')
        .type(ButtonType.Capsule)
        .margin({ top: 24 })
        .onClick(() => {
          this.sheetRequested = false;
          this.syncSheetVisibility();
        })
    }
    .width('100%')
    .padding(16)
  }
}

第四步,DIALOG 覆盖页。 它是全文里"硬约束"最集中的一个组件:背景默认透明所以内容要自己给底色、只走 onActive / onInactive、API 13 以下没有系统转场。

// entry/src/main/ets/pages/OverlayDetailPage.ets

@Component
export struct OverlayDetailPage {
  @Consume('mainStack') mainStack: NavPathStack;

  build() {
    NavDestination() {
      Column() {
        Text('覆盖详情页').fontSize(20).fontWeight(FontWeight.Medium)
        Text('下层列表页的筛选面板不会被系统收起')
          .fontSize(14)
          .fontColor('#666666')
          .margin({ top: 8 })
        Text('因为 DIALOG 目的页不影响下层的 onShown / onHidden')
          .fontSize(12)
          .fontColor('#999999')
          .margin({ top: 4 })

        Button('返回列表页')
          .type(ButtonType.Capsule)
          .backgroundColor('#007DFF')
          .margin({ top: 24 })
          .onClick(() => {
            this.mainStack.pop();
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      // DIALOG 的"背景默认透明"指容器,内容底色必须自己给
      .backgroundColor('#FFFFFF')
    }
    .hideTitleBar(true)
    // API 11 起可用。DIALOG 不进下层可见性、不触发下层 onShown / onHidden
    .mode(NavDestinationMode.DIALOG)
    .backgroundColor(Color.Transparent)
    // DIALOG 目的页只走 onActive / onInactive,不会走 onShown / onHidden。
    // 所以这个页面不能作为派生可见性的宿主,它永远收不到"可见性翻转"的通知。
    .onActive(() => {
      console.info('[dest] OverlayDetailPage onActive');
    })
    .onInactive(() => {
      console.info('[dest] OverlayDetailPage onInactive');
    })
  }
}

第五步,子模块宿主页与它内部的目的页。 这里是"多 Navigation 并存"的完整实现:子模块持有自己的 NavPathStack、自己的 PageMap,并且把返回键的三级优先级收敛到承载子 Navigation 的那个 NavDestination 上。

// entry/src/main/ets/pages/SubModuleHost.ets
import { SubRouteName } from '../model/FilterModel';
import { SubDetailPage } from './SubDetailPage';

@Component
export struct SubModuleHost {
  @Consume('mainStack') mainStack: NavPathStack;
  /** 子模块自己的路由域,只有本组件能对它 push / pop */
  private subStack: NavPathStack = new NavPathStack();

  @Builder
  subPageMap(name: string) {
    if (name === SubRouteName.SUB_DETAIL) {
      SubDetailPage({ subStack: this.subStack })
    }
  }

  build() {
    NavDestination() {
      Navigation(this.subStack) {
        Column() {
          Text('子模块首页').fontSize(20).fontWeight(FontWeight.Medium)
          Text('这一层是独立的 NavPathStack')
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 8 })

          Button('进入子模块详情')
            .type(ButtonType.Capsule)
            .backgroundColor('#007DFF')
            .margin({ top: 24 })
            .onClick(() => {
              this.subStack.pushPath({ name: SubRouteName.SUB_DETAIL });
            })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      .mode(NavigationMode.Stack)
      .hideTitleBar(true)
      .navDestination(this.subPageMap)
    }
    .hideTitleBar(true)
    .mode(NavDestinationMode.STANDARD)
    .onBackPressed(() => {
      // 返回键优先级第 2 级:子栈还有页面就先弹子栈,并消费这次返回。
      // 放在这里而不是子栈内部的每个目的页上,保证决策点唯一。
      if (this.subStack.size() > 0) {
        this.subStack.pop();
        return true;
      }
      // 子栈已空,不消费,交回主栈由主栈 pop 掉 SubModuleHost
      return false;
    })
    .onShown(() => {
      console.info(`[dest] SubModuleHost onShown, subStack=${this.subStack.size()}`);
    })
    .onHidden(() => {
      console.info(`[dest] SubModuleHost onHidden, subStack=${this.subStack.size()}`);
    })
  }
}
// entry/src/main/ets/pages/SubDetailPage.ets

@Component
export struct SubDetailPage {
  /** 由宿主页在 push 时注入,子栈的操作全部由宿主页发起 */
  subStack: NavPathStack = new NavPathStack();

  build() {
    NavDestination() {
      Column() {
        Text('子模块详情页').fontSize(20).fontWeight(FontWeight.Medium)
        Text(`子栈深度:${this.subStack.size()}`)
          .fontSize(14)
          .fontColor('#666666')
          .margin({ top: 8 })

        Row({ space: 12 }) {
          Button('内层返回')
            .type(ButtonType.Capsule)
            .backgroundColor('#007DFF')
            .onClick(() => {
              this.subStack.pop();
            })
          Button('清空子栈')
            .type(ButtonType.Capsule)
            .backgroundColor('#F1F3F5')
            .fontColor('#333333')
            .onClick(() => {
              // 子栈内部只操作自己的 name 空间。
              // 写主栈的页面名只会静默失败,不会报错。
              this.subStack.clear();
            })
        }
        .margin({ top: 24 })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .hideTitleBar(true)
  }
}

第六步,入口页。 navBar 里放的是首页骨架,它是一个普通组件而不是 NavDestination,所以它天然没有 onShown / onHidden——这一点很重要:任何需要感知自身可见性的页面,都不要放在 navBar 里。

// entry/src/main/ets/pages/Index.ets
import { RouteName } from '../model/FilterModel';
import { ListPage } from './ListPage';
import { RecommendPage } from './RecommendPage';
import { OverlayDetailPage } from './OverlayDetailPage';
import { SubModuleHost } from './SubModuleHost';

@Entry
@Component
struct Index {
  /** 主栈:由入口页创建并持有,通过 @Provide 下发 */
  @Provide('mainStack') mainStack: NavPathStack = new NavPathStack();

  @Builder
  pageMap(name: string) {
    if (name === RouteName.LIST) {
      ListPage()
    } else if (name === RouteName.RECOMMEND) {
      RecommendPage()
    } else if (name === RouteName.OVERLAY_DETAIL) {
      OverlayDetailPage()
    } else if (name === RouteName.SUB_HOST) {
      SubModuleHost()
    }
  }

  build() {
    Navigation(this.mainStack) {
      // navBar 内容:普通组件,不是 NavDestination,
      // 因此没有 onShown / onHidden。不要在这里放需要可见性感知的页面。
      Column() {
        Text('首页').fontSize(20).fontWeight(FontWeight.Medium)
        Text('所有需要可见性感知的页面都以 NavDestination 承载')
          .fontSize(13)
          .fontColor('#666666')
          .margin({ top: 8 })

        Button('进入商品列表')
          .type(ButtonType.Capsule)
          .backgroundColor('#007DFF')
          .margin({ top: 24 })
          .onClick(() => {
            // push 前先清掉栈里已有的同名页面,避免重复叠栈
            this.mainStack.removeByName(RouteName.LIST);
            this.mainStack.pushPath({ name: RouteName.LIST });
          })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .mode(NavigationMode.Stack)
    .hideTitleBar(true)
    .navDestination(this.pageMap)
  }
}

第七步,验证用探针。 上线前把派生过程打出来,确认"收起 → 恢复"这条路径真的走通了,而不是靠肉眼观察。

// 在 syncSheetVisibility 里加一行,即可看到每一次推导的输入与输出
private syncSheetVisibility(): void {
  const next: boolean = this.sheetRequested && this.isDestinationShown;
  if (next !== this.isSheetShowing) {
    console.info(`[sheet] requested=${this.sheetRequested} shown=${this.isDestinationShown} ` +
      `=> isSheetShowing ${this.isSheetShowing} -> ${next}`);
    this.isSheetShowing = next;
  }
}

正常的"STANDARD 覆盖 → 返回"日志序列应当是:

[dest] ListPage onShown            ← 首次显示,派生 true(若意图为真)
[sheet] onAppear
[dest] ListPage onHidden           ← 推入 RecommendPage,宿主不可见
[sheet] requested=true shown=false => isSheetShowing true -> false
[sheet] onWillDisappear
[sheet] onDisappear                ← 系统收回,shown 已为 false,意图保留
[dest] ListPage onShown            ← 返回,宿主重新可见
[sheet] requested=true shown=true => isSheetShowing false -> true
[sheet] onAppear                   ← 面板恢复,且筛选条件未丢

而 DIALOG 覆盖页的日志里不会出现 onHiddenisDestinationShown 始终为 truesyncSheetVisibility 一次都不会触发——面板就因为"什么都没发生"而存活下来了。这正是把可见性交给架构而不是交给定时器的意义。

总结

回到最开始的问题:bindSheet 为什么会在子页面跳转后消失,以及多 Navigation 并存该怎么管。把结论收敛成六条。

第一,bindSheet 的宿主是绑定它的那个页面级组件,跳转后消失是归属关系决定的必然结果,不是 bug。 渲染层级上它在 overlay 最上面,生命周期归属上它属于宿主节点。宿主进入不可见状态时,框架主动销毁模态节点,并且这条路径不回写 $$ 绑定变量。理解这个归属关系,你才能预判它何时消失、何时幸存。

第二,"再点也弹不出来"的根因是状态漂移,不是 bindSheet 失效。 bindSheet 消费的是可见性的变化,不是可见性的。变量停在 true 时,任何一次赋 true 都是零信息量的;同步栈内的 false → true 在差分后同样只剩 true。修复方向是让变量先真正回到 false,而不是想办法再赋一次值。

第三,onNavBarStateChange 表达的是导航栏显隐,不是"是否在栈顶"。 它在 Stack 模式下"看起来能用"是因为 navBar 恰好随 push 隐藏;在 Split 分栏下 navBar 常驻可见,回调不触发,方案必然失效——这类只在特定设备形态上复现的 bug,排查成本最高。同理,onPageShow@Entry 页面的生命周期,Navigation 内部推栈不会触发它,所以"返回后在 onPageShow 里重开面板"这条路径没有执行机会。

第四,正确做法是用 NavDestination.onShown / onHidden 作为锚点,让可见性由单一数据源派生。 两个正交输入(用户意图、宿主可见)、一个派生出口(唯一消费 bindSheet 的变量)、一个同步函数(唯一写入口),"被覆盖时收起、返回后自动恢复"就成了推导出来的必然结果,needRestore 那类标记和 setTimeout 那类时间赌注都会失去存在必要。前提是列表页必须由 NavDestination 承载——放在 navBar 里就拿不到任何权威的可见性信号。

第五,覆盖页的模式决定面板是"收起"还是"幸存",这是产品层面的取舍。 STANDARD 覆盖页会触发下层 onHidden,面板收起、返回恢复;DIALOG 覆盖页不影响下层可见性,面板自然存活。同时要记住 DIALOG 的硬约束:API 11 引入、API 13 前无系统转场、只走 onActive / onInactive(所以它自己不能做派生可见性的宿主)、背景透明只针对容器。SheetMode.EMBEDDED 也有自己的约束:只能挂在 PageNavDestination 上、必须在节点上树后才能拉起、显示期间不能切 mode、不铺满全屏遮罩且会失去"点遮罩关闭"的语义——两种模式的行为不能兼得。

第六,多 Navigation 并存要按"路由域"来治理,不能当作动画技巧。 一个栈只有一个归属组件,路由名空间相互隔离,返回键的优先级收敛到承载子 Navigation 的那个 NavDestination 上(先关面板 → 再弹子栈 → 最后交外层)。别为了做转场而叠两个 Navigation,两套时序没有机制可以对齐;也别在子栈里按名操作外层栈的页面,那只会静默失败。

最后补一句工程判断:如果产品上本来就接受"点查看推荐后收起筛选面板",那在推栈前显式把意图置 false 就行,整类状态同步问题直接消失。能通过产品决策消除的技术问题,不必用工程手段去兜。 反过来说,只有当需求真的是"面板要跨页面存活"或"返回后必须恢复"时,才值得把上面这套派生模型完整落地——而一旦落地,它就不会再因为机型、分栏模式或者系统负载而偶发失效。

Logo

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

更多推荐