Flutter定制一个ScrollView嵌套webview滚动的效果

5,321 阅读7分钟

ScrollView中嵌套webview的滚动

场景描述

业务需要在一个滚动布局中嵌入一个webview,但是在Android平台上有一个关于webview高度的bug: 当webview高度过大时会导致Crash甚至手机重启。所以我想到了这样一种布局:最外层是一个ScrollView,内部含有一个定高的可以滚动的webview。这里有两个问题:

  1. webview怎么滚动
  2. webview的滚动怎么和外部的ScrollView联动

解决方案

第一个问题可以通过设置gestureRecognizers解决: gestureRecognizers: [Factory(() => EagerGestureRecognizer())].toSet(),

但是这种方法会导致webview在手势竞争中获胜,外部的ScrollView根本无法获得滚动事件,从而导致webview滚动完全独立于外部ScrollView的滚动,这也是这种布局很少出现的原因。

于是我想到了使用NestedScrollView的方案,但是很明显我需要重新定义,因为我最终想要的效果是这样子的:

3e57f4ee6da74be9970a40f1150ff101_tplv-k3u1fbpfcp-watermark.gif

OutScrollView 滑动或者Fling时InnerScrollView完全静止。

在滚动InnerScrollView时OutScrollView完全不会滑动,只有在InnerScrollView滑动到边界时才能滑动OutScrollView。如果InnerScrollView Fling, OutScrollView不会Fling,同样的在InnerScrollView边界Fling则会触发OutScrollView的Fling。

下面就是具体方案: NestedScrollView介入滚动是靠自定义ScrollActivityDelegate开始的,scrollable.dart源码中展示了滚动手势的传递过程:

Scrollable->GestureRecorgnizer->Drag(ScrollDragController)->ScrollActivityDelegate

当用户手指拖动ScrollView时会调用:

ScrollDragController:
@override
void update(DragUpdateDetails details) {
    //other codes
    delegate.applyUserOffset(offset);
}

当拖动结束时调用:

@override
void end(DragEndDetails details) {
    ///other codes, goBallistic代表Fling
    delegate.goBallistic(velocity);
}

所以自定义ScrollActivityDelegate就是Hook滚动的开始,在NestedScrollView中这个类是_NestedScrollCoordinator, 所以我的思路就是自己定义一个Delegate。下面是魔改的过程:

需要判断InnerScrollView是否在滚动

我强制InnerScrollView必须被我的自定义Widget包裹:

class _NestedInnerScrollChildState extends State<NestedInnerScrollChild> {
  @override
  Widget build(BuildContext context) {
    return Listener(
      child: NotificationListener<ScrollEndNotification>(
        child: widget.child,
        onNotification: (end) {
          widget.coordinator._innerTouchingKey = null;
          //继续向上冒泡
          return false;
        },
      ),
      onPointerDown: _startScrollInner,
    );
  }

  void _startScrollInner(_) {
    widget.coordinator._innerTouchingKey = widget.scrollKey;
  }
}

我使用了Listener onPointerDown 方法来判断用户触摸了inner view, 但是并没有使用onPointerUp或者onPointerCancel来判断滚动结束,原因就是Fling的存在,Fling效果下手指已经离开屏幕但是view可能还在滑动,因此使用ScrollEndNotification这个标记更靠谱。

OutScrollView滑动时完全禁止InnerScrollView的滑动

  1. applyUserOffset的hook
  @override
  void applyUserOffset(double delta) {
    if (!innerScroll) {
      _outerPosition.applyFullDragUpdate(delta);
    }
  }

  1. Fling 首先会调用Coordinator的goBallistic方法,然后触发beginActivity方法,我们直接在beginActivity中拦截即可:
///_innerPositions并不是所有innerView的集合,这个后面会讲到
if (innerScroll) {
  for (final _NestedScrollPosition position in _innerPositions) {
    final ScrollActivity newInnerActivity = innerActivityGetter(position);
    position.beginActivity(newInnerActivity);
    scrolling = newInnerActivity.isScrolling;
  }
}

InnerScrollView和OutScrollView嵌套滑动

  1. applyUserOffset 借鉴NestedScrollView即可
@override
  void applyUserOffset(double delta) {
    double remainDelta = innerPositionList.first.applyClampedDragUpdate(delta);
      if (remainDelta != 0.0) {
        _outerPosition.applyFullDragUpdate(remainDelta);
      }
  }

  1. Fling innerView触发Fling手势的调用链:ScrollDragController会调用ScrollActivityDelegate的goBallistic方法->触发ScrollPosition的beginActivity方法并创建BallisticScrollActivity实例->BallisticScrollActivity实例结合Simulation不断计算滚动距离。

BallisticScrollActivity有个方法:

 /// Move the position to the given location.
  ///
  /// If the new position was fully applied, returns true. If there was any
  /// overflow, returns false.
  ///
  /// The default implementation calls [ScrollActivityDelegate.setPixels]
  /// and returns true if the overflow was zero.
  @protected
  bool applyMoveTo(double value) {
    return delegate.setPixels(value) == 0.0;
  }

当这个方法返回false时就会立刻停止滚动,正好NestedScrollView有创建自定义OutBallisticScrollActivity方法,所以我在applyMove那里判断如果是innerView 正在滚动就返回false

  @override
  bool applyMoveTo(double value) {
    if (coordinator.innerScroll) {
         return false;
    }
//    other codes
}

当然,这里也可以加个优化:比如innerView如果在边界触发了Fling就可以放开。

支持多个inner scroll view

outview只能有一个,但是innerView理论上可以有多个,我这里贴下参考的文章链接[:]("Flutter 扩展NestedScrollView (二)列表滚动同步解决 - 掘金 (juejin.cn)")。核心就是在ScrollController attach detach时实现position和ScrollView的绑定。

实现webview的滚动

这里我也是借鉴的大神的思路[:](大道至简:Flutter嵌套滑动冲突解决之路 - V大师在一号线 (vimerzhao.top))

Flutter中所有的滚动View最终都是用Scrollable+Viewport来实现的,Scrollable负责获取滚动手势,距离计算等,而绘制则交给Viewport来实现。翻看viewport.dart相关源码,我贴下paint的方法:

@override
  void paint(PaintingContext context, Offset offset) {
    if (firstChild == null)
      return;
    if (hasVisualOverflow && clipBehavior != Clip.none) {
      _clipRectLayer.layer = context.pushClipRect(
        needsCompositing,
        offset,
        Offset.zero & size,
        _paintContents,
        clipBehavior: clipBehavior,
        oldLayer: _clipRectLayer.layer,
      );
    } else {
      _clipRectLayer.layer = null;
      _paintContents(context, offset);
    }
  }

void _paintContents(PaintingContext context, Offset offset) {
    for (final RenderSliver child in childrenInPaintOrder) {
      if (child.geometry!.visible)
        context.paintChild(child, offset + paintOffsetOf(child));
    }
  }

paintOffsetOf(child)就可以简化为滚动导致的绘制偏差。举个栗子:一个viewport高500,内容高度1000,默认绘制[0-500]的内容,当用户向上滑动了100,则绘制[100,600]的内容,这里的100就是paintOffset。

所以我最后创建了一个自定义Viewport,但是Flutter端绘制时paintOffset始终传0,我把真正的offset传递给webview,然后调用window.scrollTo(0,offset)即可实现webview内容的滑动了。简而言之,传统的ScrollView是内容不动,画布在动,而我的方案就是画布不动,但是内容在动。参考代码:[]("inner_scroll_webview.dart (github.com)")

自定义Viewport解决了滚动的问题,但是引入了另一个问题:webview无法正确响应屏幕触摸事件,看下hitTestBehavior代码:

@override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    if (child != null) {
      return result.addWithPaintOffset(
        offset: _paintOffset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset? transformed) {
          assert(transformed == position + -_paintOffset);
          return child!.hitTest(result, position: transformed!);
        },
      );
    }
    return false;
  }

很明显child收到的position加上了paintOffset,这就导致了child可能不会被加到BoxHitTestResult里,作为child子孙节点的webview也就没法响应点击事件,举个栗子:假设viewport高度500,paintOffset -500,position为(0,300),那么webview的父RenderObject就会认为点击事件发生在(0,800), 从而丢弃该事件。

那为什么平时使用SingleChildScrollView没有这个问题呢?看下面的例子:

SingleChildScrollView(
  child: Column(
    children: [
      Container(
        height: 400,
        color: Colors.black,
      ),
      Container(
        height: 300,
        color: Colors.green,
      ),
      GestureDetector(
        child: Container(
          width: 100,
          height: 100,
          child: TextButton(
            child: Text("click me"),
            onPressed: () {
              print("haha");
            },
          ),
        ),
      ),
      Container(
        height: 200,
        color: Colors.red,
      ),
    ],
  ),
);

假设我向上滚动了600的距离(paintOffset),同时点击了按钮, 点击的屏幕位置为(100,150),看看源码怎么处理的:

  1. 首先是SingleChildScrollView 触发hitTest:
@override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    if (child != null) {
      return result.addWithPaintOffset(
        offset: _paintOffset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset? transformed) {
          assert(transformed == position + -_paintOffset);
          return child!.hitTest(result, position: transformed!);
        },
      );
    }
    return false;
  }

传给child的position是(100,750)

  1. 然后是Column对应的RenderFlex:
@override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    return defaultHitTestChildren(result, position: position);
  }

bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
    // The x, y parameters have the top left of the node's box as the origin.
    ChildType? child = lastChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData! as ParentDataType;
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset? transformed) {
          assert(transformed == position - childParentData.offset);
          return child!.hitTest(result, position: transformed!);
        },
      );
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }

它会倒序遍历每一个child,如果发现其中有一个能处理该事件就终止遍历

当遍历到倒数第二个child的时候

position: (100,750)
childParentData.offset: (0,700)
transformed: (100,50), 正好落在该box里,遍历结束

最后看下如何处理才能让webview正确处理屏幕事件:

  1. hook hitTest事件

    hitTest()⇒ hitTestChildren || hitTestSelf, 所以可以略过hitTestChildren直接处理hitTest

    @override
      bool hitTest(BoxHitTestResult result, {Offset position}) {
        if (size.contains(position)) {
          _addPositionToWebView(child, result, position - _paintOffset);
          return true;
        }
        return false;
      }
    
      void _addPositionToWebView(
          RenderObject obj, BoxHitTestResult result, Offset position) {
        if (obj is RenderAndroidView || obj is RenderUiKitView) {
          result.add(BoxHitTestEntry(obj, position));
          return;
        }
        obj.visitChildren((child) {
          _addPositionToWebView(child, result, position);
        });
      }
    

我这里判断是否touch到了webview所在区域,如果是直接遍历到webview对应的RenderObject后把它加到result里

  1. hook applyPaintTransform

做完上述操作后我发现webview仍然无法处理屏幕事件,因为platformView默认会把屏幕坐标再次做处理,看看调用栈:

截屏2021-12-21_上午10.41.40.png

最终会调用到RenderBox的applyPaintTransform方法:

@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
  final Offset paintOffset = _paintOffset;
  transform.translate(paintOffset.dx, paintOffset.dy);
}

由于我们已经让webview内部滚动了,所以传递给webview的坐标就不需要再加上paintOffset了

@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
  transform.translate(0.0,0.0);
}

我把上述代码整理成了一个pub库[](nested_inner_scroll | Flutter Package (pub.dev))