Flutter 滑动分析之 Scrollview
Flutter 中的 scrollable widget 根据实现方式的不同,可以分为两大类:
- 基于 RenderBox 的 box protocol 实现的,主要基于 Size 实现布局。常见的有 SingleChildScrollView。
- 基于 RenderSliver 的 sliver protocol 实现的,主要基于 SliverGeometry 实现布局。比如 CustomScrollView 及其子类 ListView、GridView 等继承自 ScrollView 的 Widget,以及基于 CustomScrollView 的 NestedScrollView、基于 Viewport 等的 PageView、TabBarView 等直接对 SliverFillViewport 等进行封装的 Widget。
上述所有的 scrollable widget 其底层逻辑依然是对 Scrollable 的封装,其内部处理了 ScrollController、ScrollPosition(viewport 的 offset)、ViewportBuilder(容纳滚动内容的容器)、ScrollPhysics(管理 scrollable view 的物理属性,比如是否可以滚动或弹性滚动等)、ScrollActivity(对外发出 ScrollNotification)、RawGestureDetector(手势识别)等等一系列与 scroll 有关的逻辑,从而使得其他 scrollable view 能够比较方便的实现 scroll 效果。
上回我们对 SingleChildScrollView 的实现做了简单分析(见《Flutter 滑动分析之 SingleChildScrollView》),本文将对另外一种遵循 sliver protocol 的 ScrollView 做一分析。
官方对 ScrollView 的定义是:“A widget that scrolls”。
其主要由三部分组成:
一个ScrollWidget,监听用户手势,实现 scrolling 的交互设计
一个viewport widget,根据传入的 shrinkwrap 值的 true/false 分别会是 ShrinkWrappingViewport 或者 Viewport。通过根据传入的 ViewportOffset 不同而值展示 slivers 的一部分内容来实现滑动的视觉设计效果。
一个或多个slivers,可以被组合起来创建各种 scrolling effects(比如 list,grids,expanding header 等)的 widget,是真正显示在屏幕上的 widget。
由于默认的 ScrollView 创建的 viewport 的 slivers 属性只接受能创建 RenderSliver 的 Widget,所以 ScrollView 的
List<Widget> buildSlivers(BuildContext context)
方法只能返回 SliverXXX 之类(比如 SliverList)可以创建 RenderSliver 的 Widget。
ScrollView 是一个抽象类,它主要的作用是将上面提到的三部分组合起来,为(遵从 sliver protocol 的)scrollable widget 封装屏蔽掉滑动底层细节,提供像buildSlivers
之类的方法方便子类能够快速实现一个 scrollable widget。
源码分析
abstract class ScrollView extends StatelessWidget{}
ScrollView 继承自 StatelessWidget,他的主要逻辑在 build 方法中:
Widget build(BuildContext context) {
// 这里创建 slivers
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);
// scrollController 要么使用最近的 PrimaryScrollController,要么使用自己的 controller
final ScrollController? scrollController =
primary ? PrimaryScrollController.of(context) : controller;
// 这里是主要创建 Scrollable 的地方
final Scrollable scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
scrollBehavior: scrollBehavior,
semanticChildCount: semanticChildCount,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
// 在这里创建 viewport,使用我们创建好的 slivers 填充 viewport
// 并传入 Scrollable.ScrollPosition 作为这里的入参 offset,在 viewport 中
// 会监听 offset 的变化来重新绘制 slivers 从而实现滑动效果
return buildViewport(context, offset, axisDirection, slivers);
},
);
final Widget scrollableResult = primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
// 这里是处理当 scrollable view 滑动时隐藏键盘的逻辑
if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
return NotificationListener<ScrollUpdateNotification>(
child: scrollableResult,
onNotification: (ScrollUpdateNotification notification) {
final FocusScopeNode focusScope = FocusScope.of(context);
if (notification.dragDetails != null && focusScope.hasFocus) {
focusScope.unfocus();
}
return false;
},
);
} else {
return scrollableResult;
}
}
通过上述代码,我们可以验证之前的判断:ScrollView 本身是对 Scrollable、viewport、slivers 的封装。具体的处理滑动手势、更新 ScrollPosition、发送 ScrollNotification 等等都在 Scrollable 中处理了,ScrollView 的子类只需要按照要求提供 slivers(通过 buildSlivers 方法)和其他一些必须的信息即可。
上面的代码中还分别调用了 buildViewport 和 buildSlivers 方法,接下来我们逐一分析一下他们的源码。
Widget buildViewport()
buildViewport 方法顾名思义,是用来创建 viewport 的。在 ScrollView 中默认会按照 shrinkWrap 的不同创建两种 viewport,他的子类也可以根据需要重写此方法以返回自己的 viewport。
Widget buildViewport(
BuildContext context,
ViewportOffset offset,
AxisDirection axisDirection,
List<Widget> slivers,
) {
assert(() {
switch (axisDirection) {
case AxisDirection.up:
case AxisDirection.down:
// 如果是上述两种 AxisDirection 说明 Axis 是 Axis.vertical 的,那么就
// 需要判断是否此 widget 是否有 Directionality 可以用来判断文本布局方向
// 如果没法判断则抛出 FlutterError,否则返回 true
return debugCheckHasDirectionality(
context,
why: 'to determine the cross-axis direction of the scroll view',
hint: 'Vertical scroll views create Viewport widgets that try to determine their cross axis direction '
'from the ambient Directionality.',
);
// 如果 getDirection() 能得出下面两种 AxisDirection 说明 Axis 是
// Axis.horizontal 的,并且已经得知文本方向,所以直接返回 true
case AxisDirection.left:
case AxisDirection.right:
return true;
}
}());
// 经过上述检查,到这里 widget 的文本方向(TextDirection)一定已经确定了
// 这里根据 shrinkWrap 的不同分别创建两种 viewport
if (shrinkWrap) {
return ShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
clipBehavior: clipBehavior,
);
}
return Viewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
clipBehavior: clipBehavior,
);
}
可以看到,在 buildViewport() 方法中,显示在 debug 模式下检查确保 widget 已经确定了文本方向(TextDirection 是 rtl 还是 ltr);然后根据 shrinkWrap 的不同分别创建 ShrinkWrappingViewport 或者 Viewport,他们会根据 offset 的变化展示不同部分的 slivers。
ShrinkWrappingViewport 和 Viewport 都是继承自 MultiChildRenderObjectWidget 的 widget,主要逻辑是分别创建对应的 RenderObject:RenderShrinkWrappingViewport 和 RenderViewport,而这两者又都继承自 RenderViewportBase。
class Viewport extends MultiChildRenderObjectWidget {
@override
RenderViewport createRenderObject(BuildContext context) {
return RenderViewport(...);
}
@override
void updateRenderObject(BuildContext context, RenderViewport renderObject) {
renderObject...;
}
@override
MultiChildRenderObjectElement createElement() => _ViewportElement(this);
}
class ShrinkWrappingViewport extends MultiChildRenderObjectWidget {
@override
RenderShrinkWrappingViewport createRenderObject(BuildContext context) {
return RenderShrinkWrappingViewport(...);
}
@override
void updateRenderObject(BuildContext context, RenderShrinkWrappingViewport renderObject) {
renderObject...;
}
}
RenderViewportBase
abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMixin<RenderSliver>> extends RenderBox with ContainerRenderObjectMixin<RenderSliver, ParentDataClass> implements RenderAbstractViewport {}
RenderViewportBase 继承自 RenderBox,混入了 ContainerRenderObjectMixin<RenderSliver, ParentDataClass>类,本身不持有 children,但提供了在 RenderBox 中容纳 RenderSliver 的一些通用方法:
- 自动添加监听_offset 的方法,在其变化时执行 markNeedsLayout() 方法实现滑动效果
- 提供按照_offset 的值 layout、paint 持有的 children 的方法
- 通过 hitTestChildren 实现 children 的 hit test
校验 children 类型是否为 RenderSliver
在 RenderViewportBase 混入的 ContainerRenderObjectMixin<RenderSliver, ParentDataClass>的 debugValidateChild() 方法中会检验 child 的类型是否为指定的 ChildType(在 RenderViewportBase 中 ChildType 为 RenderSliver),如果不是则会抛出 FlutterError,这也是 ScrollView 默认 Viewport 只支持可以创建 RenderSliver 的 Widget 的原因。
mixin ContainerRenderObjectMixin<ChildType extends RenderObject, ParentDataType extends ContainerParentDataMixin<ChildType>> on RenderObject {
bool debugValidateChild(RenderObject child) {
assert(() {
if (child is! ChildType) {// 此处会校验 child 的类型
throw FlutterError.fromParts(<DiagnosticsNode>[
...
]);
}
return true;
}());
return true;
}
}
可见 ContainerRenderObjectMixin 提供了检测 child 类型的方法,那么它是在什么时候被调用的呢?
无论是 Viewport 还是 ShrinkWrappingViewport 都继承自 MultiChildRenderObjectWidget,其会创建 MultiChildRenderObjectElement。
MultiChildRenderObjectElement.insertRenderObjectChild() 添加 child 中的 RenderObject 时都会先检查一下 child 的类型:
class MultiChildRenderObjectElement extends RenderObjectElement {
@override
void insertRenderObjectChild(RenderObject child, IndexedSlot<Element?> slot) {
final ContainerRenderObjectMixin<RenderObject, ContainerParentDataMixin<RenderObject>> renderObject = this.renderObject;
// 注意这里调用 MultiChildRenderObjectElement 持有的 RenderObject 的
// debugValidateChild 方法校验 child 类型
assert(renderObject.debugValidateChild(child));
renderObject.insert(child, after: slot.value?.renderObject);
assert(renderObject == this.renderObject);
}
}
而因为不管是 RenderShrinkWrappingViewport 还是 RenderViewport 都是继承自 RenderViewportBase,也就会执行ContainerRenderObjectMixin<RenderSliver, ParentDataClass>.debugValidateChild(child)
方法,校验 child 类型是否为 RenderSliver,所以会在其slivers
中直接传入 box widget 则会报错“A RenderViewport expected a child of type RenderSliver but received a child of type RenderXXX.”
RenderViewport
class RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData> {}
RenderViewport 是 Flutter 滑动机制的主力,他通过监听offset
的变化展示children
的一部分来实现滑动的视觉效果,他会占据父级给的最大空间(大小由父级指定)。
其内部持有一个双向的 slivers 列表children
,以在 zero scroll offset 的center
为锚点:
- slivers 列表中在 center 之前的 Slivers 按照列表反方向,沿着 axisDirection 的反方向展示。
- slivers 列表中在 center 之后的 Slivers 按照列表的方向,沿着 axisDirection 的方向展示。
比如一个 axisDirection 为 AxisDirection.down,children 列表为["1", "2", "3", "center", "5", "6", "7"],center 为“center”,那么默认会展示["center", "5", "6", "7"],当手指向下滑动的时候,会依次展示出“3”、“2”、“1”,等完全下拉之后,展示内容为:["1", "2", "3", "center", "5", "6", "7"]。
RenderShrinkWrappingViewport
class RenderShrinkWrappingViewport extends RenderViewportBase<SliverLogicalContainerParentData>{}
RenderShrinkWrappingViewport 通过监听offset
的变化展示children
的一部分来实现滑动的视觉效果,与 Viewport 不同的是,他会 shrinkWrap(收缩包装)自己以便在主轴上匹配 children 的 size(大小由 RenderShrinkWrappingViewport 根据 children 计算而来),比较耗费性能(特别是当 item 可能会通过折叠展开等方式改变尺寸时)。
List<Widget> buildSlivers()
ScrollView 的 buildSlivers 方法是抽象方法,由子类根据需要实现,一般子类也只需要重写此方法即可。
/// Build the list of widgets to place inside the viewport.
///
/// Subclasses should override this method to build the slivers for the inside
/// of the viewport.
@protected
List<Widget> buildSlivers(BuildContext context);
使用示例
CustomScrollView 就是继承自 ScrollView:
class CustomScrollView extends ScrollView {
/// Creates a [ScrollView] that creates custom scroll effects using slivers.
///
/// See the [ScrollView] constructor for more details on these arguments.
const CustomScrollView({
...
}) : super(
...
);
/// The slivers to place inside the viewport.
final List<Widget> slivers;
@override
List<Widget> buildSlivers(BuildContext context) => slivers;
}
可以看到 CustomScrollView 的实现比较简单,主要逻辑是将传入的参数slivers
作为List<Widget> buildSlivers(BuildContext context)
的返回值。这导致我们在使用 CustomScrollView 的时候,需要传入 SliverList、SliverAppBar 等这些继承自 SliverMultiBoxAdaptorWidget 能创建 RenderSliver 的 Widget,而不是普通的 box widget。
总结
ScrollView 的子类借助 SliverMultiBoxAdaptorWidget 及其子类可以实现对 item 的懒加载从而避免创建无法通过 viewport 可见的 children(这种类型的传参一般都需传入 SliverChildDelegate 的子类),从而优化性能。
而根据 shrinkWrap 的不同,分别使用 Viewport 和 ShrinkWrappingViewport 创建 viewport,从而分别实现按照父级指定 size 或按照子级计算 size(比较耗性能)。
ScrollView 是 Flutter 中基于 sliver protocol 的 scrollable widget 的父类,因为 viewport 的限制只接受创建 RenderSliver 的 widget 作为其直接子类。其子类则通过 SliverMultiBoxAdaptorWidget 及其子类实现加载 box widget。
以下类都是基于 ScrollView 实现的 scrollable widget: