Flutter 嵌套滚动的 WebView

5,456 阅读5分钟

前言

WebView 嵌入到滚动组件中是一个常见的需求,最普遍做法就是获取到 WebView 内容的滚动高度 scrollHeight ,然后将 WebView 的高度设置为 scrollHeight,代码如下:

 return SliverToBoxAdapter(
   child: SizedBox(
    height: scrollHeight,
    child: WebView(),
    ),
   );

相关的 issue, [webview_flutter] make height of WebView the height of webpage · Issue #34138 · flutter/flutter (github.com)

但是这种做法也有不少的问题:

Flutter 都这么多年了,应该有比较完美的解决方案了吧? 于是我再多搜了一下。

需要魔改 WebView ,而且没有 demo (白嫖) 的代码,像我这种原生小白,直接就 pass 了。

作者很 nice ,留言让帮忙出个 demo,很快就添加了。

运行了下,效果如下图:

3353C8ECD99214DFBD28F63478283FB7.gif

不过感觉逻辑蛮奇怪的,并不是传统意义的 Sliver 组件的效果,组件地址 nested_inner_scroll | Flutter Package (flutter-io.cn)

最终因为时间原因我还是选择了直接撑满 WebView 的做法,直到五一放假,抽空研究下这种场景的解决方案。

原理

实际上,我认为这种场景就是 RenderSliverToBoxAdapter 加上 RenderSliverFixedExtentBoxAdaptor 的一种特殊情况。

  • 一方面,WebView 是作为单个的 child,所以应该以 RenderSliverToBoxAdapter 为原型。
  • 另一方面,WebView 是一个可以滚动的并且已知滚动高度的 childRenderSliverFixedExtentBoxAdaptor 的代码可以白嫖下。
  • 如果 WebViewscrollHeight 小于等于 viewport 的高度,那么你可以认为它是高度为 scrollHeightWebView
  • 如果 WebViewscrollHeight 大于 viewport 的高度,那么你可以认为 WebView 为高度为 viewport,滚动高度为 scrollHeight 的滚动组件。
  • 轮到 WebView 滚动的时候,不是让它的绘制位置发生改变,而且是通过 WebViewController.scrollTo ,让 WebView 的内容发生滚动。

有了这五点认知,那么动起手来就比较简单了。

SliverToNestedScrollBoxAdapter

代码抄抄 SliverToBoxAdapter 的代码,一个孩子的 Widget

  • childExtentWebViewscrollHeight
  • onScrollOffsetChanged 为通知 ScrollOffset 变化的回调。
class SliverToNestedScrollBoxAdapter extends SingleChildRenderObjectWidget {
  /// Creates a sliver that contains a single nested scrollable box widget.
  const SliverToNestedScrollBoxAdapter({
    Key? key,
    Widget? child,
    required this.childExtent,
    required this.onScrollOffsetChanged,
  }) : super(key: key, child: child);

  final double childExtent;
  final ScrollOffsetChanged onScrollOffsetChanged;

  @override
  RenderSliverToNestedScrollBoxAdapter createRenderObject(
          BuildContext context) =>
      RenderSliverToNestedScrollBoxAdapter(
        childExtent: childExtent,
        onScrollOffsetChanged: onScrollOffsetChanged,
      );

  @override
  void updateRenderObject(BuildContext context,
      covariant RenderSliverToNestedScrollBoxAdapter renderObject) {
    renderObject.childExtent = childExtent;
    renderObject.onScrollOffsetChanged = onScrollOffsetChanged;
  }
}

RenderSliverToNestedScrollBoxAdapter

RenderSliverFixedExtentListRenderSliverFixedExtentBoxAdaptor 的代码拿过来用用。

  • childExtent 变化的时候重新 layout
class RenderSliverToNestedScrollBoxAdapter
    extends RenderSliverSingleBoxAdapter {
  /// Creates a [RenderSliver] that wraps a [RenderBox].
  RenderSliverToNestedScrollBoxAdapter({
    RenderBox? child,
    required double childExtent,
    required this.onScrollOffsetChanged,
  })  : _childExtent = childExtent,
        super(child: child);

  double get childExtent => _childExtent;
  double _childExtent;
  set childExtent(double value) {
    assert(value != null);
    if (_childExtent == value) {
      return;
    }
    _childExtent = value;
    markNeedsLayout();
  }

  ScrollOffsetChanged onScrollOffsetChanged;

  @override
  void performLayout() {
      ...
  }

  @override
  void paint(PaintingContext context, Offset offset) {
      ...
  }

  @override
  @protected
  void setChildParentData(RenderObject child, SliverConstraints constraints,
      SliverGeometry geometry) {
      ...
  }

  @override
  bool hitTestBoxChild(BoxHitTestResult result, RenderBox child,
      {required double mainAxisPosition, required double crossAxisPosition}) {
      ...
}
  • 写一个 demo ,调试下 performLayout 中发生的事情。
  1. 进入 SliverFixedExtentList 范围内,performLayout 各个值的情况。
  2. 离开 SliverFixedExtentList 范围内,performLayout 各个值的情况。
    return CustomScrollView(
      slivers: <Widget>[
        SliverToBoxAdapter(
          child: Container(
            height: 100,
            color: Colors.red,
            child: const Center(
              child: Text(
                'Header',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ),
        SliverFixedExtentList(
          delegate: SliverChildBuilderDelegate(
            (BuildContext b, int index) {
              return Container(
                decoration: BoxDecoration(
                  border: Border.all(
                    color: Colors.grey,
                  ),
                ),
                alignment: Alignment.center,
                child: const Text('Test'),
              );
            },
            childCount: 1,
          ),
          itemExtent: 1000,
        ),
        SliverToBoxAdapter(
          child: Container(
            height: 300,
            color: Colors.green,
            child: const Center(
              child: Text(
                'Footer',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ),
      ],
    );

performLayout

performLayout 中代码如下:

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // viewport 的大小跟 webview 的滚动高度取小的一个
    final double childLayoutExtent =
        min(childExtent, constraints.viewportMainAxisExtent);

    final double scrollOffset =
        constraints.scrollOffset + constraints.cacheOrigin;
    assert(scrollOffset >= 0.0);
    final double remainingExtent = constraints.remainingCacheExtent;
    assert(remainingExtent >= 0.0);
    
    // 避免 child 重复 layout
    if (!child!.hasSize || child!.size.height != childLayoutExtent) {
      final BoxConstraints childConstraints = constraints.asBoxConstraints(
        minExtent: childLayoutExtent,
        maxExtent: childLayoutExtent,
      );

      child!.layout(childConstraints, parentUsesSize: true);
    }
    
    // 由于是 单个 child,所以 leading 和 trailing 不会发生改变。
    const double leadingScrollOffset = 0;
    final double trailingScrollOffset = childExtent;
    
    // 计算出绘制范围
    final double paintExtent = calculatePaintOffset(
      constraints,
      from: leadingScrollOffset,
      to: trailingScrollOffset,
    );

    final double cacheExtent = calculateCacheOffset(
      constraints,
      from: leadingScrollOffset,
      to: trailingScrollOffset,
    );
    // 预估的最大滚动高度,当然就是 webview 的滚动高度
    final double estimatedMaxScrollOffset = childExtent;
    geometry = SliverGeometry(
      scrollExtent: estimatedMaxScrollOffset,
      paintExtent: paintExtent,
      cacheExtent: cacheExtent,
      maxPaintExtent: estimatedMaxScrollOffset,
      // Conservative to avoid flickering away the clip during scroll.
      hasVisualOverflow: constraints.scrollOffset > 0.0,
    );

    setChildParentData(child!, constraints, geometry!);
  }

setChildParentData

setChildParentData 中代码如下:

  @override
  @protected
  void setChildParentData(RenderObject child, SliverConstraints constraints,
      SliverGeometry geometry) {
    final SliverPhysicalParentData childParentData =
        child.parentData! as SliverPhysicalParentData;
    // 已经滚动的距离 + 剩余绘制的距离    
    // webview 的已展示总高度
    final double targetEndScrollOffsetForPaint =
        constraints.scrollOffset + constraints.remainingPaintExtent;
    assert(constraints.axisDirection != null);
    assert(constraints.growthDirection != null);
    switch (applyGrowthDirectionToAxisDirection(
        constraints.axisDirection, constraints.growthDirection)) {
      case AxisDirection.up:
        assert(false, 'not support for RenderSliverToScrollableBoxAdapter');
        // childParentData.paintOffset = Offset(
        //     0.0,
        //     -(geometry.scrollExtent -
        //         (geometry.paintExtent + constraints.scrollOffset)));
        break;
      case AxisDirection.right:
        assert(false, 'not support for RenderSliverToScrollableBoxAdapter');
        //childParentData.paintOffset = Offset(-constraints.scrollOffset, 0.0);
        break;
      case AxisDirection.down:
        //childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
        //  webview 的总滚动高度 - webview 已展示高度
        //  如果大于0,说明 webview 还可以滚动,那么我们只需要把 webview 绘制到 0 的位置
        //  如果小于0,说明 webview 已经滚动到低,那么我们需要改变 webview 的绘制位置
        childParentData.paintOffset = Offset(
            0.0,
            childExtent <= constraints.viewportMainAxisExtent
                ? -constraints.scrollOffset
                : min(childExtent - targetEndScrollOffsetForPaint, 0));
        break;
      case AxisDirection.left:
        assert(false, 'not support for RenderSliverToScrollableBoxAdapter');
        // childParentData.paintOffset = Offset(
        //     -(geometry.scrollExtent -
        //         (geometry.paintExtent + constraints.scrollOffset)),
        //     0.0);
        break;
    }
    assert(childParentData.paintOffset != null);
  }

理解:

  1. targetEndScrollOffsetForPaintWebView 的已展示总高度
final double targetEndScrollOffsetForPaint =
        constraints.scrollOffset + constraints.remainingPaintExtent;
  1. WebView 的总滚动高度 减去 WebView 已展示高度。
  • 如果 WebView 的高度小于可视区域,那么就当普通场景处理
  • 如果大于0,说明 WebView 还可以滚动,那么我们只需要把 webview 绘制到 0 的位置,随后通知 webview 内容自行滚动即可。
  • 如果小于0,说明 WebView 已经滚动到低,那么我们需要改变 webview 的绘制位置

childParentData.paintOffset = Offset( 0.0, childExtent <= constraints.viewportMainAxisExtent ? -constraints.scrollOffset : min(childExtent - targetEndScrollOffsetForPaint, 0));

  1. 只实现了 AxisDirection.down, 主要这组件只是为了 Webview,Pdfview 这类组件设计的,其他情况也不支持。

paint

没有直接在 performLayout 进行通知,是因为不能在 layout 的过程中再去触发childlayout。(当然你也可以使用 SchedulerBinding.instance?.addPostFrameCallback)

条件 constraints.scrollOffset + constraints.remainingPaintExtent <= childExtent 很好理解,大于的话,说明 WebView 已经滚动到最底部了,就无需通知了。

  @override
  void paint(PaintingContext context, Offset offset) {
    if (childExtent > constraints.viewportMainAxisExtent) {
      // maybe overscroll in ios
      onScrollOffsetChanged(math.min(constraints.scrollOffset,
          childExtent - constraints.viewportMainAxisExtent));
    }
    super.paint(context, offset);
  }

hitTestBoxChild

由于我们是根据滚动的距离,将 WebView 的内容进行滚动,而不是将 WebView 布局到对应的位置,所以 hitTest 的时候我们需要给 position.dy 减去 constraints.scrollOffset

  @override
  bool hitTestBoxChild(BoxHitTestResult result, RenderBox child,
      {required double mainAxisPosition, required double crossAxisPosition}) {
    final bool rightWayUp = _getRightWayUp(constraints);
    double delta = childMainAxisPosition(child);
    final double crossAxisDelta = childCrossAxisPosition(child);
    double absolutePosition = mainAxisPosition - delta;
    final double absoluteCrossAxisPosition = crossAxisPosition - crossAxisDelta;
    Offset paintOffset, transformedPosition;
    assert(constraints.axis != null);
    switch (constraints.axis) {
      case Axis.horizontal:
        assert(true, 'not support for RenderSliverToScrollableBoxAdapter');
        if (!rightWayUp) {
          absolutePosition = child.size.width - absolutePosition;
          delta = geometry!.paintExtent - child.size.width - delta;
        }
        paintOffset = Offset(delta, crossAxisDelta);
        transformedPosition =
            Offset(absolutePosition, absoluteCrossAxisPosition);
        break;
      case Axis.vertical:
        if (!rightWayUp) {
          absolutePosition = child.size.height - absolutePosition;
          delta = geometry!.paintExtent - child.size.height - delta;
        }
        paintOffset = Offset(crossAxisDelta, delta);
        transformedPosition =
            Offset(absoluteCrossAxisPosition, absolutePosition);
        break;
    }
    assert(paintOffset != null);
    assert(transformedPosition != null);
    return result.addWithOutOfBandPosition(
      paintOffset: paintOffset,
      hitTest: (BoxHitTestResult result) {
        // 减去 scrollOffset,因为只有当滚动大于 webview 的高度时候,webview 才会被绘制到对应的位置,除此之外都是放置到 0 的位置。
        return child.hitTest(result,
            position: Offset(transformedPosition.dx,
                transformedPosition.dy - constraints.scrollOffset));
      },
    );
  }

最终效果

nested_scroll_webview.gif

实现方式WebView 加载完毕WebView 滚动到最后
截屏2022-05-03 下午9.11.08.png截屏2022-05-03 下午8.47.49.png截屏2022-05-03 下午8.49.03.png
截屏2022-05-03 下午9.08.30.png截屏2022-05-03 下午8.51.35.png截屏2022-05-03 下午8.54.34.png

可以看到使用 SliverToNestedScrollBoxAdapter 可以节约内存,让 Webview 看起来就像一个普通 Sliver 组件一样,跟 Flutter Sliver 一起工作。

结语

Have one and Say one

Flutter Sliver 还是很好用的,不知道还有多少人示它为一生之敌。

最后放上组件的地址

fluttercandies/extended_sliver(github.com)

extended_sliver | Flutter Package (flutter-io.cn)

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081

最最后放上 Flutter Candies 全家桶,真香。

相关阅读