列表组件在移动端上尤为重要,Sliver 作为 Flutter 列表组件中重要的一部分,开发者非常有必要了解 Sliver 的原理和用法。
两种类型的布局
Flutter 的布局可以分为两种:
- Box ( RenderBox ): 2D 绘制布局
- Sliver ( RenderSliver ):滚动布局
重要的概念
Sliver
Sliver 是 Flutter 中的一个概念,表示可滚动布局中的一部分,它的 child 可以是普通的 Box Widget。
ViewPort
- ViewPort 是一个显示窗口,它内部可包含多个 Sliver;
- ViewPort 的宽高是确定的,它内部 Slivers 的宽高之和是可以大于自身的宽高的;
- ViewPort 为了提高性能采用懒加载机制,它只会绘制可视区域内容 Widget。
ViewPort 有一些重要属性:
class Viewport extends MultiChildRenderObjectWidget {
/// 主轴方向
final AxisDirection axisDirection;
/// 纵轴方向
final AxisDirection crossAxisDirection;
/// center 决定 viewport 的 zero 基准线,也就是 viewport 从哪个地方开始绘制,默认是第一个 sliver
/// center 必须是 viewport slivers 中的一员的 key
final Key center;
/// 锚点,取值[0,1],和 zero 的相对位置,比如 0.5 代表 zero 被放到了 Viewport.height / 2 处
final double anchor;
/// 滚动的累计值,确切的说是 viewport 从什么地方开始显示
final ViewportOffset offset;
/// 缓存区域,也就是相对有头尾需要预加载的高度
final double cacheExtent;
/// children widget
List<Widget> slivers;
}
一图胜千言:
上图中假设每个 sliver 的 height 都相等且等于屏幕高度的 ⅕,这样设置 center = sliver1,屏幕的第一个显示的应该是 sliver 1,但是因为 anchor = 0.2,0.2 * viewport.height 正好等于 sliver1 的高度,所以屏幕上显示的第一个是 sliver 2。
ScrollPostion
ScrollPosition 决定了 Viewport 哪些区域是可见的,它包含了Viewport 的滚动信息,它的主要成员变量如下:
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
// 滚动偏移量
double _pixels;
// 设置滚动响应效果,比如滑动停止后的动画
final ScrollPhysics physics;
// 保存当前的滚动偏移量到 PageStore 中,当 Scrollable 重建后可以恢复到当前偏移量
final bool keepScrollOffset;
// 最小滚动值
double _minScrollExtent;
// 最大滚动值
double _maxScrollExtent;
...
}
ScrollPosition 的类继承关系如下:
|-- Listenable
|---- ChangeNotifier
|------ ScrollPosition
|-------- ScrollPositionWithSingleContext
所以 ScrollPosition 可以作为被观察者,当数据改变的时候可以通知观察者。
Scrollable
Scrollable 是一个可滚动的 Widget,它主要负责:
- 监听用户的手势,计算滚动状态发出 Notification
- 计算 offset 通知 listeners
Scrollable 本身不具有绘制内容的能力,它通过构造注入的 viewportBuilder 来创建一个 Viewport 来显示内容,当滚动状态变化的时候,Scrollable 就会不断的更新 Viewport 的 offset ,Viewport 就会不断的更新显示内容。
Scrollable 主要结构如下:
Widget result = _ScrollableScope(
scrollable: this,
position: position,
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
gestures: _gestureRecognizers,
...,
child: Semantics(
...
child: IgnorePointer(
...
child: widget.viewportBuilder(context, position),
),
),
),
),
);
- _ScrollableScope 继承自 InheritedWidget,这样它的 children 可以方便的获取 scrollable 和 position;
- RawGestureDetector 负责手势监听,手势变化时会回调 _gestureRecognizers;
- viewportBuilder 会生成 viewport;
SliverConstraints
和 Box 布局使用 BoxConstraints 作为约束类似,Sliver 布局采用 SliverConstraints 作为约束,但相对于 Box 要复杂的多,可以理解为 SliverConstraints 描述了 Viewport 和它内部的 Slivers 之间的布局信息:
class SliverConstraints extends Constraints {
// 主轴方向
final AxisDirection axisDirection;
// 窗口增长方向
final GrowthDirection growthDirection;
// 如果 Direction 是 AxisDirection.down,scrollOffset 代表 sliver 的 top 滑过 viewport 的 top 的值,没滑过 viewport 的 top 时 scrollOffset 为 0。
final double scrollOffset;
// 上一个 sliver 覆盖下一个 sliver 的大小(只有上一个 sliver 是 pinned/floating 才有效)
final double overlap;
// 轮到当前 sliver 开始绘制了,需要 viewport 告诉 sliver 当前还剩下多少区域可以绘制,受 viewport 的 size 影响
final double remainingPaintExtent;
// viewport 主轴上的大小
final double viewportMainAxisExtent;
// 缓存区起点(相对于 scrolloffset),如果 cacheExtent 设置为 0,那么 cacheOrigin 一直为 0
final double cacheOrigin;
// 剩余的缓存区大小
final double remainingCacheExtent;
...
}
上图中的 sliver1 会被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此时 overlap 会一直大于 0,如果设置像 iOS bouncing 那样的滑动效果,那么当 list 滚动到顶部继续滑动的时候 overlap 会小于 0(此刻并没有东西遮盖 sliver1,而是 sliver1 的 top 和 viewport 的 top 有间距)。
SliverGeometry
Viewport 通过 SliverConstraints 告知它内部的 sliver 自己的约束信息,比如还有多少空间可用、offset 等,那么Sliver 则通过 SliverGeometry 反馈给 Viewport 需要占用多少空间量。
class SliverGeometry extends Diagnosticable {
// sliver 可以滚动的范围,可以认为是 sliver 的高度(如果是 AxisDierction.Down)
final double scrollExtent;
// 绘制起点(默认是 0.0),是相对于 sliver 开始 layout 的起点而言的,不会影响下一个 sliver 的 layoutExtent,会影响下一个 sliver 的paintExtent
final double paintOrigin;
// 绘制范围
final double paintExtent;
// 布局范围,当前 sliver 的 top 到下一个 sliver 的 top 的距离,范围是[0,paintExtent],默认是 paintExtent,会影响下一个 sliver 的 layout 位置
final double layoutExtent;
// 最大绘制大小,必须 >= paintExtent
final double maxPaintExtent;
// 如果 sliver 被 pinned 在边界的时候,这个大小为 Sliver 的自身的高度,其他情况为0,比如 pinned app bar
final double maxScrollObstructionExtent;
// 点击有效区域的大小,默认为paintExtent
final double hitTestExtent;
// 是否可见,visible = (paintExtent > 0)
final bool visible;
// 是否需要做clip,免得chidren溢出
final bool hasVisualOverflow;
// 当前 sliver 占用了 SliverConstraints.remainingCacheExtent 多少像素值
final double cacheExtent;
...
}
Sliver 布局过程
RenderViewport 在 layout 它内部的 slivers 的过程如下:
这个 layout 过程是一个自上而下的线性过程:
- 给 sliver1 输入 SliverConstrains1 并且得到输出结果(SliverGeometry1) ,
- 根据 SliverGeometry1 重新生成一个新的 SliverConstrains2 输入给 sliver2 得到 SliverGeometry2
- …
- 直至最后一个 sliver 具体的过程可以查看 RenderViewport 的 layoutChildSequence 方法。
ScrollView
以 ScrollView 为例,我们串联上面介绍的几个 Widget 之间的关系。 先来看 ScrollView 的 build 方法:
@override
Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);
final ScrollController scrollController = primary
? PrimaryScrollController.of(context)
: controller;
final Scrollable scrollable = Scrollable(
...
controller: scrollController,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers);
},
);
return primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}
可以看到 ScrollView 创建了一个 Scrollable,并传入了构造 ViewPort 的 buildViewPort 方法。 上面讲过 Scrollable 负责手势监听,通过 buildViewPort 创建视图,在手势变化的时候不停的更新 ViewPort,大概流程如下:
自定义 Sliver
CustomPinnedHeader 光看一些概念会难以理解,最好的方式是 debug 一下,我们可以 copy 一下 SliverToBoxAdapter 的代码自定义一个 Sliver 调试一下各个参数加深理解。
class CustomSliverWidget extends SingleChildRenderObjectWidget {
const CustomSliverWidget({Key key, Widget child})
: super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return CustomSliver();
}
}
/// 一个 StickPinWidget
/// 主要讲述 Sliveronstraints 和 SliverGeometry 参数的作用
class CustomSliver extends RenderSliverSingleBoxAdapter {
@override
void performLayout() {
...
// 将 SliverConstraints 转化为 BoxConstraints 对 child 进行 layout
child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
...
// 计算绘制大小
final double paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
// 计算缓存大小
final double cacheExtent =
calculateCacheOffset(constraints, from: 0.0, to: childExtent);
...
// 输出 SliverGeometry
geometry = SliverGeometry(
scrollExtent: childExtent,
paintExtent: paintedChildSize,
cacheExtent: cacheExtent,
maxPaintExtent: childExtent,
paintOrigin: 0,
hitTestExtent: paintedChildSize,
hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
);
setChildParentData(child, constraints, geometry);
}
}
我们把它放到 CustomScrollView 中:
eturn Scaffold(
body: CustomScrollView(
slivers: <Widget>[
CustomSliverWidget(
child: Container(
color: Colors.red,
height: 100,
child: Center(
child: Text("CustomSliver"),
),
)),
_buildListView(),
],
));
效果如下:
我们修改 paintOrigin 为 10 的话,发现 CustomSliverWidget 的 layout 位置没有变,但绘制的起始点下移了 10 px,并且它下一个的 Sliver - item0 的 layout 没有被影响,但是 paint 时被遮住了一部分: 再做一个简单的修改,将 sliver 的绘制起始位置改为滑动的偏移量: geometry = SliverGeometry(
...
paintOrigin: constraints.scrollOffset,
visible: true,
);
此时你会发现 CustomSliver 可以固定在头部:
我们尝试修改 paintExtrent 如下:
geometry = SliverGeometry(
//将绘制范围改为 sliver 的高度
paintExtent: childExtent,
...
);
在滑动的过程,CustomSliver 只是绘制变了,layout 没有变,导致下面 item0 没有被滑动,这是因为 layoutExtent 默认等于 paintExtent,我们将 paintExtent 赋值了常量,滑动过程中只有 paintOrigin 在改变,layout 的初始位置和高度并没有改变,它会一直占据着位置。
CustomRefreshWidget
接下来我们再做一个简单的下拉刷新 Widget,效果很简单,下拉的时候显示,释放的时候缩回:
class CustomRefreshWidget extends SingleChildRenderObjectWidget {
const CustomRefreshWidget({Key key, Widget child})
: super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return SimpleRefreshSliver();
}
}
/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {
@override
void performLayout() {
...
final bool active = constraints.overlap < 0.0;
/// 头部滑动的距离
final double overscrolledExtent =
constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
double layoutExtent = child.size.height;
print("overscrolledExtent:${overscrolledExtent - layoutExtent}");
child.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent + overscrolledExtent,
),
parentUsesSize: true,
);
if (active) {
geometry = SliverGeometry(
scrollExtent: layoutExtent,
/// 绘制起始位置
paintOrigin: min(overscrolledExtent - layoutExtent, 0),
paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
/// 布局占位
layoutExtent: min(overscrolledExtent, layoutExtent),
);
} else {
/// 如果不想显示可以直接设置为 zero
geometry = SliverGeometry.zero;
}
setChildParentData(child, constraints, geometry);
}
}
可以看到有3个关键的参数
- constraints.overlap:List 第一个 Sliver 的 top 距离屏幕 top 的距离
- paintOrigin:RefreshWidget 的绘制起始位置
- layoutExtent:RefreshWidget 的高度
items 的 top 与屏幕顶部的距离就是 constraints.overlap,它是一个小于等于 0 的值。
- 未操作时,overlap == 0,直接返回一个空 Widget(SliverGeometry.zero)
- 下拉时,overlap < 0, 这时候将 paintOrigin = min(overscrolledExtent - RefreshWidget.height, 0) 就可以让 RefreshWidget 慢慢的拉下来。
- 处理完 Paint 后,不要忘记处理 layout,前面说过,SliverGeometry 的 layoutExtent 会影响下一个 Sliver 的布局位置,所以 layoutExtent 也需要随着滑动而逐渐变大 layoutExtent = min(-overlap, RefreshWidget.height)
Scrolling Widget
常用的 List 如下,我们按照它包裹的内容分成了 3 类:
ListView
ListView.builder(
itemCount: 50,
itemBuilder: (context,index) {
return Container(
color: ColorUtils.randomColor(),
height: 50,
);
}
CustomScrollView
CustomScrollView(
slivers: <Widget>[
SliverAppBar(...),
SliverToBoxAdapter(
child:ListView(...),
),
SliverList(...),
SliverGrid(...),
],
)
NestedScrollView
NestedScrollView 其实里面是一个CustomScrollView,它的 headers 是 Sliver 的数组,body是被包裹在 SliverFillRemaining 中的,body 可以接受 Box。
NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
expandedHeight: 100,
pinned: true,
title: Text("Nest"),
),
SliverToBoxAdapter(
child: Text("second bar"),
)
];
},
body: ListView.builder(
itemCount: 20,
itemBuilder: (BuildContext context, int index) {
return Text("item: $index");
}),
);
设计 CustomScrollView 的原因
复杂列表嵌套
如果直接使用 ListView 嵌套 ListView 会报错:
Vertical viewport was given unbounded height.
大致意思是在 layout 阶段父 Listview 不能判断子 Listview 的高度,这个错误可以通过设置内部的 Listview 的 shrinkWrap = true 来修正(shrinkWrap = true 代表 ListView 的高度等于它 content 的高度)。
ListView.builder(
itemCount: 20,
itemBuilder: (BuildContext context, int index) {
return ListView.builder(
shrinkWrap: true,
itemCount: 5,
itemBuilder: (BuildContext context, int index) {
return Text("item: $index");
});
});
但是这样做的话性能会比较差,因为内部的列表每次都要计算出所有 content 的高度,这个时候使用 CustomScrollView 更为合适:
CustomScrollView(
slivers: <Widget>[
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(...),
childCount: 50)
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(...),
childCount: 50)
)
],
);
滑动特效
CustomScrollView 可以让它内部的 Slivers 进行联动,比如做一个可伸缩的 TitleBar 、中间区域可以固定的 header、下拉刷新组件等等。
Slivers
Flutter 提供了很多的 Sliver 组件,下面我们主要说一下它们的作用是什么:
SliverAppBar
类似于 android 中 CollapsingToolbarLayout,可以根据滑动做伸缩布局,并提供了 actions,bottom 等提高效率的属性。
SliverList / SliverGrid
用法和 ListView / GridView 基本一致。 此外,ListView = SliverList + Scrollable,也就是说 SliverList 不具备处理滑动事件的能力,所以它必须配合 CustomScrollView 来使用。
SliverFixedExtentList
它比 SliverList 多了修饰词 FixedExtent,意思是它的 item 在主轴方向上具有固定的高度/宽度。
设计它的原因是在 item 高度/宽度全都一样的场景下使用,它的效率比 SliverList 高,因为它不用通过 item 的 layout 过程就可以知道每个 item 的范围。
在使用的时候必须传入 itemExtent:
SliverFixedExtentList(
itemExtent: 50.0,
delegate: SliverChildBuilderDelegate(
...
);
},
),
)
SliverPersistentHeader
SliverPersistentHeader 是一个可以固定/悬浮的 header,它可以设置在列表的任意位置,显示的内容需要设置 SliverPersistentHeaderDelegate。SliverPersistentHeader(
pinned: true,
delegate: ...,
)
SliverPersistentHeaderDelegate 是一个抽象类,我们需要自己实现它,它的实现很简单,只有四个必须要实现的成员:
class CustomDelegate extends SliverPersistentHeaderDelegate {
/// 最大高度
@override
double get maxExtent => 100;
/// 最小高度
@override
double get minExtent => 50;
/// shrinkOffset: 当前 sliver 顶部越过屏幕顶部的距离
/// overlapsContent: 下方是否还有 content 显示
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return your widget
);
}
/// 是否需要刷新
@override
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
return maxExtent != oldDelegate.maxExtent ||
minExtent != oldDelegate.minExtent;
}
}
在实际运用中沉浸式的设计是很常见的,使用 SliverPersistentHeaderDelegate 可以轻松的实现沉浸式的效果:
它的实现原理就是根据 shrinkOffset 动态调整状态栏的样式和标题栏的颜色,实现代码见下面的 沉浸式 Header。
SliverToBoxAdapter
将 BoxWidget 转变为 Sliver:由于 CustomScrollView 只能接受 Sliver 类型的 child,所以很多常用的 Widget 无法直接添加到 CustomScrollView 中,此时只需要将 Widget 用 SliverToBoxAdapter 包裹一下就可以了。 最常见的使用就是 SliverList 不支持横向模式,但是又无法直接将 ListView 直接添加到 CustomScrollView 中,此时用 SliverToBoxAdapter 包裹一下:
CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: _buildHorizonScrollView(),
),
],
));
Widget _buildHorizonScrollView() {
return Container(
height: 50,
child: ListView.builder(
scrollDirection: Axis.horizontal,
primary: false,
shrinkWrap: true,
itemCount: 15,
itemBuilder: (context, index) {
return Container(
color: ColorUtils.randomColor(),
width: 50,
height: 50,
);
}),
);
}
SliverPadding
可以用在 CustomScrollView 中的 Padding。 需要注意的是不要用它来包裹 SliverPersistentHeader ,因为它会使 SliverPersistentHeader 的 pinned 失效,如果 SliverPersistentHeader 非要使用 Padding 效果,可以在 delegate 内部使用 Padding。
- wrong code:
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverPersistentHeader(
pinned: true,
floating: false,
delegate: Delegate(),
),
)
- correct code:
class Delegate extends SliverPersistentHeaderDelegate {
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) =>
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Container(
color: Colors.yellow,
),
);
...
}
SliverSafeArea
用法和 SafeArea 一致。
SliverFillRemaining
可以填充屏幕剩余控件的 Sliver。
部分实例代码:
沉浸式 Header
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class GradientSliverHeaderDelegate extends SliverPersistentHeaderDelegate {
final double collapsedHeight;
final double expandedHeight;
final double paddingTop;
final String coverImgUrl;
final String title;
GradientSliverHeaderDelegate({
this.collapsedHeight,
this.expandedHeight,
this.paddingTop,
this.coverImgUrl,
this.title,
});
@override
double get minExtent => this.collapsedHeight + this.paddingTop;
@override
double get maxExtent => this.expandedHeight;
@override
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
return true;
}
Color makeStickyHeaderBgColor(shrinkOffset) {
final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255)
.clamp(0, 255)
.toInt();
return Color.fromARGB(alpha, 255, 255, 255);
}
Color makeStickyHeaderTextColor(shrinkOffset) {
if (shrinkOffset <= 50) {
return Colors.white;
} else {
final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255)
.clamp(0, 255)
.toInt();
return Color.fromARGB(alpha, 0, 0, 0);
}
}
Brightness getStatusBarTheme(shrinkOffset) {
return shrinkOffset <= 50 ? Brightness.light : Brightness.dark;
}
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
SystemUiOverlayStyle systemUiOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: getStatusBarTheme(shrinkOffset));
SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
return Container(
height: this.maxExtent,
width: MediaQuery.of(context).size.width,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
// 背景图
Container(
child: Image.asset(
coverImgUrl,
fit: BoxFit.cover,
)),
// 收起头部
Positioned(
left: 0,
right: 0,
top: 0,
child: Container(
color: this.makeStickyHeaderBgColor(shrinkOffset), // 背景颜色
child: SafeArea(
bottom: false,
child: Container(
height: this.collapsedHeight,
child: Center(
child: Text(
this.title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
color: this
.makeStickyHeaderTextColor(shrinkOffset), // 标题颜色
),
),
)),
),
),
),
],
),
);
}
}
class CustomRefreshWidget extends SingleChildRenderObjectWidget {
const CustomRefreshWidget({Key key, Widget child})
: super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return SimpleRefreshSliver();
}
}
/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {
@override
void performLayout() {
if (child == null) {
geometry = SliverGeometry.zero;
return;
}
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;
}
assert(childExtent != null);
final double paintedChildSize =
calculatePaintOffset(constraints, from: 0.0, to: childExtent);
assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0);
final bool active = constraints.overlap < 0.0;
final double overscrolledExtent =
constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
double layoutExtent = child.size.height;
print("overscrolledExtent:${overscrolledExtent - layoutExtent}");
child.layout(
constraints.asBoxConstraints(
maxExtent: layoutExtent
// Plus only the overscrolled portion immediately preceding this
// sliver.
+
overscrolledExtent,
),
parentUsesSize: true,
);
if (active) {
geometry = SliverGeometry(
scrollExtent: layoutExtent,
/// 绘制起始位置
paintOrigin: min(overscrolledExtent - layoutExtent, 0),
paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
/// 布局占位
layoutExtent: min(overscrolledExtent, layoutExtent),
);
} else {
/// 如果不想显示可以直接设置为 zero
geometry = SliverGeometry.zero;
}
setChildParentData(child, constraints, geometry);
}
}
使用:
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
/// android 需要设置弹簧效果 overlap 才会起作用
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
CustomRefreshWidget(
child: Container(
height: 100,
color: Colors.purple,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
"RefreshWidget",
style: TextStyle(color: Colors.white),
),
Padding(
padding: EdgeInsets.only(left: 10.0),
child: CupertinoActivityIndicator(),
)
],
),
),
),
...
_buildListView(),
],
));
}