flutter:把 WebView 塞进 `NestedScrollView` 之后:手势去哪儿了?

前段时间产品上线一个需求,希望嵌入一个webview来替代一些UI,以便实现更好的运营投放,但有希望和原生一样的体验。于是遇到了不少坑点....

适用场景:Flutter + NestedScrollView + SliverAppBar + webview_flutter
目标体验:WebView 正常滚动WebView 到顶后下拉展开 SliverAppBar上推优先收起 SliverAppBar,收起后继续滚网页

你以为你在做“一个页面里嵌个 WebView”。
实际上你在做:Flutter 手势竞技场 vs PlatformView(原生视图)+ 两套滚动系统的外交谈判

这篇文章来自一个真实 demo(仓库名:nested_webview_sample)。踩坑、补洞、加日志、推翻方案、再补洞……最后我们得到一个可控、可解释、可调试的实现。


目录


需求与预期交互

页面结构:

  • 外层: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 用 dyjumpTo 外层滚动,展开 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 滚网页
  • 下拉(dy > 0)
    • 只有当 WebView 到顶(webViewAtTop == true)且 Header 可展开(outer.offset > 0)时才接管并展开
    • 否则 reject → 交给 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),问题迎刃而解。


常见坑位清单(建议收藏)

  • 透明层覆盖 WebViewStack + 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,实际上你在写“手势仲裁法”。
而写法条最重要的一条就是:别让两个部门同时拥有执法权

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容