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 {
@override
_RenderSingleChildViewport createRenderObject(BuildContext context) {
return _RenderSingleChildViewport(
axisDirection: axisDirection,
offset: offset,// 此处的 offset 是来自于 Scrollable 的 ScrollPosition
clipBehavior: clipBehavior,
);
}
@override
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 方法中也有对应操作:
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_offset.addListener(_hasScrolled);
}
@override
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。
@override
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:
@override
bool 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;
}
至此,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 等。