Navigation 路由下的半模态生命周期:bindSheet 为什么会随子页面跳转消失,以及多 Navigation 并存怎么管
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,这次赋值不构成状态变化,不会触发任何刷新。手动做一次 false 再 true 也弹不出来——如果两次赋值在同一个同步执行栈里,框架做差分时看到的最终值仍然是 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),文档说明是"导航栏显示状态切换时触发该回调",isVisible 为 true 表示显示、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 的根节点)。宿主进入不可见状态时,这个模态没有继续存在的理由,框架会主动销毁它。
我没有在文档里找到关于内部节点树的规范描述,所以不去猜它的具体实现,但从三处可观测证据可以确定这个归属关系是真实存在的:
- push 覆盖发生时,sheet 的
onDisappear一定触发——说明节点真的被销毁了,而不是被隐藏; - 绑定变量一定不被回写——说明这条销毁路径与用户交互路径是两条不同的代码路径;
- 把
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 之间没有约束关系,两者可以各自为政。而且这类游标定时器如果不做清理,还会在组件销毁后继续持有引用并对着已销毁的组件赋值。
关键判断在这里:onNavBarStateChange 与 pushPath 处在同一个同步执行栈里,这才是"必须跨帧"的原因;而不是"框架需要时间把节点建好"。 只要你把"置回可见"的赋值放到下一个真实的生命周期事件里,天然就是跨帧的,不需要任何魔法延时。
四、正确建模:单一数据源派生可见性
与其维护 isSheetShowing 加 needRestore 这一对互相打架的变量,不如把它拆成两个正交输入,可见性作为派生结果:
// 用户意图:用户是否希望筛选面板处于打开状态
@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 模式之前,必须先把这几条约束过一遍,每一条都可能导致"看起来没生效":
- 版本:
NavDestinationMode从 API 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 | undefined,timeout必须大于动画最长耗时,否则系统超时后强制结束转场;transition回调里必须调用proxy.finishTransition()。而且transition的入参是NavigationTransitionProxy,NavContentInfo只提供name、index、mode、param这类描述信息,拿不到可渲染的组件句柄。正确做法是让目的页自己在aboutToAppear里注册动画处理器,用自身的@State驱动属性动画。
八、SheetMode.EMBEDDED 的硬约束清单
"让面板在子页面压上来时幸存"这个需求,很多人会先想到 SheetMode.EMBEDDED(API 12 起)。它的官方定位是:把半模态挂在当前 Page / NavDestination 内,新页面可以覆盖它,返回后半模态依旧存在、面板内容不丢失。默认的 OVERLAY 则是显示在当前 UIContext 的所有页面之上。
同样有一份硬约束清单:
- 只支持挂载在
Page或NavDestination节点上,两者都在时优先挂到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 构建函数,路由名不要复用。popToName、removeByName 这类按名操作只在自己的栈里有效,在子栈里写一个外层栈的页面名,只会静默失败,不会报错。
规则三:返回键必须有单一决策点。 多栈并存的经典 bug 是"返回键一次退了两层"。正确做法是把优先级固定下来,写在一个函数里:
- 当前页面有半模态/弹窗打开 → 先关闭它,消费这次返回;
- 子栈
size() > 0→subStack.pop(),消费; - 否则不消费,交回外层栈,由外层栈 pop 当前目的页。
NavDestination 的 onBackPressed 回调返回 boolean,true 表示"我消费了这次返回",false 表示继续向上冒泡。把这个判断放在承载子 Navigation 的那个 NavDestination 上,而不是放在子栈内部的每个目的页上——后者的行为受树深度影响,容易和上层的处理打架。
别忘了子栈也有生命周期。 子栈里的 NavDestination 同样有 onShown / onHidden。当外层的页面压到子模块之上时,子栈的当前目的页也会收到 onHidden。如果子栈里也有 bindSheet,同样要用派生可见性建模,否则你会把同一个 bug 在子模块里再踩一遍。
十一、方案选型对照
| 需求 | 覆盖页模式 | sheet 模式 | 生命周期锚点 |
|---|---|---|---|
| 覆盖时收起面板,返回后自动恢复 | STANDARD | EMBEDDED 或 OVERLAY | 下层 onShown / onHidden |
| 覆盖时面板必须继续存在 | DIALOG | EMBEDDED(必须) | 下层不更新,派生值不翻转 |
| 覆盖页自身也有 sheet | 任意 | EMBEDDED | DIALOG 用 onActive / onInactive;STANDARD 用 onShown / onHidden |
| 需要点遮罩关闭的交互 | 任意 | OVERLAY | 接受"被覆盖必然收起" |
最后一条工程建议:如果产品上本来就希望"点查看推荐后就收起筛选面板"(这更符合大多数电商 App 的交互),那在推栈前显式把 sheetRequested 置 false 就好,整类状态同步问题会直接消失,不必为了让面板"复活"去和生命周期较劲。能通过产品决策消除的技术问题,不要用工程手段去兜。
示例代码
下面给出一个完整可运行的工程结构。为了覆盖全部要点,代码分成七个文件:模型定义、核心的列表页(派生可见性 + 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 覆盖页的日志里不会出现 onHidden,isDestinationShown 始终为 true,syncSheetVisibility 一次都不会触发——面板就因为"什么都没发生"而存活下来了。这正是把可见性交给架构而不是交给定时器的意义。
总结
回到最开始的问题: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 也有自己的约束:只能挂在 Page 或 NavDestination 上、必须在节点上树后才能拉起、显示期间不能切 mode、不铺满全屏遮罩且会失去"点遮罩关闭"的语义——两种模式的行为不能兼得。
第六,多 Navigation 并存要按"路由域"来治理,不能当作动画技巧。 一个栈只有一个归属组件,路由名空间相互隔离,返回键的优先级收敛到承载子 Navigation 的那个 NavDestination 上(先关面板 → 再弹子栈 → 最后交外层)。别为了做转场而叠两个 Navigation,两套时序没有机制可以对齐;也别在子栈里按名操作外层栈的页面,那只会静默失败。
最后补一句工程判断:如果产品上本来就接受"点查看推荐后收起筛选面板",那在推栈前显式把意图置 false 就行,整类状态同步问题直接消失。能通过产品决策消除的技术问题,不必用工程手段去兜。 反过来说,只有当需求真的是"面板要跨页面存活"或"返回后必须恢复"时,才值得把上面这套派生模型完整落地——而一旦落地,它就不会再因为机型、分栏模式或者系统负载而偶发失效。
更多推荐



所有评论(0)