大道至简:Flutter嵌套滑动冲突解决之路

本文约 9200 字,阅读需 19 分钟。

背景与挑战

为了充分验证Flutter在UI开发上的完备性(能Cover住足够复杂的场景),我们选择了微视端内最复杂的页面之一的个人页进行改造,其UI形态如下。

用户1:

weishi personal page 1

用户2:

weishi personal page 2

观察可以发现,对于UI层面而言,个人页的复杂性主要体现在以下几点:

  • 需要支持刷新(指示器、二楼)、加载的UI效果

  • 总体而言是一个【列表-PageView-列表/WebView】的嵌套层次,必然存在一些手势冲突

在初期调研之后,我们发现一个Flutter的刷新加载框架 flutter_pulltorefresh 可以满足刷新、加载、二楼等UI效果,于是我们引入了这个库。

Kapture%202021 06 01%20 pullrefresher

但是,该第三方库存在一个严重的问题,就是不支持NestedScrollView,具体原因可见这个ISSUE:

此时,摆在我们面前的有两个选择:

  1. 让NestedScrollView支持刷新、加载、二楼等UI效果

  2. 让flutter_pulltorefresh支持列表的嵌套,或者说开发一个支持flutter_pulltorefresh的嵌套列表

在斟酌之后,我们选择了第二种思路,主要原因如下:

  1. flutter_pulltorefresh实现刷新、加载、二楼的思路比较合理,对于NestedScrollView的不支持看起来是很难突破的。因此,即使选择了思路1,我们也很难超越这个第三方库。

  2. 即使flutter_pulltorefresh支持了NestedScrollView,如果内层可滚动组件是WebView这种原生View,仍然避免不了要解决滑动冲突,所以这个问题是无法避免的。

所以,我们就此开始了Flutter滑动冲突解决的探索之路。在这段探索之路上,有失误、有争论,也有收获,所以行文一篇,以作复盘、记录。

困境与争论

在我之前,这个问题由另一种思路主导,其采取的方法是一种非常合乎客户端传统开发思维的解法:拦截事件,根据滑动方向和内外列表的剩余滑动距离进行事件分配。

但是,这个思路的效果并不理想,主要体现为以下几点:

  1. 经常会出现一些奇怪的Bug需要解决,因为分发手势的逻辑需要实时获取内外列表的滚动距离。

  2. 由于flutter_pulltorefresh会重组列表,而该方案也要定制列表内部的手势识别器,所以两者有冲突。导致的结果就是该方案需要侵入flutter_pulltorefresh这个纯粹的第三方组件。

  3. 效果不理想,投入一段时间后只能支持普通的滑动,像快滑之后的Fling特效都没法做到。

我个人对此思路也存在一些担忧:

  1. Flutter的事件分发机制和Android并不完全一样,没有 dispatchTouchEventonTouchEvent 等回调,Flutter会首先通过“手势竞技场”决胜出一个响应后续手势的Widget(准确来说是GestureArenaMember,可以粗略理解为Widget),然后由该实例响应后续手势。如果再一次滑动过程中肆意分配响应手势的Widget,其实是破坏了系统的规则,风险很大。

  2. 侵入了第三方库flutter_pulltorefresh,这其实是大忌,以后很难维护了。

  3. 从分发手势解决,有其无法避免的局限性。因为列表关心的其实不是手势,而是手势所产生的列表的偏移。比如Fling效果,是基于内外列表的总长度计算滚动效果的,所以仅仅处理手势是解决不了的,实际也是如此。再比如,只处理手势,那么嵌套列表的 jumpToanimateTo 等方法还是不能正确执行,因为它们是直接告诉列表偏移值,而这个偏移值并不是通过手势产生。

基于这些分歧,也感谢团队内部良好的技术氛围,我申请到了一周的时间来探索新的思路和解法。

问题梳理

在开始解决问题之前,我认为有必要梳理清楚两件事情: 一是个人页的UI到底有多次种嵌套导致滑动冲突,二是Flutter列表滑动的中枢到底是什么

首先分析第一个问题,其实个人页一共存在4个嵌套滑动冲突,只是之前由于在Flutter列表嵌套Flutter列表的问题上消耗太多,一直没有系统地提出来。

既然要破旧立新,就要磨刀不误砍柴工,个人页的UI模型整理如下:

personal page ui model

具体来说,对于个人页的UI而言,其实有4个冲突需要解决,分别是:

  1. Flutter列表嵌套Flutter列表,垂直方向上的滑动冲突

  2. Flutter列表嵌套WebView,垂直方向上的滑动冲突

  3. WebView是嵌套在Flutter的PageView(水平方向可滑动)之中的,而WebView自身也有可水平滑动的元素(虽然其本身是垂直滑动的),因而也存在WebView和Flutter的PageView的水平方向上的滑动冲突

  4. Flutter页面所在的Fragment(以Android为例)是可以横滑切换的,它通过Google官方的ViewPager2实现,而Flutter页面内部也有可横滑的Widget: PageView,他们也存在水平方向上的滑动冲突

至此,我们理清了挡在前面的4座大山。

下面分析第二个问题:Flutter列表滑动的中枢是什么?下图是我之前研读Flutter源码时总结的关键类图:

flutter scrollable model

由图可知,ScrollPosition貌似是Flutter列表滑动的核心所在,实际也正是如此,如下图:

2021 05 27 20 scroll position

滑动手势产出的副作用将通过 setPixels 方法体现,而 jumpTo 等逻辑也将调用到ScrollPosition的具体实现,也就是说搞定了这个类,问题就搞定得差不多了。

至此,思路初步确定:不要以表层的手势处理为切入点,而是扼住列表滑动的咽喉:ScrollPosition

下面开始正式解决这4个问题。

问题一:普通列表嵌套的竖滑冲突

在之前的解法中,我们第一直觉上,似乎flutter_pulltorefresh和NestedScrollView是水火不容的(如果相容的话,我们就不需要解决这个问题)。 但是,如果深入分析flutter_pulltorefresh的逻辑,可以发现,它之所以不兼容后者,其实是因为后者将创建Sliver列表的逻辑隐藏的太深了,以致flutter_pulltorefresh无法获取到该列表,用以自定义刷新和加载效果(所谓的刷新、加载,其实就是在原有的Sliver列表前后各插入一个Sliver)。

由此看来,NestedScrollView只是构建时的层级过深,其解决滑动冲突的思路其实是大大值得借鉴的。分析其源码之后,可以发现官方的解决思路正是从ScrollPosition入手,关键代码如下所示:

2021 05 28 09 58 33 coordinator

NestedScrollView 实现了一个特殊的 ScrollPosition : _NestedScrollPosition ,它会将自身的逻辑委托给 _NestedScrollCoordinator 这个类,如其名字所昭示的,它将协调列表内外的滚动距离等信息。

由于这个逻辑是私有的,所以我们无法直接使用,为了验证其确实是列表嵌套滑动的核心所在,我们可以拷贝出相关代码(大概1000行,都在packages/flutter/lib/src/widgets/nested_scroll_view.dart 文件中),然后将其负责外部滑动的 ScrollController 赋值给外部列表,负责内部滑动的 ScrollController 赋值给内部列表,大致如下所示:

  @override
  Widget build(BuildContext context) {
    ......
    return CustomScrollView(
      controller: _coordinator.outerController, // 负责控制外部列表的滚动
      slivers: [
        SliverToBoxAdapter(......),
        SliverAppBar(......),
        SliverToBoxAdapter(
          child: Container(
            height: innerListHeight,
            child: PageView.builder(
              onPageChanged: _onPageChange,
              controller: pageController,
              itemBuilder: (BuildContext context, int index) {
                return _buildList();
              },
              itemCount: 4,
            ),
          ),
        ),
      ],
    );
  }
  Widget _buildList() {
    return GridView.builder(
      controller: _coordinator.innerController, // 负责控制内部列表的滚动
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
      itemBuilder: (context, index) {
        return Container(
          alignment: Alignment.center,
          child: Text("项目$index"),
        );
      },
    );
  }

如此,便使得普通的 CustomScrollView 和 NestedScrollView 一样,有了嵌套滑动的能力,后面我们会提一个 Isuue,推动官方将这个类的权限变为公开,如此,便可以不费一兵一卒,解决普通列表的滑动冲突问题了。

需要注意的是,这种方法是不需要关注所谓的手势冲突的:内外列表,在谁的区域就由谁负责响应手势,只是响应手势的逻辑统一委托给了协调器。由于协调器持有了两个内外列表的最大可滚动距离,当前已滚动距离等信息,Fling效果等问题也迎刃而解了。

实际上,根据Flutter的官方文档,从 ScrollPosition 切入解决也是符合其设计的,

To further customize scrolling behavior with a Scrollable:

  1. You can provide a viewportBuilder to customize the child model. For example, SingleChildScrollView uses a viewport that displays a single box child whereas CustomScrollView uses a Viewport or a ShrinkWrappingViewport, both of which display a list of slivers.

  2. You can provide a custom ScrollController that creates a custom ScrollPosition subclass. For example, PageView uses a PageController, which creates a page-oriented scroll position subclass that keeps the same page visible when the Scrollable resizes.

最终效果如下:

ezgif flutter nest list

可以看到,即使我们不用 NestedScrollView ,只是将内外 CustomScrollView 的 ScrollController 分别设置了一下(虽然仍是借用了 NestedScrollView 的协调器),即解决了列表嵌套的滑动冲突问题。

问题二:列表嵌套WebView的竖滑冲突

下面分析第2个问题,首先说明一下:Flutter列表嵌套Flutter列表的情况,之所以可以用一个协调器解决,主要原因就是内外可滚动组件(CustomScrollView等)的滚动模型是一致的,所以可以引入一个协调器解决。

而对于Flutter列表嵌套WebView的情况来说,后者的滑动模型对于我们完全是一个黑盒,那么自然无法直接引入协调器了。

那么我们就先尝试着从手势冲突的角度解决,对于WebView这种原生组件而言,Flutter会提供一个 gestureRecognizers 参数,用于Flutter定义哪些手势可以传入WebView。

class WebViewGestureRecognizer extends OneSequenceGestureRecognizer {
  bool scrollEnabled = false;

  Offset _dragDistance = Offset.zero;
  Offset _webViewDragDistance = Offset.zero;

  WebViewGestureRecognizer();

  @override
  void addPointer(PointerEvent event) {
    startTrackingPointer(event.pointer);
  }
  @override
  String get debugDescription => 'custom';

  @override
  void didStopTrackingLastPointer(int pointer) {}

  @override
  void handleEvent(PointerEvent event) {
    _dragDistance = _dragDistance + event.delta;
    if (event is PointerMoveEvent) {
      final double dy = _dragDistance.dy.abs();
      final double dx = _dragDistance.dx.abs();
      if (_webViewDragDistance.dy > 0) {
        WidgetsBinding.instance.cancelPointer(event.pointer);
        scrollEnabled = false;
        _webViewDragDistance = Offset.zero;
      } else { // isPinned标记外部Flutter列表是否滑动完,通过监听滑动距离更新
        scrollEnabled = isPinned;
      }
      if (scrollEnabled) { // Vertical drag: accept
        _webViewDragDistance = _webViewDragDistance+ event.delta; // 累计滚动距离
        resolve(GestureDisposition.accepted); // WebView internal will win
        _dragDistance = Offset.zero;
      } else if (dx > kTouchSlop && dx > dy) { // Horizontal drag: stop tracking
        stopTrackingPointer(event.pointer); // Native external will win
        _dragDistance = Offset.zero;
      }
    }
  }
}

以上逻辑的大致思路如下:判断外部列表是否滑动完,滑动完则允许WebView响应滑动;通过WebView是否滑动到顶部(注:以上代码并未体现这个思路,因为代码被我调试过程中误删了)、或者通过WebView的累计滑动距离判断WebView是否滑动回顶部,滑动到顶部则不再响应滑动。

这个方案的效果并不太好,如下:

ezgif flutter webview in list 1

造成这个方案的效果不好的原因主要如下:

  1. 单次滑动的响应者只能有一个,比如Flutter列表滑动完之后,如果不抬起手指而继续滑动,是不会触发WebView的滑动的。

  2. 边界问题不好处理,比如当前WebView已经滑倒顶部了,那么下一次滑动如下是下滑则应该给外部列表响应、如果是上滑应该给WebView响应。这种细枝末节很多,实现起来非常繁复。

实际上,原来的Native页面也存在这些问题,所以他们干脆把WebView按照实际高度展开了,但这样也有不少问题:

  1. 内存占用过高

  2. WebView如果需要在底部弹窗是没法做的,因为展开的原因,WebView的底部实际在屏幕之外

  3. 和问题2类似,WebView因为展开,没法触发Web自身的上滑加载,因为WebView是展开的,即使滑倒底部,也只是WebView这个组件滑到了底部,Web页面本身并不知道滑到底部了,所以Web没法做滑动加载,结果如下:

android nest webview demo

即只能用一个加载按钮让用户去点击。

综上所述,其实从手势分发、或者WebView展开的角度来解决这个问题都有其局限性。回过头来思考,我们能不能把WebView当作Flutter的一个滚动组件来处理呢?

结合解决问题一时引用的文档,自定义滚动行为有两种方法,解决问题一时用的是第2种,那么我们现在可不可以用1种方法(即自定义一个viewportBuilder)来解决呢?文档中特别提到了SingleChildScrollView,仔细想想,我们的WebView是不是就是一个单子节点的可滚动组件呢?

更具体来说,WebView的实际高度(即Content Height)减去Viewport(即WebView作为一个Android组件的大小)的高度就是列表的滚动距离,列表的偏移就是WebView的偏移,我们虽然无法直接控制WebView的绘制偏移,但是我们可以通过 scrollTo 这个方法告诉WebView!!!

SingleChildScrollView 的底层实现其实是 _RenderSingleChildViewport:

class _RenderSingleChildViewport extends RenderBox
    with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport {
  ......
}

理论上,我们只需要实现一个自己的RenderAbstractViewport就可以解决这个问题了。

【预警:接下来的论述有点绕,但是可以通过很少的代码解决问题】

但是,我们真的需要为WebView做一个RenderAbstractViewport吗?SingleChildScrollView其实就是我们想要的组件,只是WebView无法直接嵌入其中罢了(因为直接嵌入WebView没有布局高度约束,而且滑动距离最终体现为绘制偏移,WebView和Flutter的Widget绘制是分开的)。

为了解决这个问题,我们可以加入一个中间层:把WebView的实际高度告诉 SingleChildScrollView,用于校正可滚动距离;把列表的滚动距离告诉WebView,用于产生WebView自身的滑动效果。

关键实现如下:

// Flutter侧逻辑
@override
void paint(PaintingContext context, Offset offset) {
  Offset offsetReset = Offset.zero;
  // 滑动距离同步过去
  scrollSyncChannel.invokeMethod('', {
    'dx': -offset.dx,
    'dy': -offset.dy,
  });
  paintOffset = offset;
  super.paint(context, offsetReset);
}

// Android侧逻辑
scrollSyncChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
    @Override
    public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
        @SuppressWarnings("unchecked")
        Map<String, Object> map = (Map<String, Object>) call.arguments;
        try {
            double dx = (double) map.get(DX);
            double dy = (double) map.get(DY);
            dx *= density;
            dy *= density;
            if (platformView != null) {
                platformView.syncTo(dx, dy); // 将调用WebView的scrollTo
            }
        } catch (NullPointerException ignored) {
        }
    }
});

我们使用一个中间层隔离 SingleChildScrollView 和 WebView,在此将绘制偏移转成WebView应该滚动的距离。由重写了paint方法,读者也可以理解成我们使用了一个和WebView同高度的浮层(但没有绘制任何内容,所以不会有性能问题)来滚动,并将自身的滚动距离实时同步给WebView。

此外,由于WebView可能会加载新的内容,所以我们需要不断地将WebView的高度告诉Flutter,具体逻辑如下:

Android侧在WebView的onDraw方法中检查高度是否改变,并适时同步到Flutter:

// CustomWebView.java
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (platformViewScrollHelper != null) {
        platformViewScrollHelper.onHeightUpdate(getContentHeight());
    }
}
// PlatformViewScrollHelper.java
public void onHeightUpdate(double newSize) {
    if (Double.valueOf(newSize).equals(currentSize)) {
        return;
    }
    currentSize = newSize;
    heightSyncChannel.invokeMethod("", newSize);
}

Flutter侧重写 get size 方法,告诉Viewport自身的高度改变了,并请求重新布局:

  _RenderPlatformViewShell({
    ......
  }) : super(child: child, additionalConstraints: additionalConstraints) {
    ...... // 省略其他逻辑
    heightSyncChannel.setMethodCallHandler((call) {
      if (call.arguments is! double || realHeight == call.arguments) {
        return Future.value(0);
      }
      realHeight = call.arguments; // 更新高度
      markNeedsLayout();
      return Future.value(0);
    });
  }

  Size get size { // 每次布局的时候,Viewport会根据这个信息计算列表大小
    return Size.fromHeight(realHeight);
  }

最终效果如下:

ezgif flutter webview in list 2

可以看到,由于我们用Flutter的列表模型去管理WebView,所以WebView的嵌套滑动可以像普通列表一样,同时Fling等效果也自然而然支持了。而且,这种方法不需要展开WebView,自然也会支持WebView滑到底部的自动Laoding能力,而无需一个“加载更多”的按钮了。

注意:在验证这个想法阶段,我直接盖了一个透明列表在WebView上,监听列表的滑动偏移,调用 scrollBy 做同步,发现效果不是很好,比如快滑之后直接反向滑动,使用偏移值同步就不准。因此,要用滑动的总偏移 + scrollTo 方法来同步浮层和WebView。

至此,第二个问题完美解决。

问题三:PageView嵌套WebView的横滑冲突

在问题二中,由于WebView本身是垂直方向滑动的,所以我们可以用一个垂直方向的列表做同步。但对于横滑,这个思路是行不通的,因为WebView本身在水平方向是不可滑动的,能滑动的是Web自己控制的列表,所以必须把横滑事件传递给WebView,这一点是无法改变的。

基于此不可改变之事实,我们思考的核心就变成了:如何让WebView在自身可以横滑时横滑自身,在自身无法横滑时横滑外部的 Flutter PageView。

和问题二(通过Flutter控制WebView)截然相反,这一次,我们要通过WebView来控制Flutter。首先在WebView侧,我们可以通过通过WebView的一个回调监听横滑是否越界:

@Override
protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
    super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
    if (platformViewScrollHelper != null) {
        platformViewScrollHelper.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
    }
}

一旦发生越界(clampedX为true),我们立即发一个信号给Flutter,Flutter拿到信号后,开始捕获每次滑动(PointerMoveEvent)在水平方向的偏移,如下所示:

  void _handlePlatformViewEvent(PointerEvent event) {
    if (!attached) return;
    if (event is PointerDownEvent) {
      lastX = event.position.dx; // 每次按下的时候都做记录
      isHorizontalOverScrolled = false; // 默认WebView没有滑动越界
    }
    if (isHorizontalOverScrolled) {
      if (event is PointerMoveEvent) {
        double delta = lastX - event.position.dx; // 计算越界距离
        onOverScrolled?.call(delta); // 通知本次有效滑动
        lastX = event.position.dx;
      }
    }
  }

外部可以根据这个 delta 值来调用 ScrollPosition 的 pointerScroll 方法,其定义如下:

/// Changes the scrolling position based on a pointer signal from current
/// value to delta without animation and without checking if new value is in
/// range, taking min/max scroll extent into account.
///
/// Any active animation is canceled. If the user is currently scrolling, that
/// action is canceled.
///
/// This method dispatches the start/update/end sequence of scrolling
/// notifications.
///
/// This method is very similar to [jumpTo], but [pointerScroll] will
/// update the [ScrollDirection].
///
// TODO(YeungKC): Support trackpad scroll, https://github.com/flutter/flutter/issues/23604.
void pointerScroll(double delta);

如此,便基本实现了横滑冲突的解决,但是有几个细节需要注意:

1.WebView明明已经赢得了事件竞争,Flutter是如何知道滑动的距离的? 答:通过PointerRouter的addGlobalRoute方法监听,它是手势竞争逻辑之外的另一个获取手势信息的通道。

2.一定要Flutter侧计算delta值吗?答:是的,WebView越界时其自身也会滚动,所以即使手指不动,WebView获取到的横滑位置也是不准的,会出现以下抖动:

ezgif webview horizontal 1

3.仅仅调用pointerScroll就可以模拟出PageView的滚动吗?答:不是的,如果只调用 pointerScroll ,那么手指横滑不动后,PageView自动归位,而不是悬停住,现象如下:

ezgif webview horizontal 2

完整逻辑如下:

void _onOverScrolled(double delta){
  if (delta == 0.0) { // 处理PlatformView的越界信息
    pageController.position.hold(() {
    }).cancel(); // 没有滑动时能悬停住
  } else {
    pageController.position.pointerScroll(delta);
  }
}

最终效果如下:

ezgif pageview webview horizontal demo

此外,还有一个非常隐蔽且棘手问题需要解决,如下图所示:

ezgif webview in pageview error

这又是为何?这个其实是在解决问题二的时候引入的,当问题2中用于同步WebView的中间层滑动一段距离后,他将不在参与手势竞技,所以上图中即使看起来滑动在WebView的可横滑区域,但WebView是根本没有响应。

默认情况下,我们滑动浮层一段距离后,在浮层上的手势坐标仍会按照原始位置发送给PlatformView,如下图。

pageview nested webview model 2

对于上图左边,由于中间层列表没有实质的滚动,所有可响应点击测试的区域、中间层和WebView是能正确对应上的。但是,当浮层滚动的时候(上图右边),可响应点击测试区域就划走了,所以此时如果直接从位置2处开始滑动,PageView会直接响应,而不是由WebView响应。而当此时如果从位置1开始滑动,由于默认的映射规则,WebView的响应区域将是位置1在中间层的绝对位置,即位置2(可以理解为位置1与中间层顶部的距离 == 位置2与WebView顶部的距离)。

知道了原因,就可以着手解决问题。即:

  • 如何让中间层全部区域加入点击测试?

  • 如何让中间层的坐标转换为正确的WebView坐标?

首先分析一下中间层的父类做点击检测(hitTest)的逻辑,如下:

bool hitTest(BoxHitTestResult result, { required Offset position }) {
  if (_size!.contains(position)) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

症结就出现在 _zise 字段,我们只是重写了 get size ,所以这个字段仍是WebView的布局大小,而不是WebView内部可滚动列表的大小写。与此同时,position 却是列表中的绝对位置,因为当滚动完一屏WebView后,浮层将完全无法接收手势,因为第一个条件都无法判断通过。

于是,重写这个方法,让中间层任意位置都可以参与手势竞争:

bool hitTest(BoxHitTestResult result, { required Offset position }) {
  Offset newPos =  Offset(position.dx + paintOffset.dx, position.dy + paintOffset.dy);
  if (hitTestChildren(result, position: newPos) || hitTestSelf(newPos)) {
    result.add(BoxHitTestEntry(this, newPos));
    return true;
  }
  return false;
}

上图中还有一个问题,就是滑动一段距离后,横滑顶部位置,响应滑动的却是底部的位置。这又是为什么?这就要涉及到Flutter和WebView的坐标系转换了,所有的Flutter事件在分发给PlatformView时都会进行坐标的转换,这也是导致上面横滑抖动的原因,其具体逻辑如下:

  Offset globalToLocal(Offset point, { RenderObject? ancestor }) {
    final Matrix4 transform = getTransformTo(ancestor);
    final double det = transform.invert();
    if (det == 0.0)
      return Offset.zero;
    final Vector3 n = Vector3(0.0, 0.0, 1.0);
    final Vector3 i = transform.perspectiveTransform(Vector3(0.0, 0.0, 0.0));
    final Vector3 d = transform.perspectiveTransform(Vector3(0.0, 0.0, 1.0)) - i;
    final Vector3 s = transform.perspectiveTransform(Vector3(point.dx, point.dy, 0.0));
    final Vector3 p = s - d * (n.dot(s) / n.dot(d));
    return Offset(p.x, p.y);
  }
  Matrix4 getTransformTo(RenderObject? ancestor) {
    ...... // 省略其他逻辑
    for (int index = renderers.length - 1; index > 0; index -= 1) {
      renderers[index].applyPaintTransform(renderers[index - 1], transform);
    }
    return transform;
  }

因此,当滚动一段距离后就会出现问题,如果理解了这个过程,解决也十分简单,即在中间层的applyPaintTransform方法中根据当前滚动距离(在paint方法中获取),映射回正确的坐标就行了,如下:

@override
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
  transform.translate(-paintOffset.dx, - paintOffset.dy);
}

至此,问题三解决。

问题四:ViewPager2嵌套PageView的横滑冲突

下面,解决最后一个问题,现状如下:

ezgif viewpager2 flutter

即ViewPager2内的FlutterFragment无法响应横滑事件,这是因为ViewPager2 默认会拦截横滑事件,那么问题就变成了两个:

  1. 如何禁用/允许ViewPager2的拦截?

  2. 如何判断何时由Flutter响应滑动,何时由ViewPager2响应滑动?

首先针对问题1,可以使用 requestDisallowInterceptTouchEvent 解决。而对于问题2,则要具体问题具体分析,以本文的例子为例,可以分为3部分:

  1. 顶部的区域没有横滑能力,所以直接由ViewPager2响应

  2. 下方的PageView如果自身无法滑动,会发出OverscrollNotification通知,我们可以捕获这个通知,告知ViewPager2开始拦截、自行响应

  3. 如果PageView的第一个或者最后一个Page是WebView,那么WebView滑动到边界需要发出自行发出一个越界通知。

为了统一处理此类情况,一个自定义一个通知如下:

class EnableInterceptNotification extends Notification {
  static MethodChannel _channel =  MethodChannel('flutter_nested_scroll/global');
  static void emitToPlatform() {
    // 不需要任何参数,一般是在Move过程中告知Platform ,Platform会在每次事件开始时重置
    _channel.invokeMethod('', '');
  }
}

对于第1种情况,在页面根部监听横滑事件即可,如果根部能监听到,说明肯定没有被PageView等组件响应,对于第2种情况,在PageView的外部监听 OverscrollNotification通知即可:

class NotificationScaffold extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 滑动一个不会消费横滑事件的区域
      onHorizontalDragUpdate: (DragUpdateDetails details) {
        EnableInterceptNotification().dispatch(context);
      },
      child: NotificationListener(
        onNotification: (notification) {
          // 监听PageView滑动到边界的情况
          if (notification is OverscrollNotification) {
            EnableInterceptNotification().dispatch(context);
          }
          return false;
        },
        child: DemoPage(), // 真正的UI
      ),
    );
  }
}

对于第三种情况,结合越界情况和PageView的索引综合判断即可, _onOverScrolled 的完整逻辑如下:

  void _onOverScrolled(double delta){
    if (delta == 0.0) { // 处理PlatformView的越界信息
      pageController.position.hold(() {
      }).cancel();
    } else {
      pageController.position.pointerScroll(delta);
      if ((delta > 0 && pageController.page! >= 3.0) // 右侧越界滑动
          || (delta < 0 && pageController.page! <= 0.0)) { // 左侧越界滑动
        // 发送出去,由根部统一处理
        EnableInterceptNotification().dispatch(context);
      }
    }
  }

具体UI节点负责发送一个特殊的通知,我们负责在根节点告知Platform此信息:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue,),
      home: SafeArea(
        child: Scaffold(
          body: NotificationListener(
            onNotification: (notification) {
              // 通知宿主
              if (notification is EnableInterceptNotification) {
                EnableInterceptNotification.emitToPlatform();
                return true;
              }
              return false;
            },
            child: NotificationScaffold(),
          ),
        ),
      ),
    );
  }
}

至此,所有的横滑冲突也彻底解决了,效果如下:

ezgif viewpager2 nest flutter demo

复盘总结

  • 突破惯性思维,跳出经验圈套,具体问题具体分析。

  • 熟悉源码、总揽全局,谋定而后动,方能举重若轻。

  • 一个好的切入点可以节省大量代码,同时避免一些本就不该出现的Bug,事半功倍。

  • 对于ListView这种存在内外联动的嵌套,必须从底层滑动机制着手;对于PageView、ViewPager这种每次滑动只需要有一个响应者的嵌套,直接分发到对应的组件即可。

总阅读量次。