前段时间产品上线一个需求,希望嵌入一个webview来替代一些UI,以便实现更好的运营投放,但有希望和原生一样的体验。于是遇到了不少坑点....
适用场景:Flutter +
NestedScrollView+SliverAppBar+webview_flutter
目标体验:WebView 正常滚动;WebView 到顶后下拉展开SliverAppBar;上推优先收起SliverAppBar,收起后继续滚网页
你以为你在做“一个页面里嵌个 WebView”。
实际上你在做:Flutter 手势竞技场 vs PlatformView(原生视图)+ 两套滚动系统的外交谈判。
这篇文章来自一个真实 demo(仓库名:nested_webview_sample)。踩坑、补洞、加日志、推翻方案、再补洞……最后我们得到一个可控、可解释、可调试的实现。
目录
- 需求与预期交互
- 为什么“只调手势优先级”往往不够
- 方案演进:从 JS 透传距离到“只上报到顶”
- 最终方案:Header 手势协调器(Coordinator)
- 调试复盘:日志如何帮我们抓到真凶
- 常见坑位清单(建议收藏)
- 完整代码指路
需求与预期交互
页面结构:
- 外层:
NestedScrollView - 头部:
SliverAppBar(可展开/可收起) - 内容:
WebViewWidget
预期交互(人类直觉版):
- 网页中间:上下滑 = 滚网页
-
网页到顶:继续下拉 = 展开
SliverAppBar -
上推:优先收起
SliverAppBar,收起完继续滚网页
这听起来很“自然”。但实现起来会遇到两个非常不讲武德的事实。
为什么“只调手势优先级”往往不够
事实 1:WebViewWidget 是 PlatformView
webview_flutter 的 WebView 本质是原生控件(iOS 的 WKWebView / Android 的 WebView)。
它在原生侧滚动,Flutter 通常拿不到:
- WebView 内部是否到顶(scrollTop)
- WebView 内部滚动产生的
ScrollNotification
所以你想做的“到顶才交给 SliverAppBar”在 Flutter 侧缺了一块关键输入:WebView 的到顶状态。
事实 2:外层 NestedScrollView 可能会“吃手势吃到撑”
我们实际遇到并通过日志确认过一个现象:
外层
NestedScrollView即使已经到offset == maxScrollExtent(头部已完全收起),仍然会持续响应“上推”手势(UserScroll reverse),导致 WebView 根本拿不到那条拖拽序列。
这会出现非常诡异的体感:
- 你明明在 WebView 区域上推
- 你也看到我们的手势识别器
reject了 - 但 WebView 仍然不滚(因为外层还在把手势吃掉)
方案演进:从 JS 透传距离到“只上报到顶”
一开始最直接的方案是:H5 顶部下拉时把 dy 透传给 Flutter(JS Bridge),Flutter 用 dy 去 jumpTo 外层滚动,展开 SliverAppBar。
这个方案的确能跑起来,但缺点明显:
- 需要网页配合传距离(对第三方页面不友好)
- H5 侧实现不一致时容易出 bug(比如各种滚动容器/scrollTop 差异)
因此我们最终把约束收紧成:
网页只通知“是否到顶”(
atTop / notAtTop),不透传滑动距离。
距离(dy)由 Flutter 侧从原生触摸事件里自己算。
最终方案:Header 手势协调器(Coordinator)
核心思想(一句话版):
外层滚动别抢手势了(让它下班),Header 的展开/收起统一由一个协调器识别器驱动;WebView 负责网页滚动。
实现分两部分:H5 上报状态 + Flutter 手势协调器。
1)H5:只上报“是否到顶”
文件:assets/index.html
// Bridge Logic - 仅上报“是否到顶”,不透传手势距离(dy 在 Flutter 侧自行计算)
let isAtTop = true;
// 实时监听滚动位置
window.addEventListener('scroll', function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop <= 0 && !isAtTop) {
isAtTop = true;
if (window.ScrollBridge) window.ScrollBridge.postMessage('atTop');
} else if (scrollTop > 0 && isAtTop) {
isAtTop = false;
if (window.ScrollBridge) window.ScrollBridge.postMessage('notAtTop');
}
}, { passive: true });
注意:这里假设页面是 documentElement 滚动。如果你的实际 H5 是“内部容器滚动”(比如 div{overflow:auto}),需要把监听改到那个容器上。
2)Flutter:接收 atTop/notAtTop
文件:lib/main.dart
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'ScrollBridge',
onMessageReceived: (JavaScriptMessage message) {
final raw = message.message;
if (raw == 'atTop') setState(() => _webViewAtTop = true);
if (raw == 'notAtTop') setState(() => _webViewAtTop = false);
},
)
..loadFlutterAsset('assets/index.html');
这一步给 Flutter 一个“到顶状态开关”,后续手势协调器会用它判断是否允许“下拉展开 Header”。
3)关键修正:禁用外层 NestedScrollView 的手势滚动
这是本文最重要的一个坑点修复:外层在 max 时仍吃上推手势。
因此我们直接把外层的手势滚动关掉,让它只接收我们手动 jumpTo 的驱动:
NestedScrollView(
controller: _outerController,
physics: const NeverScrollableScrollPhysics(),
// ...
)
你可以把它理解为:
“Header 的展开/收起,我们自己开车;外层别抢方向盘。”
4)手势协调器:只在需要时接管,其他情况放行给 WebView
我们在 WebView 外层包一层 RawGestureDetector,注册一个自定义的 _HeaderCoordinatorRecognizer:
RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
_HeaderCoordinatorRecognizer:
GestureRecognizerFactoryWithHandlers<_HeaderCoordinatorRecognizer>(
() => _HeaderCoordinatorRecognizer(
outerController: _outerController,
webViewAtTop: () => _webViewAtTop,
onLog: (msg, {force = false}) => _logGesture(msg, force: force),
onOuterLog: (msg, {force = false}) => _logOuter(msg, force: force),
),
(_HeaderCoordinatorRecognizer instance) {},
),
},
child: WebViewWidget(controller: _controller),
)
协调器做的事(逻辑版):
-
上推(dy < 0):
- 如果 Header 还没收起(
outer.offset < max),接管并jumpTo让 Header 收起 - 如果 Header 已经收起(
outer.offset == max),快速 reject → 交给 WebView 滚网页
- 如果 Header 还没收起(
-
下拉(dy > 0):
- 只有当 WebView 到顶(
webViewAtTop == true)且 Header 可展开(outer.offset > 0)时才接管并展开 - 否则 reject → 交给 WebView
- 只有当 WebView 到顶(
(实现代码较长,建议直接看 lib/main.dart 的 _HeaderCoordinatorRecognizer。)
调试复盘:日志如何帮我们抓到真凶
这类问题的特征是:你以为 A 在吃手势,其实 B 在狂炫。
所以我们加了两路日志:
- WebView 状态:
[ScrollBridge] atTop / notAtTop - 外层滚动:
[Outer] UserScroll/update/end offset/max - 手势决策:
[Gesture] down/move/accept/reject/stopTracking ...
当你看到类似日志:
[Outer] UserScroll direction=ScrollDirection.reverse offset=309.0/309.0
[Outer] end offset=309.0/309.0
这代表:外层已经到 max 了还在响应上推。
那就别纠结“怎么让 WebView 赢手势竞技场”了——直接让外层下班(NeverScrollableScrollPhysics),问题迎刃而解。
常见坑位清单(建议收藏)
透明层覆盖 WebView:
Stack + Positioned.fill顶层命中,WebView 可能拿不到触摸(即使你 reject)。
✅ 用“父级包裹”的RawGestureDetector(child: WebViewWidget)更稳。手势识别器不及时 reject:上推要尽快放行,否则 WebView 启动滚动会“等红灯”。
✅ 第一帧定方向,不能处理就尽快 reject。外层到 max 仍吃上推:这是本文最大的坑。
✅ 直接NeverScrollableScrollPhysics,改为手动jumpTo驱动 Header。网页不是 document 滚动:如果是内部容器滚动,
scrollTop的来源要换。
✅ 监听真实滚动容器的scroll。
完整代码指路
- Flutter:
lib/main.dart - H5:
assets/index.html - 依赖:
webview_flutter: ^4.13.0
彩蛋:一句话总结
当你把 WebView 放进 NestedScrollView:
你以为你在写 UI,实际上你在写“手势仲裁法”。
而写法条最重要的一条就是:别让两个部门同时拥有执法权。