Flutter滑动分析之SingleChildScrollView
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的源码实现做一简单分析:它是如何实现滚动效果,有什么优势和限制。
官方对其定义是:“A box in which a single widget can be scrolled”。明确表明,SingleChildScrollView是遵守box protocol的widget,在其内部也只能有一个box widget。
用例
下面是一个SingleChildScrollView的简单使用:
1 | SingleChildScrollView( |
在这个例子中,SingleChildScrollView中容纳了一个叫Column的child,如果Column的高度无法在屏幕中完全展示,就SingleChildScrollView就会保证用户可以上下滑动,从而展示对应的内容;否则如果能够完全显示,则内容无法滑动。
源码分析
SingleChildScrollView
class SingleChildScrollView extends StatelessWidget {}
作为一个StatelessWidget,SingleChildScrollView的主要逻辑在他的build()
方法中:
1 | Widget build(BuildContext context) { |
可以看到,正如之前所言,SingleChildScrollView是依赖于封装Scrollable实现滑动效果。我们注意到在Scrollable.viewportBuilder中传入的是_SingleChildViewport,这个类处理了Scrollable传入的ScrollPosition也即这里的ViewportOffset:
_SingleChildViewport
_SingleChildViewport继承自SingleChildRenderObjectWidget,主要逻辑是创建和更新RenderObject——_RenderSingleChildViewport。
1 | class _SingleChildViewport extends SingleChildRenderObjectWidget { |
可见处理offset以便更新content实现滑动效果的主要逻辑在_RenderSingleChildViewport这个RenderObject中。
_RenderSingleChildViewport
先看一下_RenderSingleChildViewport的继承关系:class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport{}
由上述代码可知,_RenderSingleChildViewport:
- 是RenderBox,也就是说其内部lay out遵守box protocol
- 混入了RenderObjectWithChildMixin
,RenderObjectWithChildMixin为RenderObject提供一套管理单个child的模式,它的泛型指定了child的类型只能是RenderBox,这也就是为什么我们之前说SingleChildScrollView的child只能是box widget。 - 实现了RenderAbstractViewport接口,这个接口表示render object是内部比实际要大,提供了一些方法供ScrollPosition和其他viewport调用,来获取一些使此viewport在屏幕上可见的信息。
在修改axisDirection、offset、cacheExtent等三个属性的时候会触发markNeedsLayout()方法重新进行lay out;
在修改clipBehavior属性的时候只会触发markNeedsPaint()和markNeedsSemanticsUpdate()方法。
此外,在每次设置offset的时候,都会对齐添加监听,这样当Scrollable中由于用户手势或者通过ScrollController调用jumpTo/animateTo等方法修改了ScrollPosition的时候,都会使得Scrollab的viewport也就是我们这里的_RenderSingleChildViewport收到通知、从而进行对应处理:
1 | set offset(ViewportOffset value) { |
除了上述在修改offset的时候添加/移除监听,在attach/detach方法中也有对应操作:
1 |
|
从上面的分析我们也可以看出,除了设置修改axisDirection、offset、cacheExtent等属性的时候会触发layout外,其余时候只会触发重新paint。
layout
一般来说Flutter Widget要展示在屏幕上需要经历build、layout、paint三步,在分析SingleChildScrollView如何根据offset的变化实现scroll效果之前,我们先看一下他是如何实现layout的。
1 | void performLayout() { |
可以看到在SingleChildScrollView先让child在主轴方向尽可能自由布局,取得其最大值,然后自身在满足父级约束的情况下应用child的size:如果child.size在父级约束内就直接应用,负责采用父级的约束。
这样最终的效果就是我们的SingleChildScrollView在child不超过父级约束的时候只占据child的内容,当child的内容大于父级约束时,SingleChildScrollView自身的尺寸是父级给定的最大尺寸,而child本身在主轴方向上的尺寸是大于SingleChildScrollView的尺寸。这样也为我们后续通过监听offset修改显示部分child的内容实现滑动效果提供了可能。
这也告诉我们SingleChildScrollView的父级需要指定指定主轴方向约束,否则会出现异常。
比如在Column中直接使用SingleChildScrollView就会在内容过长的时候发生overflowed错误并且无法滑动SingleChildScrollView,这是因为SingleChildScrollView和child都按照最长的尺寸布局,并且这个尺寸超过了父级约束。
在SingleChildScrollView外层添加Expanded作为父级,相当于给他指定了一个约束(占据剩余空间),所以可以解决这个问题。
之后,又根据_viewportExtent以及_minScrollExtent/_maxScrollExtent分别设置了viewport和content的范围,让我们看一下这三个值的来历:
1 | double get _viewportExtent { |
可以看到,_viewportExtent是取值主轴方向的size大小,也就是SingleChildScrollView的尺寸。
1 | double get _minScrollExtent { |
_minScrollExtent默认返回0.0;
_maxScrollExtent返回的是主轴方向上child减去SingleChildScrollView之后的尺寸和0.0之间的最大值,换言之,如果child比SingleChildScrollView尺寸大,_maxScrollExtent就是多出来的那一部分,也就是我们可以滑动的范围,否则为0.0,也就是SingleChildScrollView不可滑动。
paint
到目前为止,我们的SingleChildScrollView顺利得到了尺寸,假设child尺寸大于SingleChildScrollView的最大尺寸,那么当用户滑动屏幕导致offset改变的时候,又是如何实现滑动效果的呢?
先看一个属性:
1 | // offset.pixels表示child沿着与轴方向axis direction相反的方法offset的pixels |
可以看出,_paintOffset是根据ScrollPosition计算出来的真正的child和SingleChildScrollView的偏移offset。
1 |
|
到此为止,我们可以得出以下结论:
_RenderSingleChildViewport接收传入的child,并监听传入的Offset,当其变化时执行markNeedPaint();
其先让child在主轴方向尽可能大的进行layout,然后自身在父级约束条件下尽可能满足child size,这样当child比父级给的约束大时,child保持自身大小,而viewport的size则在父级给的最大尺寸内展示一部分child内容;
当Offset变化时,按照Offset.pixels计算出对应的paintOffset,重新绘制child,展示另外一部分child的内容,从而实现滑动效果。
hitTest
_RenderSingleChildViewport将hitTest直接转发给了child:
1 |
|
至此,SingleChildScrollView基于Scrollable、ScrollPosition和_RenderSingleChildView完成了支持内部单个box widget的滑动效果。
优劣对比
相对于使用Sliver实现滑动效果的Widget来说,SingleChildScrollView使用简单,使用的是box protocol,适用于child通常是完全可见的,但是在某些特殊场景(比如竖屏变为横屏等)下可能显示不全的情况,SingleChildScrollView可以保证在父级无法完整显示child的时候使其支持滑动。
SingleChildScrollView使用起来也比较方便。
但是,正如上面分析的,无论content是否可见,SingleChildScrollView都会将其layout/paint(也就是说会将所有内容全部加载),这样如果content超出viewport的部分比较多就会非常耗费性能。
对于这种情况,就应该考虑使用ListView/GridView/CustomScrollView等基于sliver protocol的scrollable widget。在shrinkWrap属性为false的情况下,viewport会只创建屏幕可见部分 + viewport前后缓存区域的内容,在content滑出这部分区域时dispose,当其再次滑入时再recreate,从而保证性能。
进阶使用
为Column的children安全应用spacedAround,center等效果
想要给Column的children设置spacedAround效果,又需要保证在父级空间不足时能够完整显示所有children的内容的话,就需要结合SingleChildScrollView(空间不足时可滑动)、LayoutBuilder(获取父级约束信息)、ConstrainedBox(设置Column约束)来实现:
1 | child: LayoutBuilder(// 获取父级约束信息 |
这里使用ConstrainedBox确保了Column主轴方向最小尺寸是父级大小:
- 当父级尺寸大于Column的children尺寸时,多出的空隙由Column按照MainAxisAlignment.spaceAround原则分配,由于SingleChildScrollView的child尺寸和父级一致,所需不会滑动;
- 当父级尺寸小于Column的children尺寸时,Column的尺寸为children的尺寸之和(相互之间没有间隙),此时SingleChildScrollView的child尺寸大于父级尺寸,所以可以上下滑动,保证了Column的children可以完全显示。
为Column的children安全应用Expanded、Space等效果
在一些场景下,需要用到Expanded、Space等填充Column剩余的空间以展示某些内容,比如一直位于屏幕下方的版权信息,但是当Column的children尺寸大于父级尺寸时,又会导致children内容无法完整显示,如果直接在Column上加一个SingleChildScrollView作为父级,又会因为SingleChildScrollView给child在主轴方向的尺寸无限制,而Expanded又要求占据所有剩余空间从而导致出错。
此时可以在上面例子的基础上增加IntrinsicHeight/InstrinsicWidth来解决此问题:
1 | LayoutBuilder(// 获取父级约束信息 |
在上面例子中,作为SingleChildScrollView子级的Column内部能够使用Expanded的关键在于InstrinsicHeight:它的定义是“一个将child调整为child固有高度的widget”,也就是说,当child可能有无限的高度时,与其无限拓展,它更希望将自己size定位一个更加合理的固有高度(Expanded、Spacer等非RenderObjectWidget本身没有高度,所以在这里不会被计算)。
那么,当父级指定的最小约束minHeight大于InstrinsicHeight.child的最大固有高度时,child将按照父级的最小高度设置;
当父级指定的最大约束是double.infinity无限大时,InstrinsicHeight会强制其child的大小为固有高度。
但是需要注意的是,IntrinsicHeight/InstrinsicWidth因为至少需要对child进行两次layout(一次获取intrinsic dimensions,一次真正的执行layout),所以会比较耗费性能。因此应当保证Column子级数量尽可能少,并且可以使用SizeBox给child指定大小以减轻计算intrinsic dimensions的压力。
总结
SingleChildScrollView作为遵守box protocol的scrollable widget,使用简单,适用于页面内容通常为全部可见,但特殊情况下可能无法完整显示因而需要支持滚动的情况。
其child只支持可以生成RenderBox的Widget,会一次性创建所有child内容,在其内部使用ListView等时需要开启shrinkWrap从而导致其懒创建item失效,比较耗费性能。
因此,如果是大量item、child内容超出viewport部分时,应当考虑使用基于Sliver的ListView/GridView/CustomScrollView等。