注册

Flutter定制一个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.awebp?

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)")


作者:芭比Q达人
链接:https://juejin.cn/post/7041064106094231560
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册