嵌套Tabs滑到边缘像卡死:原来我漏看了边界状态
有一次做详情页页面外层是“工作台 / 报表”其中的工作台里又放了一层“概览 / 明细 / 日志”。我从概览一路滑到日志想再按原方向继续浏览画面却像被钉住了一样。第一反应通常是嵌套 Tabs 卡住了。我当时也这么想。先去看容器高度又怀疑内层内容不够长再怀疑是不是TabsController没同步。折腾了一圈页面没有报错索引也没有乱真正的问题反而很朴素我没有先问清楚内层已经到边界后这一次连续手势本来应该交给谁。在SELF_ONLY模式里答案就是“不交给外层”。内层 Tabs 只处理自身滚动到了最后一页后继续拖动外层保持不动是设计结果不是卡死。把这件事看错后面的排查就全会跑偏。先把“滑不动”拆成两种情况复杂页面里“滑不动”至少可能有两层含义。一种是真的没有收到手势按钮和页签都不变化另一种是手势被当前容器消费了但容器已经到边界按规则不再把它交给父容器。两者在用户眼里都像没反应工程上却不是同一件事。这个 Demo 提供了SELF_FIRST和SELF_ONLY两种策略。前者的意思是内层优先内层到边界后父组件可以承接后续手势后者则更克制内层只管自己父组件不会因为内层到边界就自动接班。我一开始的问题就在这里。我在SELF_ONLY下期待外层切到“报表”得到的却是内层仍停在“日志”。于是我把正确的策略表现当成了滚动故障。当时的操作过程是这样的我先把内层移动到第三个页签“日志”再继续做同一个方向的拖动。页面上的“内层”已经是日志“边界状态”也说明它在最右侧可我只盯着外层“工作台 / 报表”有没有变。外层不变我便说它卡住了。后来把页面中的edgeState和lastGesture一起放进观察区问题一下就清楚了。SELF_ONLY下页面会记录“边界后仍停留在内层 Tabs”和“外层不接续本次手势”。原来它不是没处理而是很明确地处理成了“不转交”。SELF_FIRSTSELF_ONLY内层 Tabs 进入最后一个页签继续沿同一方向拖动当前 nestedScroll 模式父层承接后续手势外层索引切换edgeState: 边界后交给外层 Tabs手势继续停留在内层外层索引不变edgeState: 边界后仍停留在内层 Tabs看起来像卡住 实际符合策略这张图是我后来定位问题的起点。先分清“应当承接”和“不应承接”再讨论有没有异常。否则只要外层没有切换就会被我统统归类为失败。不是先改 UI而是先给边界留一句话原先如果页面只显示内外两层的页签测试时会很痛苦。我们只能在拖动后猜现在到底到边界了吗父容器有没有收到手势还是刚才的手势根本没被识别现有页面用了三个很小的状态来回答这些问题State edgeState: string 未到达边界; State lastGesture: string 等待操作; State interactionCount: number 0;它们看上去不像核心功能却是这类交互排查最有用的线索。edgeState告诉我现在是不是在边界lastGesture把上一步的解释留在屏幕上interactionCount则避免我把旧截图当成当前操作的结果。我不再把“用户感觉像卡住”直接翻译成“组件坏了”。先拿到这三个状态再回到真实的策略分支里看。因为嵌套交互不是一个视觉问题它首先是手势责任的问题。真正决定去向的是这一段分支页面为了方便截图提供了“到达内层边界”和“继续拖动”两个动作。它们不是用来伪造真实手势而是把边界场景固定下来让人能先观察策略的结果。核心分支在continueDragAtEdge()private continueDragAtEdge(): void { if (this.nestedMode TabsNestedScrollMode.SELF_FIRST) { this.changeOuterIndex(this.outerIndex 0 ? 1 : 0); this.edgeState 边界后交给外层 Tabs; this.lastGesture SELF_FIRST外层切到「${this.getOuterName()}」; } else { this.edgeState 边界后仍停留在内层 Tabs; this.lastGesture SELF_ONLY外层不接续本次手势; } this.interactionCount 1; }读到这里之前的误判就没有立足点了。SELF_ONLY下没有调用changeOuterIndex因此外层不变不是偶发问题而是代码明确写下的结果。真的要排障也应该先确认模式有没有被误切换而不是在外层容器上加各种无关的刷新逻辑。这里还有一个值得记住的习惯不要只根据最终页签判断手势去向。页面已经到了“报表”也可能是用户直接点了外层页签页面还停在“工作台”也不代表手势没有执行。需要同时看模式、内层索引、外层索引和lastGesture这四个信息组合起来才够解释一次操作。我后来甚至把这四个信息当成一次“手势收据”。用户的手指离开屏幕后页面至少应该留下它落在了哪里、下一步由谁接、最后页面停在哪儿。没有这张收据大家就只能凭最后的画面倒推过程。复杂交互最怕这种倒推因为同一个画面可能由很多条路径得到。例如外层现在显示“报表”可能是用户直接点击了外层页签也可能是SELF_FIRST在内层末页承接了一次连续拖动。前者是直接跳转后者是边界接力最终结果相同产品含义却不同。若要查“用户为什么会跳到报表”只看outerIndex是不够的lastGesture才能把原因留下来。第二个误区把截图控制当成真实手势这个页面有两个很方便的按钮“到达内层边界”和“继续拖动”。它们让我第一次做截图时省了很多时间但也让我差点把页面内的状态演示当成真机上已经验证过的连续手势。两者不能混。按钮调用的是页面自己的prepareInnerEdge()和continueDragAtEdge()它们用于把内层索引和边界状态稳定到一个可观察的位置。真实手势则由 Tabs 的嵌套滚动规则、设备触摸、页面尺寸与当时的内容位置一起决定。前者适合查状态模型后者才适合确认用户连续滑动时的体验。我现在会把这两种验证分开写。代码和页面按钮证明的是当状态进入边界时SELF_FIRST和SELF_ONLY分支分别怎样更新外层索引和说明文本。真机拖动证明的是在特定设备上连续手势有没有按预期抵达这个分支。它们互相支撑但谁也不能替代谁。不能替代需要对照页面按钮: 到达内层边界固定 innerIndex 和 edgeState页面按钮: 继续拖动验证策略分支与状态文案真机连续手势Tabs nestedScroll 处理验证触摸过程和边界体验这条边界对写文章同样重要。没有真机手势截图时我可以清楚地说“页面分支和状态说明如何设计”不能把它扩写成“所有设备滑动都丝滑”。把话收在代码能够托住的地方读者反而更容易相信后续补充的实测结果。我后来怎么复测复测时我不再直接手滑两下再看结果而是把动作定死。先重置页面确认外层是“工作台”、内层是“概览”。然后选择SELF_FIRST点击“到达内层边界”让内层进入“日志”。再点击“继续拖动”此时外层应切换到“报表”状态区应写明“边界后交给外层 Tabs”。接下来回到初始状态换成SELF_ONLY重复完全相同的步骤。这一次外层应保持“工作台”但它不是无响应状态区必须写出“边界后仍停留在内层 Tabs”。如果只看到外层不变却没有这条解释那才需要继续查状态更新或手势绑定。否是否是点击重置外层: 工作台 内层: 概览选择 SELF_FIRST到达内层边界继续拖动外层是否切到报表检查 nestedMode 与 changeOuterIndex 调用记录 SELF_FIRST 边界承接再次重置选择 SELF_ONLY到达内层边界后继续拖动外层是否保持不变 且 edgeState 有解释检查 edgeState 与 lastGesture 更新记录 SELF_ONLY 不转交是预期行为这条流程没有替代真机连续手势验证。页面按钮可以固定状态帮助我们把策略差异说清楚真机上用户手势的惯性、速度和不同屏幕尺寸仍要单独复核。但至少在进入真机前逻辑层已经没有“把预期行为当 Bug”的误会。如果状态区也没有解释我会从哪里继续查假设按下“继续拖动”后外层没有变化edgeState也仍然是旧值。这时就不能再说“SELF_ONLY 正常”。因为当前模式是什么、函数有没有执行、状态有没有更新全都没有证据。我会从最短路径往回找先看continueDragAtEdge()是否被按钮调用再看nestedMode当前的值最后看interactionCount有没有加一。这个计数不是性能指标也不是业务数据它只是一次很廉价的自检。只要点击后计数不动就别急着从手势策略里找原因函数本身可能就没有走到。如果计数变了、lastGesture也更新了但edgeState不对那就检查两个分支里写入的具体文本以及页面展示是否真的绑定到了这个字段。很多“状态没有更新”的问题最后不是状态没有变而是界面读的是另一份旧变量。嵌套结构复杂时名字接近的字段尤其容易造成这种错觉。如果状态全都对外层仍然在不该变化时变化才回到changeOuterIndex()和outerTabsController.changeIndex(index)这条路径。这里要注意不建议为了让外层“看起来更灵敏”就无条件调用changeOuterIndex()。那样会破坏SELF_ONLY的语义页面从“不解释边界”变成“无视边界”故障只是换了一种样子。为什么这件小事会影响详情页体验详情页的嵌套 Tabs 常见于订单、设备、工单、报告这些信息很多的场景。外层用来切换大模块内层用来浏览当前模块的章节。用户在里面连续滑动时脑子里通常没有“父组件”和“子组件”的概念他只会觉得自己正在往下看内容。如果内层到尽头后不提示策略用户会反复划觉得页面坏了如果无条件把手势甩给外层用户又可能在没意识到的情况下跳离当前章节。两种体验都不理想。SELF_FIRST和SELF_ONLY并没有谁天然更好它们只是把“接力”与“不接力”说清楚。真正要选哪一个要看页面想让用户保持当前位置还是希望他在读完内层后自然进入下一个外层模块。这也是我在复盘里不把SELF_FIRST写成“修复方案”的原因。它只修复了我对策略的误解。页面到底应不应该使用它仍然是产品流程的选择。代码能负责把选择落实得一致不能替团队决定用户在边界之后应该去哪里。一个小状态能省掉很多误会页面里还有readingProgress、readingJourney和readingDetail。最开始我觉得这些只是 Demo 的辅助说明后来发现它们很适合用来检查承接结果有没有落在正确位置。当内层刚进入第二页时进度是 45%文字是“阅读中”内层到最后一页且还没承接时状态是“边界待承接”只有SELF_FIRST并且外层进入报表后页面才显示“已完成”和 100%。这让“外层是否接续”不再只是看一个页签名称而是能看到用户的阅读路径到底有没有走完。当然这不代表真实业务里的所有阅读体验都能用一个百分比解释。这里的进度只是现有 Demo 的可观察状态不是用户行为分析数据。但它提醒我嵌套导航一旦涉及边界就应该给使用者一个方向感。没有方向感的界面即使技术上正确也容易被误会成“卡住”。以后再遇到类似情况我先问这三个问题我补做的回归记录回归时我会先重置到外层工作台、内层概览再分别选中两种模式。每种模式都按“到达内层边界、继续拖动、观察外层名称、观察内层名称、记录边界状态”的固定顺序做一次不把按钮模拟与真实连续手势混为一谈。随后切回初始状态重复一次确认interactionCount、阅读进度和最后手势文案都来自本轮操作。这样即使视觉上仍像停住也能明确它是 SELF_ONLY 的边界规则还是状态字段没有随操作更新。当前内层真的到边界了吗还是内容本身还可以继续滚当前模式规定的是“交给父层”还是“只处理自己”页面有没有把边界和最近一次手势的解释留出来这三个问题能过滤掉大部分无效排查。以前我会先去调高度、关动画、换控制器像在一扇没上锁的门前到处找钥匙。现在会先看门上写的是“推”还是“拉”。嵌套 Tabs 不怕有边界怕的是边界发生后页面什么也不说。只要责任归属、当前索引和最近手势都能留在屏幕上“滑不动”就不再是一个模糊的抱怨而会变成一个能定位、能复测、也能和产品解释清楚的状态。这次复盘最后留给我的不是“遇到嵌套滚动就换模式”这样的口诀而是更简单的一件事先给手势找主人。内层还没到头主人是内层到了边界以后要么明确交给外层要么明确告诉用户仍由内层处理。只要这句话没有被代码和页面状态说清楚再好的动画也只能把困惑包装得更顺滑一点。做回归时我还会刻意连续跑两轮而不是只验证一次。第一轮从SELF_FIRST走到外层承接第二轮重置后切到SELF_ONLY确认外层没有被上一轮残留的索引带走。两轮之间只改变策略不改内层页签和操作顺序。这样能检查resetDemo()是否确实把外层、内层、边界文字和阅读进度一起还原。嵌套页面最怕的不是一个分支写错而是上一次操作留下的状态悄悄参与下一次判断让一个偶发问题看上去像手势规则本身不稳定。本文依据现有TabsNestedScrollPage的模式分支与状态更新撰写。真实连续手势在不同设备上的表现仍应在 HarmonyOS 6.1.1 API 24 目标环境中单独复核。