商品详情页的经典三段式布局:原生头图、Web 富文本、原生推荐。看起来只是把三个组件纵向堆起来,但第一次真机滑动时,问题会接连出现:
手指从 Web 区域滑到底,再往下滑 —— 外面那一层纹丝不动
在 Web 上快速抛滑,到边界时 惯性突然消失 ,像撞上一堵看不见的墙
滚到页面最顶部下拉, 刷新不触发
在 Web 边缘轻拉, 回弹弹了两下
单看每个现象,都像是配置没写对。但把所有现象放在一起,会浮现出一个共同点: 它们都发生在"边界"上 —— Web 的边界、外层容器的边界、手势归属的边界。
这篇文章要论证一个判断: 这些边界问题不是配置疏漏,而是两个滚动世界之间缺少通信协议所导致的必然结果。 理解这一点之后,"该用哪个方案"就不再是经验问题,而是一个可以被推导出来的结论。
在 ArkUI 里,绝大多数手势的归属是 明确的 。
一次点击发生在哪个组件上?命中测试(hit test)沿着组件树自顶向下走一遍,第一个满足条件的节点拿走这个事件。长按、双击同理。这类事件是 离散的 ——有明确的起点和终点,归属在事件发生的那一刻就被确定了。
滚动不是这样。滚动是 连续的、可累积的、可传递的 。一次滑动到底该由谁来消费,不能靠"谁被手指盖住"来决定,而必须被 分配 。
这个差别决定了一件事: 所有滚动容器必须生活在一个共同的协议里 ——它们得知道彼此的存在,能协商"这一帧的偏移量归谁"。这就是 NestedScrollMode 存在的理由,也是 onScrollFrameBegin 回调存在的理由。
问题在于,Web 组件并不住在这个协议里。
ArkUI 的滚动容器(Scroll、List、Grid、WaterFlow…)都由框架自身实现。它们共享同一个滚动模型,所以框架可以在它们之间做调度——这就是嵌套滚动能工作的基础。
Web 组件则不同。它的内容是 HTML/CSS,由 渲染引擎 负责解析、布局、绘制和滚动。滚动这件事,从手指按下到画面移动,整条链路都在渲染引擎内部完成, 框架只是一个宿主 。
| | ArkUI 滚动容器 | Web 组件 | | --- | --- | --- | | 滚动实现者 | ArkUI 框架 | 渲染引擎 | | 是否感知兄弟容器 | 是 | 否 | | 是否接受框架调度 | 是 | 否 | | 手势协议参与度 | 完整 | 协议外 |
框架与 Web 之间没有原生的滚动协调协议。 当你把 Web 塞进 Scroll 里,实际上是让两个互不相识的系统共用一块屏幕区域。它们各自都认为"垂直滑动应该归我"——
冲突不是 bug,是这个架构的默认行为。
把这个认知落到代码上,一次手指滑动在 Web 混排场景里只有两种可能的去向:
手指滑动 │ ├─→ 被 Web 的渲染引擎接收 → 它自己滚动,框架完全不知道 │ └─→ 被 ArkUI 框架接收 → 按嵌套滚动协议在外层容器间分配
不存在第三种情况:框架无法"命令"Web 滚到某个位置然后继续接管惯性。 这个限制在后面第二章会显现出它的全部后果。
所有方案,本质上都是在回答同一个问题: 如何让这两条互不相通的路径产生确定的归属。
复现:手指从 Web 中部持续上滑,滑过 Web 底部边界后,继续滑 —— 外层不跟。
从第一章的模型看,这几乎是必然的:Web 收到手势,自己滚到内容末尾,然后 继续消费这个手势 (因为它不知道外层容器的存在,也就不知道"我滚到头了该把剩下的偏移量让出去")。
外层 Scroll 从头到尾没收到过任何事件。它不是"没响应",而是 根本没有被通知 。
NestedScrollMode 的四个枚举值,正是在"分配"这件事上做文章:
| 模式 | 分配规则 | 直觉 | | --- | --- | --- | | SELF_ONLY | 全部归自己 | "别来沾边"(默认) | | SELF_FIRST | 自己先滚,边界外归父组件 | "剩下的是你的" | | PARENT_FIRST | 父组件先滚,边界外归自己 | "你先来,剩下的给我" | | PARALLEL | 两边同时滚 | 视差效果 |
值得单独说一句方向命名——这是最容易配反的地方,而配反了会 完全不生效 :
scrollForward → 向内容末尾方向滚动 → 通常对应手指上滑 scrollBackward → 向内容起始方向滚动 → 通常对应手指下滑
注意这是 内容流向 ,不是手势方向。命名方式与直觉相反,所以对照文档逐字确认是必要的,不要凭感觉写。
这个症状最值得深挖,因为它暴露了整套机制里最脆弱的一环。
在父组件优先滚动的场景中,当 Web 组件进行惯性滚动(抛滑)时,若父组件到达边界且未完全消耗滚动速度,会导致 Web 组件停止滚动。 该问题存在于 API 26.0.0 以下版本 ,已在 API 26.0.0 中修复 。
"到达边界时 Web 停止滚动"——为什么?线索藏在官方 FAQ 的另一个案例里。
那个案例的场景是:RelativeContainer 绑定了 PanGesture,内部包含 Web。快速滑动触发 PanGesture 后, Web 由于惯性仍然在滚动,此时设置 scrollable 为 false 也没有效果 。
setScrollable(false) 对正在进行的惯性滚动无效 —— 这一句是理解整个问题的钥匙。
原因在于: 惯性滚动(fling)不是触摸事件驱动的,它是渲染引擎内部的一个动画。
触摸事件是同步的、可拦截的、可取消的。手指抬起之后,渲染引擎根据抬起瞬间的速度启动一个减速动画——从此刻起,这个动画的 所有权完全在渲染引擎侧 。框架既看不到它,也无法取消它。
框架能做的: 决定 "要不要把触摸事件给 Web" 框架做不到的:接管或取消 Web 侧已经在跑的惯性动画
回到那个缺陷:外层容器到达边界时,此时正在消耗滚动速度的是 Web 侧的 fling 动画。框架想让滚动权移交给自己,但它 碰不到那个动画 ,于是画面就停在了那里。
这解释了为什么官方文档在描述该缺陷后,紧接着给的建议是改用"滚动偏移量由父组件统一派发"方案—— 不是换一种配置,而是换一种控制权模型 。我稍后会解释为什么这个建议是必然的。
回弹(edge effect)是同一个问题的另一个切面。
Web 有自己的过滚动效果(overscroll),外层 Scroll 也有 EdgeEffect.Spring 。两者都不知道对方存在,于是:
Web 到达自身边界 → 触发一次弹性动画 外层容器到达边界 → 再触发一次弹性动画 结果:用户看到 "弹了两下" ,或者两次弹性相互削弱,变成 "卡在半路"
建议配置过滚动模式为关闭状态。当过滚动模式开启时,当用户在 Web 界面上滑动到边缘时,Web 会通过弹性动画弹回界面,会与 Scroll 组件的回弹相互冲突,影响体验。
回弹必须单点化 ——这是后面第四章"通用法则"的第一条。
把三个症状放在一起看,它们指向同一个根源:
| 症状 | 表面原因 | 真实原因 | | --- | --- | --- | | 边界断档 | 没配 nestedScroll | Web 不知道外层存在,不会归还偏移量 | | 惯性撞墙 | 版本 bug | 惯性动画所有权在渲染引擎,框架无法接管 | | 回弹叠加 | 没关 overScrollMode | 两套回弹系统各自独立运行 |
既然根源是"控制权归属不明",那么解法必然围绕 如何确立控制权 展开。这三种策略的差别,是控制权介入时机的差别——从最早(根本不产生问题)到最晚(逐帧接管)。
layoutMode(WebLayoutMode.FIT_CONTENT) 让 Web 组件的高度随 H5 内容自适应撑开。Web 不再是"一个有自己滚动条的窗口",而变成"一段有确定高度的内容"。
这一步的巧妙之处在于 它让第一章描述的两个世界不再冲突,因为第二个世界消失了。
Web 没有独立滚动能力之后,"Web 内部滚动和谁抢事件"这个问题不成立——没有内部滚动了。剩下的只是一个普通的长内容,由外层容器统一滚动。
手势边界统一(只有一个滚动容器,没有归属争议)
回弹统一(只有一层)
下拉刷新可用(外层在顶部时正常触发)
惯性撞墙不可能发生 (Web 侧根本没有 fling 动画了)
这是一个"降维"解法 ——它不解决"两个滚动容器如何协调",而是把问题降维成"只有一个滚动容器"。
| 限制 | 影响 | | --- | --- | | 不支持瀑布流网页(下拉到底加载更多) | H5 无法做无限滚动 | | 不支持 H5 内部独立滚动区 | 页面内不能有 overflow: scroll 的区块 | | 仅高度自适应,不支持宽度自适应 | 横向滚动不可用 | | 不支持通过 height 属性修改高度 | 高度完全由内容决定 | | 键盘避让 RESIZE_CONTENT 不生效 | 输入场景需另做处理 |
Web({
src: $rawfile('detail_richtext.html'),
controller: this.webController,
// ① 全量展开场景必须显式指定同步渲染。
// 内容宽高超过 7680px(物理像素)时,异步渲染会导致白屏或布局错误。
renderMode: RenderMode.SYNC_RENDER
})
.layoutMode(WebLayoutMode.FIT_CONTENT) // ② 高度随内容自适应
.overScrollMode(OverScrollMode.NEVER) // ③ 关闭 Web 自身回弹,避免与外层冲突
.zoomAccess(false) // ④ FIT_CONTENT 不支持缩放第 ③ 项尤其容易漏。它的作用不是"优化",而是 移除一个独立的回弹系统 ——这正是 2.3 节问题的解药。
适用判断 :商品详情、长文章、协议页——这类"静态、确定、一次性呈现"的 H5 内容,几乎都应该走这条路。
保留 Web 的独立滚动,但用 NestedScrollMode 告诉框架"这个方向上的优先级是什么":
Web({ src: ..., controller: this.webController })
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST, // 上滑:外层先滚(收起头图)
scrollBackward: NestedScrollMode.SELF_FIRST // 下滑:Web 先滚到顶,再展开头图
})它的本质是 声明式地建立协议 :框架据此得知"Web 也是这个滚动体系的一员",从而在分配偏移量时把 Web 纳入考量。
这解决了 2.1 的断档问题,但 解决不了 2.2 的惯性问题 ——因为协议能决定"触摸事件给谁",却仍然碰不到渲染引擎内部的 fling 动画。这正是官方文档在描述该缺陷后建议改用派发方案的原因。
适用判断 :API 26.0.0 及以上,且 H5 必须保留独立滚动、联动逻辑又比较简单的场景。
这个方案换了一个根本思路: 既然 Web 的滚动不归框架管,那就先把它收归框架,再由框架统一分配。
// ① 关掉 Web 自己的触摸滚动 —— 这是全部前提
this.webController.setScrollable(false, webview.ScrollType.EVENT);
// ② 外层逐帧拦截偏移量
.onScrollFrameBegin((offset: number, state: ScrollState) => {
return this.dispatchScrollOffset(offset);
})
// ③ 把偏移量指派给当前该消费它的那一层
private dispatchScrollOffset(offset: number): ScrollResult {
if (offset > 0) { // 手指上滑
if (!this.isWebAtBottom()) { // Web 还没到底 → 给 Web
this.webController.scrollBy(0, offset);
return { offsetRemain: 0 };
}
if (!this.outerScroller.isAtEnd()) {
return { offsetRemain: offset }; // 外层自己滚
}
this.bottomListScroller.scrollBy(0, offset); // 给底部列表
return { offsetRemain: 0 };
}
// …… 下滑方向对称处理
}setScrollable(false, webview.ScrollType.EVENT) 的语义是 只禁用触摸事件触发的滚动 。
这一点的关键性常被忽略:它禁掉的是"输入路径",而 保留了对滚动位置的编程控制能力 ( scrollBy 、 scrollTo 等仍然可用)。
输入路径:手指 → 外层 Scroll(唯一入口,由框架统一分配) 输出路径:框架 → scrollBy ( 0 , offset) → Web 滚动到指定位置
Web 从"自主滚动的容器"被改造成了"受控滚动的显示区域"。 控制权完成了移交。
顺带说明 ScrollType.EVENT 为什么优于其他选项:因为我们需要保留 scrollBy 这个"输出通道"。如果连 API 滚动也禁掉,派发机制就没有执行手段了。
这是最容易写错、也最能体现设计意图的一处。
onScrollFrameBegin 的返回值语义是: 外层容器还需要自己消费多少偏移量。
返回 offset → "我没处理,外层你全吃掉" → 外层滚动
返回 0 → "我已经处理完了,你不需要动"
派发给 Web 时必须返回 0 ,否则外层会同时滚动,出现双层同步位移(视觉上就是内容跳了一下)。
但这里还有一个更精妙的点: 返回 0 而不做任何中断,可以让外层保持惯性动画的连续性。 官方示例中特别强调了这一点——如果直接中断回调流程,抛滑到 Web 区域时会出现"突然刹住"的手感。
这个设计的哲学是:每一帧都要明确回答"这一帧的滚动责任归谁"。 这正是第一章所说的"滚动需要被分配"的字面实现。
| 项 | 说明 | | --- | --- | | 复杂度 | 需要自行处理边界判断、惯性衔接、方向对称 | | 维护成本 | 逻辑与具体布局强耦合,布局变动要同步改派发逻辑 | | 前提约束 | Web 高度需固定(与 FIT_CONTENT 互斥) | | 优势 | 唯一能在低版本实现"父组件优先且不中断 Web 滚动"的方案 |
适用判断 :H5 必须是瀑布流或有内部独立滚动区;或目标设备低于 API 26.0.0 且需要 PARENT_FIRST 语义。
三种策略不是平行的备选项,它们有 明确的优先级 :
H5 是静态、确定的内容(详情/文章/协议)? │ ├─ 是 ──→ 【FIT_CONTENT】消除嵌套 │ 最优解,且维护成本最低 │ └─ 否(必须有独立滚动 / 瀑布流) │ ├─ API ≥ 26.0 . 0 且联动简单 ──→ 【nestedScroll】 │ └─ 低版本 或 需要像素级控制 ──→ 【偏移量派发】
决策的实质是:在"消除能力"和"承担复杂度"之间权衡。 FIT_CONTENT 用"不能无限滚动"换来了零冲突;派发方案保留了全部能力,代价是复杂度。中间那条路(nestedScroll)只在特定版本下成立。
只允许最外层容器拥有 EdgeEffect ├─ Web: overScrollMode (OverScrollMode.NEVER) ├─ 内层 List: edgeEffect (EdgeEffect.None) └─ 最外层: edgeEffect (EdgeEffect.Spring)
同一时刻只能有一个层在"主动消费"手势。要么靠 nestedScroll 声明式分配,要么靠 onScrollFrameBegin 命令式分配—— 不能两者混用 。
不要依赖"滚到头了自然会停"。用明确的判断表达边界意图:
this.outerScroller.isAtEnd() // 外层是否到底 this.webController.getScrollOffset().y + this.webViewportHeight >= this.webContentHeight // Web 是否到底
Web 的边界判断需要 window.innerHeight 与 getPageHeight() 配合——单看 getPageHeight() 无法区分"内容刚好铺满"和"内容溢出一点"。
回顾整篇文章,会发现 FIT_CONTENT 那条路线的价值不在于它"配置简单",而在于它 改变了对问题的定义 :
| 视角 | 问题定义 | 解法 | | --- | --- | --- | | 常规思路 | 两个滚动容器如何协调? | 设计协调协议(→ 复杂度高,边界情况多) | | 降维思路 | 能否让它只剩一个滚动容器? | 消除嵌套(→ 问题不存在) |
当一个问题的所有解法都显得复杂且边界情况层出不穷时,值得回过头问一句:这个问题的前提是否可以被消除。
这个问题上,官方文档其实已经给了暗示——它把 FIT_CONTENT 列在"Web 组件大小自适应页面内容布局"这一章,而不是"嵌套滚动"那一章。 在文档结构里,它属于另一个问题域。
原生侧配置再正确,H5 不做配合也会失效。特别是走 FIT_CONTENT 路线时:
| 项 | 要求 | 原因 | | --- | --- | --- | | 图片懒加载 | 不建议用 IntersectionObserver | 滚动中高度持续变化,会让外层滚动位置跳动 | | 内部滚动区 | 避免 overflow: scroll 嵌套 | FIT_CONTENT 下会失效 | | 横向溢出 | viewport 必须正确配置 | 横向溢出会破坏高度计算 | | 内容变化 | 需触发重排 | FIT_CONTENT 依赖"内容高度确定" |
第一条最容易出问题。 详情的富文本通常图片很多,前端出于性能考虑加懒加载是本能的——但在 FIT_CONTENT 模式下,这个"优化"会直接破坏滚动体验。 这类跨层耦合,是混合开发里最容易背锅的地方。
| 条件 | 方案 | 关键配置 | | --- | --- | --- | | 静态富文本 / 长文章 | FIT_CONTENT | SYNC_RENDER + FIT_CONTENT + overScrollMode(NEVER) + zoomAccess(false) | | API ≥ 26 + 简单联动 | nestedScroll | scrollForward: PARENT_FIRST / scrollBackward: SELF_FIRST | | 瀑布流 / 低版本 | 偏移量派发 | setScrollable(false, ScrollType.EVENT) + onScrollFrameBegin |
内层滚动组件是否有 明确有限的高度 ?(高度随内容展开就不是独立滚动容器)
是否同时加了竞争性的 PanGesture ?(绕过嵌套滚动协调)
是否手动 consume 了触摸事件?(同上)
回弹是否只留了一层?
方向是否配反了?( scrollForward = 内容末尾 = 通常为手指上滑)
| 写法 | 问题 |
| --- | --- |
| setScrollable(false, ScrollType.ALL) | 连 API 滚动也禁了,派发无从执行 |
| 派发时 return { offsetRemain: offset } | 外层同步滚动,出现双层位移 |
| 派发时中断回调流程 | 抛滑到该区域时突然刹住 |
| FIT_CONTENT 与固定高度同时用 | 高度计算与派发逻辑冲突 |
| 内层 List 保留 edgeEffect | 回弹叠加 || 能力 | 版本要求 | | --- | --- | | RenderMode.SYNC_RENDER | 全量展开场景必需 | | 父组件优先时 Web 惯性滚动不中断 | API 26.0.0 起修复 |
| ScrollType.EVENT | 用于保留 scrollBy 能力 |
回到最初那个判断: 这些边界问题不是配置疏漏,而是两个滚动世界缺少通信协议。
一旦把问题定位到"控制权归属",三种解法就变得层次分明:
FIT_CONTENT —— 让冲突的一方退场, 问题不存在
nestedScroll —— 建立声明式协议, 在分配阶段解决
偏移量派发 —— 接管输入通道, 在消费阶段解决
三者不是"简单/中等/复杂"的递进,而是 在三个不同层次上回答同一个问题 。
而真正值得带走的方法论或许是这一条: 当所有方案都复杂时,先问问题的前提能不能消除。 在 Web 混排滚动里,官方其实已经把这个答案放在文档的另一章了。
Web 组件嵌套滚动(nestedScroll 属性、偏移量派发、版本缺陷说明) developer.huawei.com/consumer/cn…
Web 组件大小自适应页面内容布局(FIT_CONTENT 规格与约束) developer.huawei.com/consumer/cn…
Web 页面显示内容滚动(scrollTo / scrollBy / pageUp / pageDown) developer.huawei.com/consumer/cn…
如何解决 Web 页上下滑动时会误触发 tab 页翻页手势(setScrollable 与 nestedScroll 配合) developer.huawei.com/consumer/cn…
HarmonyOS 鸿蒙 Next Web 滑动惯性问题(正在惯性滚动时 setScrollable 无效) bbs.itying.com/topic/69c24…
如何监听网页滚动到底部事件(边界判断方式) developer.huawei.com/consumer/cn…
nestedScroll 四种模式与「顶部 Banner + 列表」实战 developer.huawei.com/consumer/cn…
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Juejin HarmonyOS
原文作者:MardaWang;如有侵权,请联系管理员删除。