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的简单使用:
SingleChildScrollView(
child: Column(
children: List.generate(
20,
(index) => SizedBox(
height: 50,
child: Center(child: Text("item $index")),
)),
),
)
在这个例子中,SingleChildScrollView中容纳了一个叫Column的child,如果Column的高度无法在屏幕中完全展示,就SingleChildScrollView就会保证用户可以上下滑动,从而展示对应的内容;否则如果能够完全显示,则内容无法滑动。
源码分析
SingleChildScrollView
class SingleChildScrollView extends StatelessWidget {}
作为一个StatelessWidget,SingleChildScrollView的主要逻辑在他的build()
方法中:
Widget build(BuildContext context) {
final AxisDirection axisDirection = _getDirection(context);
Widget? contents = child;
if (padding != null)
contents = Padding(padding: padding!, child: contents);
// 这里scrollController如果没有指定或者primary为true的话会使用上级最近的
// PrimaryScrollController
final ScrollController? scrollController = primary
? PrimaryScrollController.of(context)
: controller;
// 正如我们之前所说,SingleChildScrollView实现其实也就是对Scrollable的
// 进一步封装,提供一些自己特有的内容,比如_SingleChildViewport
Widget scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
restorationId: restorationId,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
// 这里使用了自定义的Viewport来实现布局逻辑
return _SingleChildViewport(
axisDirection: axisDirection,
offset: offset,// offset就是Scrollable处理的ScrollPosition
clipBehavior: clipBehavior,
child: contents,// 就是我们传入的child
);
},
);
// 这里处理了滑动时键盘隐藏的问题
if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
scrollable = NotificationListener<ScrollUpdateNotification>(
child: scrollable,
onNotification: (ScrollUpdateNotification notification) {
final FocusScopeNode focusNode = FocusScope.of(context);
if (notification.dragDetails != null && focusNode.hasFocus) {
focusNode.unfocus();
}
return false;
},
);
}
return primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}
可以看到,正如之前所言,SingleChildScrollView是依赖于封装Scrollable实现滑动效果。我们注意到在Scrollable.viewportBuilder中传入的是_SingleChildViewport,这个类处理了Scrollable传入的ScrollPosition也即这里的ViewportOffset:
_SingleChildViewport
_SingleChildViewport继承自SingleChildRenderObjectWidget,主要逻辑是创建和更新RenderObject——_RenderSingleChildViewport。
class _SingleChildViewport extends SingleChildRenderObjectWidget {
_RenderSingleChildViewport createRenderObject(BuildContext context) {
return _RenderSingleChildViewport(
axisDirection: axisDirection,
offset: offset,// 此处的offset是来自于Scrollable的ScrollPosition
clipBehavior: clipBehavior,
);
}
void updateRenderObject(BuildContext context, _RenderSingleChildViewport renderObject) {
// Order dependency: The offset setter reads the axis direction.
renderObject
..axisDirection = axisDirection
..offset = offset// 此处的offset是来自于Scrollable的ScrollPosition
..clipBehavior = clipBehavior;
}
}
可见处理offset以便更新content实现滑动效果的主要逻辑在_RenderSingleChildViewport这个RenderObject中。
_RenderSingleChildViewport
先看一下_RenderSingleChildViewport的继承关系: class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport{}
由上述代码可知,_RenderSingleChildViewport:
- 是RenderBox,也就是说其内部lay out遵守box protocol
- RenderObjectWithChildMixin<RenderBox>,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收到通知、从而进行对应处理:
set offset(ViewportOffset value) {
assert(value != null);
if (value == _offset)
return;
if (attached)
// 先移除已有的监听
_offset.removeListener(_hasScrolled);
_offset = value;
if (attached)
// 再为新的offset添加监听
_offset.addListener(_hasScrolled);
markNeedsLayout();
}
void _hasScrolled() {
markNeedsPaint();
markNeedsSemanticsUpdate();
}
除了上述在修改offset的时候添加/移除监听,在attach/detach方法中也有对应操作:
void attach(PipelineOwner owner) {
super.attach(owner);
_offset.addListener(_hasScrolled);
}
void detach() {
_offset.removeListener(_hasScrolled);
super.detach();
}
从上面的分析我们也可以看出,除了设置修改axisDirection、offset、cacheExtent等属性的时候会触发layout外,其余时候只会触发重新paint。
layout
一般来说Flutter Widget要展示在屏幕上需要经历build、layout、paint三步,在分析SingleChildScrollView如何根据offset的变化实现scroll效果之前,我们先看一下他是如何实现layout的。
void performLayout() {
final BoxConstraints constraints = this.constraints;
if (child == null) {
size = constraints.smallest;// 如果child为空,则按照父级的最小尺寸来
} else {
// 如果有child,就不限制主轴方向的尺寸,让child进行layout(会得到最大的主轴尺寸)
child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
// 在父级约束范围内尽可能满足child的尺寸
size = constraints.constrain(child!.size);
}
// 使用_viewportExtent作为offset的viewport范围
offset.applyViewportDimension(_viewportExtent);
// 更新viewport的内容content的大小范围
offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
}
// 只有横轴方向的约束,没有主轴方向的约束
BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
switch (axis) {
case Axis.horizontal:
// 如果是水平布局,就只限制高度,不限制宽度
return constraints.heightConstraints();
case Axis.vertical:
// 如果是垂直布局,就只限制宽度,不限制高度
return constraints.widthConstraints();
}
}
可以看到在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的范围,让我们看一下这三个值的来历:
double get _viewportExtent {
assert(hasSize);
switch (axis) {
case Axis.horizontal:
return size.width;
case Axis.vertical:
return size.height;
}
}
可以看到,_viewportExtent是取值主轴方向的size大小,也就是SingleChildScrollView的尺寸。
double get _minScrollExtent {
assert(hasSize);
return 0.0;
}
double get _maxScrollExtent {
assert(hasSize);
if (child == null)
return 0.0;
switch (axis) {
case Axis.horizontal:
return math.max(0.0, child!.size.width - size.width);
case Axis.vertical:
return math.max(0.0, child!.size.height - size.height);
}
}
_minScrollExtent默认返回0.0; _maxScrollExtent返回的是主轴方向上child减去SingleChildScrollView之后的尺寸和0.0之间的最大值,换言之,如果child比SingleChildScrollView尺寸大,_maxScrollExtent就是多出来的那一部分,也就是我们可以滑动的范围,否则为0.0,也就是SingleChildScrollView不可滑动。
paint
到目前为止,我们的SingleChildScrollView顺利得到了尺寸,假设child尺寸大于SingleChildScrollView的最大尺寸,那么当用户滑动屏幕导致offset改变的时候,又是如何实现滑动效果的呢?
先看一个属性:
// offset.pixels表示child沿着与轴方向axis direction相反的方法offset的pixels
// 比如axis direction是down的话,手指向上滑动屏幕此值增大,否则减小
Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);
// 根据position计算出child实际在SingleChildScrollView中的offset
// 以child的左上角在SingleChildScrollView左上角为0.0,向上为负,向下为正
Offset _paintOffsetForPosition(double position) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
return Offset(0.0, position - child!.size.height + size.height);
case AxisDirection.down:
return Offset(0.0, -position);
case AxisDirection.left:
return Offset(position - child!.size.width + size.width, 0.0);
case AxisDirection.right:
return Offset(-position, 0.0);
}
}
可以看出,_paintOffset是根据ScrollPosition计算出来的真正的child和SingleChildScrollView的偏移offset。
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final Offset paintOffset = _paintOffset;
void paintContents(PaintingContext context, Offset offset) {
// 可以看到这里,除了父级传入的offset外,还应用了ScrollPosition改变而变化的
// paintOffset。这样每次Scrollable修改ScrollPosition之后都会触发paint
// 方法,使用新的paintOffset绘制child
context.paintChild(child!, offset + paintOffset);
}
if (_shouldClipAtPaintOffset(paintOffset) && 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);
}
}
}
到此为止,我们可以得出以下结论:
_RenderSingleChildViewport接收传入的child,并监听传入的Offset,当其变化时执行markNeedPaint(); 其先让child在主轴方向尽可能大的进行layout,然后自身在父级约束条件下尽可能满足child size,这样当child比父级给的约束大时,child保持自身大小,而viewport的size则在父级给的最大尺寸内展示一部分child内容; 当Offset变化时,按照Offset.pixels计算出对应的paintOffset,重新绘制child,展示另外一部分child的内容,从而实现滑动效果。
hitTest
_RenderSingleChildViewport将hitTest直接转发给了child:
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;
}
bool
至此,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约束)来实现:
child: LayoutBuilder(// 获取父级约束信息
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return SingleChildScrollView(// 父级空间不足时可以滚动
child: ConstrainedBox(
constraints: BoxConstraints(
// 这里指定最小高度为父级高度,所以空间足够时Column可以按需分布children,
// 空间不足时则将children一个个依次排列(互相之间space为0)
minHeight: viewportConstraints.maxHeight,
),
child: Column(
mainAxisSize: MainAxisSize.min,// 默认主轴尺寸尽可能的小
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Container(
// A fixed-height child.
color: const Color(0xffeeee00), // Yellow
height: 120.0,
alignment: Alignment.center,
child: const Text('Fixed Height Content'),
),
Container(
// Another fixed-height child.
color: const Color(0xff008000), // Green
height: 120.0,
alignment: Alignment.center,
child: const Text('Fixed Height Content'),
),
],
),
),
);
},
)
这里使用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来解决此问题:
LayoutBuilder(// 获取父级约束信息
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return SingleChildScrollView(// 保证child超出父级限制时可以滑动
child: ConstrainedBox(
constraints: BoxConstraints(
// 这里指定最小高度为父级高度,所以空间足够时Column可以按需分布children,
// 空间不足时则将children一个个依次排列(互相之间space为0)
minHeight: viewportConstraints.maxHeight,
),
child: IntrinsicHeight(
// 当minHeight:viewportConstraints.maxHeight比Column想要的大时,
// 那么Column采用viewportConstraints.maxHeight的值
// 否则Column按照自己的内容大小来
child: Column(
children: <Widget>[
Container(
// A fixed-height child.
color: const Color(0xffeeee00), // Yellow
height: 320.0,
alignment: Alignment.center,
child: const Text('Fixed Height Content'),
),
Expanded(
// A flexible child that will grow to fit the viewport but
// still be at least as big as necessary to fit its contents.
child: Container(
color: const Color(0xffee0000), // Red
height: 120.0,
alignment: Alignment.center,
child: const Text('Flexible Content'),
),
),
],
),
),
),
);
},
)
在上面例子中,作为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等。