前言
将 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)。
但是这种做法也有不少的问题:
- 内存占用过高
WebView
的底部弹窗或者上拉加载效果将失效- Flutter 中 安卓
WebView
高度设置过大会崩溃。(现在你可以使用 SurfaceAndroidWebView 来替代 AndroidWebView 解决)
Flutter
都这么多年了,应该有比较完美的解决方案了吧? 于是我再多搜了一下。
需要魔改 WebView
,而且没有 demo
(白嫖) 的代码,像我这种原生小白,直接就 pass
了。
作者很 nice
,留言让帮忙出个 demo
,很快就添加了。
运行了下,效果如下图:
不过感觉逻辑蛮奇怪的,并不是传统意义的 Sliver 组件的效果,组件地址 nested_inner_scroll | Flutter Package (flutter-io.cn)
最终因为时间原因我还是选择了直接撑满 WebView
的做法,直到五一放假,抽空研究下这种场景的解决方案。
原理
实际上,我认为这种场景就是 RenderSliverToBoxAdapter
加上 RenderSliverFixedExtentBoxAdaptor
的一种特殊情况。
- 一方面,
WebView
是作为单个的child
,所以应该以RenderSliverToBoxAdapter
为原型。 - 另一方面,
WebView
是一个可以滚动的并且已知滚动高度的child
,RenderSliverFixedExtentBoxAdaptor
的代码可以白嫖下。 - 如果
WebView
的scrollHeight
小于等于viewport
的高度,那么你可以认为它是高度为scrollHeight
的WebView
。 - 如果
WebView
的scrollHeight
大于viewport
的高度,那么你可以认为WebView
为高度为viewport
,滚动高度为scrollHeight
的滚动组件。 - 轮到
WebView
滚动的时候,不是让它的绘制位置发生改变,而且是通过WebViewController.scrollTo
,让WebView
的内容发生滚动。
有了这五点认知,那么动起手来就比较简单了。
SliverToNestedScrollBoxAdapter
代码抄抄 SliverToBoxAdapter
的代码,一个孩子的 Widget
。
childExtent
为WebView
的scrollHeight
。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
RenderSliverFixedExtentList
和 RenderSliverFixedExtentBoxAdaptor
的代码拿过来用用。
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
中发生的事情。
- 进入
SliverFixedExtentList
范围内,performLayout
各个值的情况。 - 离开
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);
}
理解:
targetEndScrollOffsetForPaint
为WebView
的已展示总高度
final double targetEndScrollOffsetForPaint =
constraints.scrollOffset + constraints.remainingPaintExtent;
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));
- 只实现了
AxisDirection.down
, 主要这组件只是为了 Webview,Pdfview 这类组件设计的,其他情况也不支持。
paint
没有直接在 performLayout
进行通知,是因为不能在 layout
的过程中再去触发child
的 layout
。(当然你也可以使用 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));
},
);
}
最终效果
实现方式 | WebView 加载完毕 | WebView 滚动到最后 |
---|---|---|
可以看到使用 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小糖果
最最后放上 Flutter Candies 全家桶,真香。