上拉加载组件

flutter中有个很好用的Sliver CupertinoSliverRefreshControl,但是没有提供对应的上拉组件,三方库中有不少不错的支持的刷新组件,比如flutter_easy_refresh。但是基本是需要再滚动视图外包上一个另外的监听组件,似乎没有以Sliver方式实现的简单的上拉组件,所以我按照CupertinoSliverRefreshControl的实现原理,简单实现一个上拉sliver组件

CupertinoSliverRefreshControl实现原理

先回顾下Sliver的布局逻辑:

1、Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
2、Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
3、Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。

而该段逻辑主要在RenderSliver的performLayout方法中,需要用到SliverConstraints的如下几个属性

1
2
3
4
5
6
7
8
class SliverConstraints extends Constraints {
//当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
double? scrollOffset;
//上一个 sliver 覆盖当前 sliver 的长度(重叠部分的长度),通常在 sliver 是 pinned/floating
//或者处于列表头时,距离顶部的距离
double? overlap;
...
}

当用户滑动列表,传递给Sliver的约束会不断变化,设置新的geometry和重新布局子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
const SliverGeometry({
this.scrollExtent = 0.0, // sliver占用主轴的高度,决定Sliver在Viewport可滚动的距离,为0时不影响子组件布局显示,但是不占用滚动高度,比如排列在首尾的Sliver在用户结束滑动时,会回弹出Viewport
this.paintExtent = 0.0, // 可视区域中的绘制长度,会被传递到Boxcontrains中的minHeight
this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置,小于0时也能生效
//在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。主要影响下一个Sliver的位置
//范围[0,paintExtent]
double? layoutExtent,
this.maxPaintExtent = 0.0,//最大绘制长度
//scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
//可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。
this.scrollOffsetCorrection,
...
})

CupertinoSliverRefreshControl结构

先看下CupertinoSliverRefreshControl的两个主要组成元素。

  • CupertinoSliverRefreshControl
    一个StatefulWidget,他管理的状态就是RefreshIndicatorMode,并通过transitionNextState方法来更新RefreshIndicatorMode
  • _CupertinoSliverRefresh是个SingleChildRenderObjectWidget,他接受CupertinoSliverRefreshControl的参数配置,并传递给_RenderCupertinoSliverRefresh_RenderCupertinoSliverRefresh是个Sliver,他本身不管理刷新状态,只负责根据目前的sliver约束条件和CupertinoSliverRefreshControl的参数配置来布局和绘制。

通过下面的伪代码,表达下大概是这样的组成方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
CupertinoSliverRefreshControl (refreshIndicatorExtent){
// CupertinoSliverRefreshControl的state
_CupertinoSliverRefreshControlState { // 负责管理刷新状态
bool _hasLayoutExtent;
Widget build(context) {
return _CupertinoSliverRefresh( // 负责绘制刷新组件
refreshIndicatorExtent:widget.refreshIndicatorExtent, // 刷新组件的指定高度
hasLayoutExtent:_hasLayoutExtent, // 是否要让子组件常驻显示,默认情况下比如下拉距离不够,子组件还是会回弹的,只有下拉足够距离_hasLayoutExtent才未true
child:LayoutBuilder(builder:(context,constrain){ // 这里加个LayoutBuilder的目的是为了拿到constrain,
latestIndicatorBoxExtent = constraints.maxHeight; // 保存布局高度,据此来判断当前刷新状态
refreshState = transitionNextState(); // transitionNextState的大概逻辑是根据latestIndicatorBoxExtent的高度,和之前保存的状态,得出目前的最新状态(更新_hasLayoutExtent),必要时会触发setstate刷新
return widget.build(context, refreshState...);
})
);
}
}
}

_CupertinoSliverRefresh {
// _CupertinoSliverRefresh的RenderSliver
_RenderCupertinoSliverRefresh {
double _refreshIndicatorExtent; //
bool _hasLayoutExtent; //
performLayout() {
// 计算子组件的刷新高度
final double layoutExtent = (_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent;
// 判断此时是否需要显示子组件
final bool active = constraints.overlap < 0.0 || layoutExtent > 0.0;

// 计算子组件的高度
final double overscrolledExtent = constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
// 布局子组件
child!.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent + overscrolledExtent,
),
parentUsesSize: true,
);

if (active) {
// 显示子组件的情况,计算当前sliver的布局
geometry = SliverGeometry(
scrollExtent: layoutExtent,
paintOrigin: -overscrolledExtent - constraints.scrollOffset,
paintExtent: max(
max(child!.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
maxPaintExtent: max(
max(child!.size.height, layoutExtent) - constraints.scrollOffset,
0.0,
),
layoutExtent: max(layoutExtent - constraints.scrollOffset, 0.0),
);
} else {
// 不显示子组件的情况,直接隐藏,此时不会触发LayoutBuilder的绘制
geometry = SliverGeometry.zero;
}
}
}
}

CupertinoSliverRefreshControl总结

发现CupertinoSliverRefreshControl的逻辑并不复杂,主要通过Sliver的布局变化,触发刷新还是隐藏sliver,其中主要注意点点是:

  • _CupertinoSliverRefreshControlState中使用了LayoutBuilder,其目的是为了拿到sliver返回的Box布局信息,来判断当前的滚动状态,因为_CupertinoSliverRefresh中并没有给到一个回调机制
  • _CupertinoSliverRefresh中动态修改scrollExtent,其实会引起Viewport的抖动,因为后续的sliver布局都受到自己的高度影响,所以geometry中提供了scrollOffsetCorrection这个修正属性。

实现一个CupertinoSliverLoadMoreControl

CupertinoSliverLoadMoreControl结构

我们的组件和CupertinoSliverRefreshControl的结构保持一致,尽量不更改使用期望,只是增加了自动加载和预加载的功能。
用到的约束条件如下

1
2
3
4
5
6
7
8
9
class SliverConstraints extends Constraints {
//当前Sliver之前的sliver的滚动高度,肯能是double.infinity
double precedingScrollExtent;
//Viewport剩余的绘制距离,相当于最后一个sliver到Viewport底部的距离
double remainingPaintExtent;
// viewport 的高度
double viewportMainAxisExtent;
...
}

另外用到了Viewport的偏移量,从parent中的offset可以读取到,和cacheExtent,表示预加载区或者缓存区,在sliver不在Viewport区域但是在cacheExtent时,任然会回调布局逻辑。

代码层面的主要修改集中在sliver的performLayout()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
bool invisible = constraints.precedingScrollExtent <= 0; // 如果前面没有sliver占用空间,认为当前的Viewport是空的,则不显示刷新组件
// 同样,当剩余空间没有了,说明当前sliver已经不在Viewport中,也不需要显示,这里通过小于0.000000001判断,是因为测试中发现,在Viewport向下拉动时,有时候的remainingPaintExtent会变成一个特别小的数字,而且和0交替出现,虽然此时其实应该是0,可能是flutter的bug。
invisible |= (constraints.remainingPaintExtent < 0.000000001);
// 当不展示sliver时,就直接设置为空sliver的布局数据,
if (invisible && !_autoRefresh) {
geometry = SliverGeometry.zero;
child!.layout(
constraints.asBoxConstraints(maxExtent: 0),
parentUsesSize: true,
);
return;
} else if (invisible && _autoRefresh) {
geometry = SliverGeometry.zero;
child!.layout(
constraints.asBoxConstraints(maxExtent: 0),
parentUsesSize: true,
);

// 这里处理自动加载逻辑,
if (parent is RenderViewport) {
RenderViewport port = parent as RenderViewport;
// 这里计算出Viewport的剩余滚动空间,相当于最后一个sliver到Viewport底部的距离,没有使用`remainingPaintExtent`,是因为在这种情况下他的值为0
final remainScrollExtent = constraints.precedingScrollExtent -
port.offset.pixels -
constraints.viewportMainAxisExtent;
// 理论上这里这里永远会成立,因为`cacheExtent`和`remainScrollExtent`永远相等,只有在进入缓冲区布局时才会布局,但是实际测试发现有还未进入缓冲区就布局的情况。
if (remainScrollExtent <=
(port.cacheExtent ??
RenderAbstractViewport.defaultCacheExtent) &&
remainScrollExtent > 0) {
onPreloadZone();
}
}
return;
}

double scrollOffsetOfAllSlivers = (parent as RenderViewport).offset.pixels;

// 这里是有部分sliver占用了空间,但是没有占满Viewport的情况
if (constraints.precedingScrollExtent <
constraints.viewportMainAxisExtent) {
if (_autoRefresh && scrollOffsetOfAllSlivers >= 0) {
final scrollExtent = (constraints.viewportMainAxisExtent -
constraints.precedingScrollExtent);
geometry = SliverGeometry(
scrollExtent: scrollExtent,
paintExtent: scrollExtent,
maxPaintExtent: scrollExtent,
);
child!.layout(
constraints.asBoxConstraints(
minExtent: 0,
maxExtent: min(
constraints.remainingPaintExtent,
geometry!.maxPaintExtent,
)),
parentUsesSize: true,
);
return;
}
// user pulling down
if (scrollOffsetOfAllSlivers < 0) {
geometry = SliverGeometry.zero;
child!.layout(
constraints.asBoxConstraints(maxExtent: 0),
parentUsesSize: true,
);
return;
}

if (_hasLayoutExtent) {
var paintOrigin =
constraints.remainingPaintExtent - scrollOffsetOfAllSlivers;
var fix = paintOrigin +
_refreshIndicatorExtent -
constraints.remainingPaintExtent;
if (fix > 0) {
paintOrigin -= fix;
}

final painExtent = constraints.remainingPaintExtent - paintOrigin;
geometry = SliverGeometry(
scrollExtent: 0,
paintOrigin: paintOrigin,
paintExtent: painExtent,
maxPaintExtent: painExtent,
);
} else {
geometry = SliverGeometry(
scrollExtent: 0,
paintOrigin:
constraints.remainingPaintExtent - scrollOffsetOfAllSlivers,
paintExtent: scrollOffsetOfAllSlivers,
maxPaintExtent: scrollOffsetOfAllSlivers,
);
}
child!.layout(
constraints.asBoxConstraints(
maxExtent: min(
constraints.remainingPaintExtent,
geometry!.maxPaintExtent,
)),
parentUsesSize: true,
);
return;
}

// 这里处理了sliver即将划入到Viewport的情况,此时`invisible`为false,`remainingPaintExtent`也开始>0,此时只需要判断要不要给子组件滚动空间即刻。
child!.layout(
constraints.asBoxConstraints(maxExtent: constraints.remainingPaintExtent),
parentUsesSize: true,
);

final double layoutExtent =
(_hasLayoutExtent ? 1.0 : 0.0) * _refreshIndicatorExtent;
geometry = SliverGeometry(
scrollExtent: layoutExtent,
paintExtent:
min(_refreshIndicatorExtent, constraints.remainingPaintExtent),
maxPaintExtent: _refreshIndicatorExtent,
);

CupertinoSliverLoadMoreControl总结

下拉组件和核心代码说明已经完成,其他部分代码可以查看仓库,或者试下demo(记得在手机上查看,或者打开移动端调试模式,因为桌面滚动视图不支持弹性滚动overscroll)。