flutter —— 布局原理与约束 (Sliver 布局)
布局模型
RenderBox 布局,移步 flutter —— 布局原理与约束
Sliver 的布局流程如下:
- Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
- Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
- Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。
Sliver 模型的约束
基础约束对象
class SliverConstraints extends Constraints {
//主轴方向
AxisDirection? axisDirection;
//Sliver 新数据沿主轴的哪个方向插入?枚举类型,正向或反向
GrowthDirection? growthDirection;
//用户滑动方向
ScrollDirection? userScrollDirection;
//当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
double? scrollOffset;
//当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
double? precedingScrollExtent;
//上一个 sliver 覆盖当前 sliver 的大小,通常在 sliver 是 pinned/floating
//或者处于列表头尾时有效。
double? overlap;
//当前Sliver在Viewport中的最大可以绘制的区域。
//绘制如果超过该区域会比较低效(因为不会显示)
double? remainingPaintExtent;
//纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
double? crossAxisExtent;
//纵轴方向
AxisDirection? crossAxisDirection;
//Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
double? viewportMainAxisExtent;
//Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
double? cacheOrigin;
//Viewport加载区域的长度,范围:
//[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
double? remainingCacheExtent;
}
布局信息对象
const SliverGeometry({
//Sliver在主轴方向占据的预估高度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
this.scrollExtent = 0.0,
this.paintExtent = 0.0, // 可视区域中的绘制高度
this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
//在 Viewport中占用的高度;如果列表滚动方向是水平方向,则表示列表长度。
//范围[0,paintExtent]
double? layoutExtent,
this.maxPaintExtent = 0.0,//最大绘制高度
this.maxScrollObstructionExtent = 0.0,
double? hitTestExtent, // 点击测试的范围
bool? visible,// 是否显示
//是否会溢出Viewport,如果为true,Viewport便会裁剪
this.hasVisualOverflow = false,
//scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
//可以先进行修正。
this.scrollOffsetCorrection,
double? cacheExtent, // 在预渲染区域中占据的高度
})
布局原理
基本流程:约束子 sliver,设置自身 geometry,保存子节点偏移信息,绘制子节点。
下面自定义一个简单的 SliverToBoxAdapter 组件:
class SliverToCustomBoxAdapter extends SingleChildRenderObjectWidget {
const SliverToCustomBoxAdapter({
Key? key,
Widget? child,
}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverToCustomBoxAdapter();
}
}
class RenderSliverToCustomBoxAdapter extends RenderSliverSingleBoxAdapter {
RenderSliverToCustomBoxAdapter();
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
// 1.对子节点进行约束 SliverConstraints
child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
double childExtent;
switch (constraints.axis) {
case Axis.horizontal:
childExtent = child!.size.width;
break;
case Axis.vertical:
childExtent = child!.size.height;
break;
}
final paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: childExtent);
final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent);
// 2.上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
// visible: paintExtent > 0.0
// 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
// paintOrigin: 0
// 通常等于子组件的高度,并且不随着滚动而改变
scrollExtent: childExtent,
// 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
paintExtent: paintedChildSize,
cacheExtent: cacheExtent,
// 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
maxPaintExtent: childExtent,
);
// 3.设置并保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
// PS:方法内部执行的 childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
setChildParentData(child!, constraints, geometry!);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
// 4.绘制子节点
// topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
//其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
//其中 paintOffset(默认等于0)
//即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
}
约束属性
cacheOrigin
Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
remainingPaintExtent
当前 Sliver 在 Viewport 中的最大可以绘制的区域。
作用1:计算当前 sliver 距离顶部的距离
//如果大于0,表示当前 sliver 距离顶部的高度为 topOffset。若已经到达顶部或超出顶部,则该值始终等于 0。此时超出的距离可参考 scrollOffset。
topOffset = viewportMainAxisExtent - remainingPaintExtent;
作用2:限制 paintExtent 的最大高度
// paintExtent 通常需要这样处理一下,避免超出 remainingPaintExtent
paintExtent = min(paintExtent, constraints.remainingPaintExtent);
scrollOffset
scrollOffset 属性表示滚动滑出Viewport边界的距离,类似于 web 的 scrollTop 属性。一般来说表示组件的上边界离开 viewport 顶部的长度,未到达顶部之前都是 0。
overlap
上一个 sliver 覆盖当前 sliver 的大小,见下面的示例2。或者在顶部向下拉一下弹回的效果时,overlay 会变成负的。
布局属性
paintOrigin
当该值小于 0 时,当前 sliver 的整体起始位置会向上偏移 paintOrigin.abs() 的长度。如果每次下拉 x 的长度,paintOrigin 也向上移动 x 的距离,则 sliver 相对静止,由此可实现 pinned 效果。
layoutExtent
布局时占用的高度,取值范围 [0, paintExtent]。即 layoutExtent 须小于等于 paintExtent
(当前绘制的高度,一般是等于)。
当 layoutExtent 小于 paintExtent 时,则一部分高度会被下一个 sliver 顶上。
示例 2:
说明:蓝色块高度是 100,但是占据高度只有 30,导致红色块向上顶了 70的高度。紫色部分是下方的红色与上方的蓝色重叠的区域。
代码:
class CustomSliverPage extends StatefulWidget {
const CustomSliverPage({Key? key}) : super(key: key);
@override
State<CustomSliverPage> createState() => _CustomSliverPageState();
}
class _CustomSliverPageState extends State<CustomSliverPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.blueGrey,
body: CustomScrollView(
physics: AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
slivers: [
SliverAppBar(
toolbarHeight: 0,
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 200,
color: Colors.green,
child: Text("200"),
),
),
SliverWrapper(
sliver: SliverList(
delegate: SliverChildListDelegate([
Container(
alignment: Alignment.center,
color: Colors.blue.withOpacity(0.5),
height: 100,
child: Text("100"),
),
]),
),
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 200,
color: Colors.red,
child: Text("200"),
),
)
],
));
}
}
class SliverWrapper extends SingleChildRenderObjectWidget {
const SliverWrapper({
Key? key,
Widget? sliver,
}) : super(child: sliver);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverWrapper();
}
}
class RenderSliverWrapper extends RenderSliver with RenderObjectWithChildMixin<RenderSliver> {
RenderSliverWrapper();
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
//1. 对子节点进行约束 SliverConstraints
child!.layout(constraints, parentUsesSize: true);
final SliverGeometry childLayoutGeometry = child!.geometry!;
//2. 上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// scrollExtent: childLayoutGeometry.scrollExtent - childLayoutGeometry.maxScrollObstructionExtent,
paintExtent: childLayoutGeometry.paintExtent,
paintOrigin: childLayoutGeometry.paintOrigin,
//占用布局的高度,默认等于绘制高度 paintExtent
// 这里 paintExtent 等于 100, 而 layoutExtent 等于 30,即有 70 的高度未被占用,则下一个 sliver 会向上占用剩下的 70的高度
layoutExtent: math.min(childLayoutGeometry.paintExtent, 30),
maxPaintExtent: childLayoutGeometry.maxPaintExtent,
maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
hitTestExtent: childLayoutGeometry.hitTestExtent,
visible: childLayoutGeometry.visible,
hasVisualOverflow: childLayoutGeometry.hasVisualOverflow,
scrollOffsetCorrection: childLayoutGeometry.scrollOffsetCorrection,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && child!.geometry!.visible) {
//3. 绘制子节点
final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
childParentData.applyPaintTransform(transform);
}
}
visible
当 visible 为 false 时会影响子节点的显示。在示例中,只占据空间(占据高度 layoutExtent),而不显示界面。即不影响布局。
效果:
说明:中间 30 的高度为原蓝色块占据的空间
scrollExtent
可滚动的范围。一般来说,对于 ListView,在 sliver 上边界滚动到顶部之前 paintExtent 等于 layoutExtent 都等于 scrollExtent,到达顶部后慢慢变小,直到变为 0。而 scrollExtent 一直不变。
注:如果 layoutExtent 不慢慢变小,即保持不变并且大于 0,则在当前 sliver 滚动到顶部后还可以继续滚动 scrollExtent 的长度(除非 scrollExtent 也等于 0),然后再执行下一个 sliver 的滚动。
示例 3:
说明:由于 layoutExtent 与 scrollExtent 都一直不变,并且不等于 0。蓝色 sliver 向上滚动到 Viewport 顶部后,还可以继续滚动 100 的高度,当这 100 也滚完了,下一个 sliver 才开始滚动。
代码:
class CustomSliverPage extends StatefulWidget {
const CustomSliverPage({Key? key}) : super(key: key);
@override
State<CustomSliverPage> createState() => _CustomSliverPageState();
}
class _CustomSliverPageState extends State<CustomSliverPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.blueGrey,
body: CustomScrollView(
physics: AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
slivers: [
SliverAppBar(
toolbarHeight: 0,
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 200,
color: Colors.green,
child: Text("200"),
),
),
SliverWrapper(
sliver: SliverList(
delegate: SliverChildListDelegate([
Container(
alignment: Alignment.center,
color: Colors.blue.withOpacity(0.8),
height: 300,
child: Text("300"),
),
]),
),
),
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
height: 1200,
color: Colors.red,
child: Text("200"),
),
)
],
));
}
}
class SliverWrapper extends SingleChildRenderObjectWidget {
const SliverWrapper({
Key? key,
Widget? sliver,
}) : super(child: sliver);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderSliverWrapper();
}
}
class RenderSliverWrapper extends RenderSliver with RenderObjectWithChildMixin<RenderSliver> {
RenderSliverWrapper();
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
//1. 对子节点进行约束 SliverConstraints
child!.layout(constraints, parentUsesSize: true);
final SliverGeometry childLayoutGeometry = child!.geometry!;
//2. 上报当前节点的布局信息给 viewport
geometry = SliverGeometry(
// 当前 Sliver 顶部向上滚动到 Viewport 顶部后,还可以继续滚动 100 的高度
// 当这 100 也滚完了,下一个 sliver 才开始滚动
scrollExtent: 100,
paintExtent: 300,
paintOrigin: childLayoutGeometry.paintOrigin,
layoutExtent: 300,
maxPaintExtent: 300,
maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
hitTestExtent: childLayoutGeometry.hitTestExtent,
visible: true,
// hasVisualOverflow: true,
// scrollOffsetCorrection: 0.2,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && child!.geometry!.visible) {
//3. 绘制子节点
final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child == this.child);
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
childParentData.applyPaintTransform(transform);
}
}
2233