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