开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 33 天,点击查看活动详情
CustomScrollView
ListView、PageView、GridView都是一个完整的可滚动组件,它们都包括Scrollable、Viewport和Sliver。如果需要多个滚动组件联动,比如想将已有的两个垂直方向滚动的ListView合成一个ListView,在第一个ListView滑动到底部,第二个ListView能自动接上滑动,这是一种常见的场景。
Widget buildTwoListView() {
var listView = ListView.builder(
itemCount: 20,
itemBuilder: (_, index) => ListTile(title: Text('$index')),
);
return Column(
children: [
Expanded(child: listView),
Divider(color: Colors.grey),
Expanded(child: listView),
],
);
}
}
这样虽然能够显示出来,但是每个ListView只会响应自己可视区域中的滑动,实现不了联动效果。原因是两个ListView都有自己独立的Scrollable、Viewport、Sliver。那么如果自己创建一个共有的Scrollable和Viewport对象,然后将两个ListView对应的Sliver添加到这个共用的Viewport对象中是不是就可以实现效果了?
CustomScrollView组件就是帮助创建一个公共的Scrollable和Viewport,然后它的Slivers参数接受一个Sliver数组:
Widget buildTwoSliverList() {
// SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项。
// 再次提醒,如果列表项高度相同,我们应该优先使用SliverFixedExtentList
// 和 SliverPrototypeExtentList,如果不同,使用 SliverList.
var listView = SliverFixedExtentList(
itemExtent: 56, //列表项高度固定
delegate: SliverChildBuilderDelegate(
(_, index) => ListTile(title: Text('$index')),
childCount: 10,
),
);
// 使用
return CustomScrollView(
slivers: [
listView,
listView,
],
);
}
可以看出CustomScrollView的主要功能就是提供一个公共的Scrollable和Viewport,来组合多个Sliver。如下:
常用的Sliver
| Sliver 名称 | 功能 | 对应的可滚动组件 |
|---|---|---|
| SliverList | 列表 | ListView |
| SliverFixedExtentList | 高度固定的列表 | ListView,指定itemExtent时 |
| SliverAnimatedList | 添加/删除列表项可以执行动画 | AnimatedList |
| SliverGrid | 网格 | GridView |
| SliverPrototypeExtentList | 根据原型生成高度固定的列表 | ListView,指定prototypeItem时 |
| SliverFillViewport | 包含多个子组件,每个都可以填满屏幕 | PageView |
除来和列表对应的Sliver之外,还有一些用于对Sliver进行布局、装饰的组件,它们的子组件必须是Sliver:
| Sliver名称 | 对应RenderBox |
|---|---|
| SliverPadding | Padding |
| SliverVisibility、SliverOpacity | Visibility、Opacity |
| SliverFadeTransition | FadeTransition |
| SliverLayoutBuilder | LayoutBuilder |
其他一些常用的Sliver:
| Sliver名称 | 说明 |
|---|---|
| SliverAppBar | 对应AppBar,主要是为了在CunstomScrollView中使用。 |
| SliverToBoxAdapter | 一个适配器,可以将RenderBox适配为Sliver |
| SlivePersistentHeader | 滑动到顶部时可以固定值 |
大多数Sliver都和可滚动组件对应,还有一些如SliverPadding、SliverAppBar等是可可滚动组件无关的,它们主要是为了结合CustomScrollView一起使用,这是因为CustomScrollView的子组件必须是Sliver。 实例:
// 因为本路由没有使用 Scaffold,为了让子级Widget(如Text)使用
// Material Design 默认的样式风格,我们使用 Material 作为本路由的根。
Material(
child: CustomScrollView(
slivers: <Widget>[
// AppBar,包含一个导航栏.
SliverAppBar(
pinned: true, // 滑动到顶端时会固定住
expandedHeight: 250.0,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Demo'),
background: Image.asset(
"./imgs/sea.png",
fit: BoxFit.cover,
),
),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverGrid(
//Grid
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, //Grid按两列显示
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
childAspectRatio: 4.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建子widget
return Container(
alignment: Alignment.center,
color: Colors.cyan[100 * (index % 9)],
child: Text('grid item $index'),
);
},
childCount: 20,
),
),
),
SliverFixedExtentList(
itemExtent: 50.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return Container(
alignment: Alignment.center,
color: Colors.lightBlue[100 * (index % 9)],
child: Text('list item $index'),
);
},
childCount: 20,
),
),
],
),
);
代码分为三部分:
- 头部SliverAppBar:SliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollView。SliverAppBar可以结合FlexibleSpcaeBar实现Material Design中头部伸缩的模型。
- 中间的SliverGrid:它用SliverPadding包裹以给SliverGrid添加补白。SliverGrid是一个两列,宽高比为4的网格。
- 底部SliverFixedExtentList:它是一个所有子元素高度都为50像素的列表。
SliverToBoxAdapter
在实际布局中,经常需要在CustomScrollView中添加一些自定义的组件,而这些组件有些不是Sliver,为此,Flutter提供了一个SliverToBoxAdapter组件,它可以将RenderBox适配为Sliver:
CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: PageView(
children: [Text("1"), Text("2")],
),
),
),
buildSliverFixedList(),
],
);
注意,代码可以正常运行,但是PageView换一个滑动方向和CustomScrollView一致的ListView则不会正常工作,原因是CustomScrollView组合Sliver的原理是为所有子Sliver提供一个共享的Scrollable,然后统一处理指定滑动方向的滑动事件,如果Sliver中引入了其他的Scrollable,则滑动事件会冲突。上例中PageView可以正常工作是因为PageView的Scrollable只处理水平滑动,而CustomScrollView是处理垂直方向的滑动,两者不干扰,如果PageView换成一个垂直方向的ListView则不能正常工作,原因是事件先被ListView的Scrollable消费,CustomScrollView的Scrollable便接收不到滑动事件。
Flutter中手势冲突时,默认的策略是子元素生效。 结论:如果CustomScrollView存在子元素也是一个完整的可滚动组件且它们滑动的方向一致,则CustomScrollView不能正常工作。解决这个问题使用NestedScrollView。
SliverPersistentHeader
SliverPersistentHeader的功能是当滑动到CustomScrollView的顶部时,可以将组件固定在顶部。Flutter设计SliverPersistentHeader组件的初衷时为了实现SliverAppBar,所以它的属性和回调在SliverAppBar中会用到。
const SliverPersistentHeader({
Key? key,
// 构造 header 组件的委托
required SliverPersistentHeaderDelegate delegate,
this.pinned = false, // header 滑动到可视区域顶部时是否固定在顶部
this.floating = false, // 正文部分介绍
})
- floating的作用是:pinned为false时,则header可以滑出可视区域(CustomScrollView的Viewport)而不会固定到顶部,当用户再次下滑时,此时不管header已经滑出多远,都会立即出现在可视区域顶部并固定住,直到继续下滑到header在列表中原来的位置时,header才会重新回到原来的位置(不再固定在顶部)。
- delegate 用于生成header的委托,类型为SliverPersistentHeaderDelegate,是一个抽象类,需要我们自己实现对应的函数。
abstract class SliverPersistentHeaderDelegate {
// header 最大高度;pined为 true 时,当 header 刚刚固定到顶部时高度为最大高度。
double get maxExtent;
// header 的最小高度;pined为true时,当header固定到顶部,用户继续往上滑动时,header
// 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent
double get minExtent;
// 构建 header。
// shrinkOffset取值范围[0,maxExtent],当header刚刚到达顶部时,shrinkOffset 值为0,
// 如果用户继续向上滑动列表,shrinkOffset的值会随着用户滑动的偏移减小,直到减到0时。
//
// overlapsContent:一般不建议使用,在使用时一定要小心,后面会解释。
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
// header 是否需要重新构建;通常当父级的 StatefulWidget 更新状态时会触发。
// 一般来说只有当 Delegate 的配置发生变化时,应该返回false,比如新旧的 minExtent、maxExtent
// 等其他配置不同时需要返回 true,其余情况返回 false 即可。
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);
// 下面这几个属性是SliverPersistentHeader在SliverAppBar中时实现floating、snap
// 效果时会用到,平时开发过程很少使用到,读者可以先不用理会。
TickerProvider? get vsync => null;
FloatingHeaderSnapConfiguration? get snapConfiguration => null;
OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;
}
需要关注的是maxExtent和minExtent;pined为True时,当header刚刚固定到顶部,此时会对它应用maxExtent(最大高度);当用户继续往上滑动时,header的高度会随着用户继续上滑从MaxExtent逐渐减小到minExtent。如果我们想让header高度固定,则将maxExtent和minExtent指定为同样的值。
构建header需要定义一个类,让它继承自SliverpersistentHeaderDelegate,这样会增加使用成本,通过封装一个通用的委托构造器SliverHeaderDelegate,可以快速构建SliverPersistentHeaderDelegate:
typedef SliverHeaderBuilder = Widget Function(
BuildContext context, double shrinkOffset, bool overlapsContent);
class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {
// child 为 header
SliverHeaderDelegate({
required this.maxHeight,
this.minHeight = 0,
required Widget child,
}) : builder = ((a, b, c) => child),
assert(minHeight <= maxHeight && minHeight >= 0);
//最大和最小高度相同
SliverHeaderDelegate.fixedHeight({
required double height,
required Widget child,
}) : builder = ((a, b, c) => child),
maxHeight = height,
minHeight = height;
//需要自定义builder时使用
SliverHeaderDelegate.builder({
required this.maxHeight,
this.minHeight = 0,
required this.builder,
});
final double maxHeight;
final double minHeight;
final SliverHeaderBuilder builder;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
Widget child = builder(context, shrinkOffset, overlapsContent);
//测试代码:如果在调试模式,且子组件设置了key,则打印日志
assert(() {
if (child.key != null) {
print('${child.key}: shrink: $shrinkOffset,overlaps:$overlapsContent');
}
return true;
}());
// 让 header 尽可能充满限制的空间;宽度为 Viewport 宽度,
// 高度随着用户滑动在[minHeight,maxHeight]之间变化。
return SizedBox.expand(child: child);
}
@override
double get maxExtent => maxHeight;
@override
double get minExtent => minHeight;
@override
bool shouldRebuild(SliverHeaderDelegate old) {
return old.maxExtent != maxExtent || old.minExtent != minExtent;
}
}
实现代码:
class PersistentHeaderRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
buildSliverList(),
SliverPersistentHeader(
pinned: true,
delegate: SliverHeaderDelegate(//有最大和最小高度
maxHeight: 80,
minHeight: 50,
child: buildHeader(1),
),
),
buildSliverList(),
SliverPersistentHeader(
pinned: true,
delegate: SliverHeaderDelegate.fixedHeight( //固定高度
height: 50,
child: buildHeader(2),
),
),
buildSliverList(20),
],
);
}
// 构建固定高度的SliverList,count为列表项属相
Widget buildSliverList([int count = 5]) {
return SliverFixedExtentList(
itemExtent: 50,
delegate: SliverChildBuilderDelegate(
(context, index) {
return ListTile(title: Text('$index'));
},
childCount: count,
),
);
}
// 构建 header
Widget buildHeader(int i) {
return Container(
color: Colors.lightBlue.shade200,
alignment: Alignment.centerLeft,
child: Text("PersistentHeader $i"),
);
}
}
SliverPersistentHeader的builder参数overlapsContent一般不建议使用,使用时要注意,因为按照overlapsContent变量名的字面意思,只要有内容和Sliver重叠时就应该为true,但是我们在builder中打印一下overlapsContent的值就会发现,PersistentHeader 1的overlapstentContent值一直都是false,PersistentHeader 2则正常,如果再添加几个SliverPersistentHeader,发现也是正常。总结:当多个SliverPersistentHeader时,需要注意第一个SliverPersistentHeader的overlapsContent值一直为false。 约定:在使用SliverPersistentHeader构建子组件时,需要依赖overlapsContent参数,则必须保证之前至少还有一个SliverPersistentHeader或SliverAppBar(SliverAppBar在当前Flutter版本实现中都包含SliverPersistentHeader)。
总结
- CustomScrollView组合Sliver的原理是为所有子Sliver提供一个共享的Scrollable,然后统一处理指定的滑动方向事件。
- CustomScrollView和ListView、GridView、PageView一样,都是完整的可滚动组件(同时拥有Scrollable、Viewport、Sliver)。
- CustomScrollView只能组合Sliver,如果子元素也是一个完整的可滚动组件()通过SliverToBoxAdapter嵌入 且它们的滑动方向一致时不能正常工作。