Sliver布局模型
我们介绍过 Flutter 有两种布局模型:
- 基于
RenderBox
的盒模型布局。 - 基于
Sliver
(RenderSliver
) 按需加载列表布局。
之前我们主要了解了盒模型布局组件,下面学习基于Sliver
的布局组件。
通常可滚动组件的子组件可能会非常多、占用的总高度也会非常大;如果要一次性将子组件全部构建出将会非常昂贵!为此,Flutter中提出一个Sliver
(中文为“薄片”的意思)概念,Sliver
可以包含一个或多个子组件。Sliver
的主要作用是配合:加载子组件并确定每一个子组件的布局和绘制信息,如果 Sliver
可以包含多个子组件时,通常会实现按需加载模型。
只有当 Sliver
出现在视口中时才会去构建它,这种模型也称为“基于Sliver的列表按需加载模型”。可滚动组件中有很多都支持基于Sliver
的按需加载模型,如ListView
、GridView
,但是也有不支持该模型的,如SingleChildScrollView
。
Flutter 中的可滚动组件主要由三个角色组成:Scrollable
、Viewport
和 Sliver
:
Scrollable
:用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建Viewport
。Viewport
:显示的视窗,即列表的可视区域;Sliver
:视窗里显示的元素。
具体布局过程:
Scrollable
监听到用户滑动行为后,根据最新的滑动偏移构建Viewport
。Viewport
将当前视口信息和配置信息通过SliverConstraints
传递给Sliver
。Sliver
中对子组件(RenderBox
)按需进行构建和布局,然后确认自身的位置、绘制等信息,保存在geometry
中(一个SliverGeometry
类型的对象)。
比如有一个 ListView
,大小撑满屏幕,假设它有 100
个列表项(都是RenderBox
)且每个列表项高度相同,结构如图所示:
图中白色区域为设备屏幕,也是 Scrollable
、 Viewport
和 Sliver
所占用的空间,三者所占用的空间重合,父子关系为:Sliver
父组件为 Viewport
,Viewport
的 父组件为 Scrollable
。注意ListView
中只有一个 Sliver
,在 Sliver
中实现了子组件(列表项)的按需加载和布局。
其中顶部和底部灰色的区域为 cacheExtent
,它表示预渲染的高度,需要注意这是在可视区域之外,如果 RenderBox
进入这个区域内,即使它还未显示在屏幕上,也是要先进行构建的,预渲染是为了后面进入 Viewport
的时候更丝滑。cacheExtent
的默认值是 250
,在构建可滚动列表时我们可以指定这个值,这个值最终会传给 Viewport
。
Scrollable
用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport
,我们看一下其关键的属性:
Scrollable({ ... this.axisDirection = AxisDirection.down, this.controller, this.physics, required this.viewportBuilder, })
属性 | 说明 |
---|---|
axisDirection | 滚动方向 |
physics | 此属性接受一个ScrollPhysics 类型的对象,它决定可滚动组件如何响应用户操作,比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示。 默认情况下,Flutter会根据具体平台分别使用不同的 ScrollPhysics 对象,应用不同的显示效果,如当滑动到边界时,继续拖动的话,在 iOS 上会出现弹性效果,而在 Android 上会出现微光效果。如果你想在所有平台下使用同一种效果,可以显式指定一个固定的 ScrollPhysics ,Flutter SDK中包含了两个ScrollPhysics 的子类,他们可以直接使用: 1) ClampingScrollPhysics :列表滑动到边界时将不能继续滑动,通常在Android 中 配合 GlowingOverscrollIndicator (实现微光效果的组件) 使用。2) BouncingScrollPhysics :iOS 下弹性效果。 |
controller | 此属性接受一个ScrollController 对象。ScrollController 的主要作用是控制滚动位置和监听滚动事件。默认情况下,Widget树中会有一个默认的 PrimaryScrollController ,如果子树中的可滚动组件没有显式的指定controller ,并且primary 属性值为true 时(默认就为true ),可滚动组件会使用这个默认的PrimaryScrollController 。这种机制带来的好处是父组件可以控制子树中可滚动组件的滚动行为,例如, Scaffold 正是使用这种机制在iOS中实现了点击导航栏回到顶部的功能。 |
viewportBuilder | 构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport ,同时传递一个 ViewportOffset 类型的 offset 参数,该参数描述 Viewport 应该显示那一部分内容。注意重新构建 Viewport 并不是一个昂贵的操作,因为 Viewport 本身也是 Widget ,只是配置信息,Viewport 变化时对应的 RenderViewport 会更新信息,并不会随着 Widget 进行重新构建。 |
主轴和纵轴
在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴。由于可滚动组件的默认方向一般都是沿垂直方向,所以默认情况下主轴就是指垂直方向,水平方向同理。
Viewport
Viewport
比较简单,用于渲染当前视口中需要显示 Sliver
。
Viewport({ Key? key, this.axisDirection = AxisDirection.down, this.crossAxisDirection, this.anchor = 0.0, required ViewportOffset offset, // 用户的滚动偏移 this.center, // 类型为Key,表示从什么地方开始绘制,默认是第一个元素 this.cacheExtent, // 预渲染区域 // 该参数用于配合解释cacheExtent的含义,也可以为主轴长度的乘数 this.cacheExtentStyle = CacheExtentStyle.pixel, this.clipBehavior = Clip.hardEdge, List<Widget> slivers = const <Widget>[], // 需要显示的 Sliver 列表})
需要注意的是:
offset
:该参数为Scrollabel
构建Viewport
时传入,它描述了Viewport
应该显示那一部分内容。cacheExtent
和cacheExtentStyle
:CacheExtentStyle
是一个枚举,有pixel
和viewport
两个取值。- 当
cacheExtentStyle
值为pixel
时,cacheExtent
的值为预渲染区域的具体像素长度; - 当
cacheExtentStyle
值为viewport
时,cacheExtent
的值是一个乘数,表示有几个viewport
的长度,最终的预渲染区域的像素长度为:cacheExtent * viewport
的积, 这在每一个列表项都占满整个Viewport
时比较实用,这时cacheExtent
的值就表示前后各缓存几个页面。
- 当
Sliver
Sliver
主要作用是对子组件进行构建和布局,比如 ListView
的 Sliver
需要实现子组件(列表项)按需加载功能,只有当列表项进入预渲染区域时才会去对它进行构建和布局、渲染。
Sliver
对应的渲染对象类型是 RenderSliver
,RenderSliver
和 RenderBox
的相同点是都继承自 RenderObject
类,不同点是在布局的时候约束信息不同。RenderBox
在布局时父组件传递给它的约束信息对应的是 BoxConstraints
,只包含最大宽高的约束;而 RenderSliver
在布局时父组件(列表)传递给它的约束是对应的是 SliverConstraints
。
可滚动组件的通用配置
几乎所有可滚动组件在构造时都能指定的通用属性有:
scrollDirection
(滑动的主轴)reverse
(滑动方向是否反向,指阅读方向的反方向,取决于语言环境)controller
physics
cacheExtent
这些属性最终会透传给对应的 Scrollable
和 Viewport
。
Scrollbar
Scrollbar
是一个Material风格的滚动指示器(滚动条),如果要给可滚动组件添加滚动条,只需将Scrollbar
作为可滚动组件的任意一个父级组件即可,如:
Scrollbar( child: SingleChildScrollView( ... ),);
Scrollbar
和CupertinoScrollbar
都是通过监听滚动通知来确定滚动条位置的。CupertinoScrollbar
是 iOS 风格的滚动条,如果你使用的是Scrollbar
,那么在iOS平台它会自动切换为CupertinoScrollbar
。
SingleChildScrollView
SingleChildScrollView
类似于Android中的ScrollView
,它只能接收一个子组件,定义如下:
SingleChildScrollView({ this.scrollDirection = Axis.vertical, // 滚动方向,默认是垂直方向 this.reverse = false, this.padding, bool primary, this.physics, this.controller, this.child,})
除了前面介绍过的可滚动组件的通用属性外,这里重点关注primary
属性:
- 它表示是否使用
widget
树中默认的PrimaryScrollController
(MaterialApp
组件树中已经默认包含一个PrimaryScrollController
了); - 当滑动方向为垂直方向(
scrollDirection
值为Axis.vertical
)并且没有指定controller
时,primary
默认为true
。
需要注意的是,通常SingleChildScrollView
只应在期望的内容不会超过屏幕太多时使用,这是因为SingleChildScrollView
不支持基于 Sliver
的延迟加载模型,所以如果预计视口可能包含超出屏幕尺寸太多的内容时,那么使用SingleChildScrollView
将会非常昂贵(性能差),此时应该使用一些支持Sliver
延迟加载的可滚动组件,如ListView
。
下面是一个使用SingleChildScrollView
将大写字母 A-Z
沿垂直方向显示的例子:
class SingleChildScrollViewTestRoute extends StatelessWidget { Widget build(BuildContext context) { String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; return Scrollbar( // 显示进度条 child: SingleChildScrollView( padding: EdgeInsets.all(16.0), child: Center( child: Column( //动态创建一个List children: str.split("") //每一个字母都用一个Text显示,字体为原来的两倍 .map((c) => Text(c, textScaleFactor: 2.0,)) .toList(), ), ), ), ); }}
效果:
ListView
ListView
是最常用的可滚动组件之一,它可以沿一个方向线性排布所有子组件,并且它也支持列表项懒加载(在需要时才会创建)。
ListView默认构造函数
我们看看ListView
的默认构造函数定义:
ListView({ ... // 可滚动 widget 公共参数 Axis scrollDirection = Axis.vertical, // 滑动方向,Axis.horizontal水平列表Axis.vertical垂直列表 bool reverse = false, // 滑动方向是否反向 ScrollController? controller, // 控制可滚动组件的滚动 bool? primary, // 是否使用 widget 树中默认的PrimaryScrollController ScrollPhysics? physics, // 如滑动到边界时效果 EdgeInsetsGeometry? padding, // 内边距 // ListView 各个构造函数的共同参数 double? itemExtent, Widget? prototypeItem, // 列表项原型 bool shrinkWrap = false, bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, double? cacheExtent, // 预渲染区域长度 // 列表项元素 List<Widget> children = const <Widget>[],})
上面参数分为两组:第一组是可滚动组件的公共参数,前面已经介绍过,不再赘述;第二组是ListView
各个构造函数(ListView
有多个构造函数)的共同参数,我们重点来看看这些参数:
-
itemExtent
:该参数如果不为null
,则会强制children
的“长度”为itemExtent
的值;这里的“长度”是指滚动方向上子组件的长度,也就是说如果滚动方向是垂直方向,则itemExtent
代表子组件的高度;如果滚动方向为水平方向,则itemExtent
就代表子组件的宽度。在
ListView
中,指定itemExtent
比让子组件自己决定自身长度会有更好的性能,这是因为指定itemExtent
后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。 -
prototypeItem
:如果我们知道列表中的所有列表项长度都相同但不知道具体是多少,这时我们可以指定一个列表项,该列表项被称为prototypeItem
(列表项原型)。指定prototypeItem
后,可滚动组件会在 layout 时计算一次它延主轴方向的长度,这样也就预先知道了所有列表项的延主轴方向的长度,所以和指定itemExtent
一样,指定prototypeItem
会有更好的性能。注意:
itemExtent
和prototypeItem
互斥,不能同时指定它们。 -
shrinkWrap
:该属性表示是否根据子组件的总长度来设置ListView
的长度,默认值为false
。默认情况下,ListView
会在滚动方向尽可能多的占用空间。注意:当
ListView
在一个无边界(滚动方向上)的容器中时,shrinkWrap
必须为true
。 -
addAutomaticKeepAlives
:该属性我们将在介绍 PageView 组件时详细解释。 -
addRepaintBoundaries
:该属性表示是否将列表项(子组件)包裹在RepaintBoundary
组件中。RepaintBoundary
可以先简单理解为它是一个”绘制边界“,将列表项包裹在RepaintBoundary
中可以避免列表项不必要的重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary
反而会更高效。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该指定为false
。
注意:上面这些参数并非
ListView
特有,其他可滚动组件也可能会拥有这些参数,它们的含义是相同的。
默认构造函数有一个children
参数,它接受一个Widget
列表(List
)。这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder
按需动态构建列表项。
注意:虽然这种方式将所有
children
一次性传递给ListView
,但子组件仍然是在需要时才会加载(build、布局、绘制),也就是说通过默认构造函数构建的ListView
也是基于Sliver
的列表懒加载模型。
下面是一个例子:
ListView( shrinkWrap: true, padding: const EdgeInsets.all(20.0), children: <Widget>[ const Text('I\'m dedicating every day to you'), const Text('Domestic life was never quite my style'), const Text('When you smile, you knock me out, I fall apart'), const Text('And I thought I was so smart'), ],);
可以看到,虽然使用默认构造函数创建的列表也是懒加载的,但我们还是需要提前将 Widget 创建好,等到真正需要加载的时候才会对 Widget 进行布局和绘制。
ListView.builder
ListView.builder
适合列表项比较多或者列表项不确定的情况,下面看一下ListView.builder
的核心参数列表:
ListView.builder({ // ListView公共参数已省略 ... required IndexedWidgetBuilder itemBuilder, int itemCount, ...})
-
itemBuilder
:它是列表项的构建器,类型为IndexedWidgetBuilder
,返回值为一个widget
。当列表滚动到具体的index
位置时,会调用该构建器构建列表项。 -
itemCount
:列表项的数量,如果为null
,则为无限列表。
下面看一个例子:
ListView.builder( itemCount: 100, itemExtent: 50.0, // 强制高度为 50.0 itemBuilder: (BuildContext context, int index) { return ListTile(title: Text("$index")); });
效果:
ListView.separated
ListView.separated
可以在生成的列表项之间添加一个分割组件,它比ListView.builder
多了一个separatorBuilder
参数,该参数是一个分割组件生成器。
下面我们看一个例子:奇数行添加一条蓝色下划线,偶数行添加一条绿色下划线。
class ListView3 extends StatelessWidget { Widget build(BuildContext context) { // 下划线widget预定义以供复用 Widget divider1 = Divider(color: Colors.blue,); Widget divider2 = Divider(color: Colors.green); return ListView.separated( itemCount: 100, itemBuilder: (BuildContext context, int index) { // 列表项构造器 return ListTile(title: Text("$index")); }, separatorBuilder: (BuildContext context, int index) { // 分割器构造器 return index%2==0?divider1:divider2; }, ); }}
效果:
固定高度列表
前面说过,给列表指定 itemExtent
或 prototypeItem
会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtent
或 prototypeItem
。
下面看一个示例:
class FixedExtentList extends StatelessWidget { const FixedExtentList({Key? key}) : super(key: key); Widget build(BuildContext context) { return ListView.builder( prototypeItem: ListTile(title: Text("1")), // itemExtent: 56, itemBuilder: (context, index) { // LayoutLogPrint是一个自定义组件,在布局时可以打印当前上下文中父组件给子组件的约束信息 return LayoutLogPrint( tag: index, child: ListTile(title: Text("$index")), ); }, ); }}
因为列表项都是一个 ListTile
,高度相同,但是我们不知道 ListTile
的高度是多少,所以指定了prototypeItem
,运行后,控制台打印:
flutter: 0: BoxConstraints(w=428.0, h=56.0)flutter: 1: BoxConstraints(w=428.0, h=56.0)flutter: 2: BoxConstraints(w=428.0, h=56.0)...
可见 ListTile
的高度是 56
,所以我们指定 itemExtent
为 56
也是可以的。但是还是建议优先指定原型,这样的话在列表项布局修改后,仍然可以正常工作(前提是每个列表项的高度相同)。
如果本例中不指定 itemExtent
或 prototypeItem
,我们看看控制台日志信息:
flutter: 0: BoxConstraints(w=428.0, 0.0<=h<=Infinity)flutter: 1: BoxConstraints(w=428.0, 0.0<=h<=Infinity)flutter: 2: BoxConstraints(w=428.0, 0.0<=h<=Infinity)...
可以发现,列表不知道列表项的具体高度,高度约束变为 0.0
到 Infinity
。
ListView 原理
ListView
内部组合了 Scrollable
、Viewport
和 Sliver
,需要注意:
-
ListView
中的列表项组件都是RenderBox
,并不是 Sliver, 这个一定要注意。 -
一个
ListView
中只有一个Sliver
,对列表项进行按需加载的逻辑是Sliver
中实现的。 -
ListView
的Sliver
默认是SliverList
,如果指定了itemExtent
,则会使用SliverFixedExtentList
;如果prototypeItem
属性不为空,则会使用SliverPrototypeExtentList
,无论是是哪个,都实现了子组件的按需加载模型。
实例:无限加载列表
假设我们要从数据源异步分批拉取一些数据,然后用ListView展示,当我们滑动到列表末尾时,判断是否需要再去拉取数据,如果是,则去拉取,拉取过程中在表尾显示一个loading,拉取成功后将数据插入列表;如果不需要再去拉取,则在表尾提示"没有更多"。
代码如下:
import 'package:flutter/material.dart';import 'package:english_words/english_words.dart';import 'package:flutter/rendering.dart';class InfiniteListView extends StatefulWidget { _InfiniteListViewState createState() => _InfiniteListViewState();}class _InfiniteListViewState extends State<InfiniteListView> { static const loadingTag = "##loading##"; //表尾标记 var _words = <String>[loadingTag]; void initState() { super.initState(); _retrieveData(); } Widget build(BuildContext context) { return ListView.separated( itemCount: _words.length, itemBuilder: (context, index) { if (_words[index] == loadingTag) { // 如果到了表尾 if (_words.length - 1 < 100) { // 不足100条,继续获取数据 _retrieveData(); // 获取数据 return Container( // 显示加载 loading padding: const EdgeInsets.all(16.0), alignment: Alignment.center, child: SizedBox(width: 24.0, height: 24.0, child: CircularProgressIndicator(strokeWidth: 2.0), ), ); } else { // 已经加载了100条数据,不再获取数据。 return Container( alignment: Alignment.center, padding: EdgeInsets.all(16.0), child: Text("没有更多了",style: TextStyle(color: Colors.grey)), ); } } return ListTile(title: Text(_words[index])); // 显示单词列表项 }, separatorBuilder: (context, index) => Divider(height: .0), ); } // 模拟网络请求 void _retrieveData() { Future.delayed(Duration(seconds: 2)).then((e) { setState(() { // 重新构建列表 _words.insertAll( _words.length - 1, // 每次插入到 loadingTag 之前 generateWordPairs().take(20).map((e) => e.asPascalCase).toList(), // 每次生成20个单词 ); }); }); }}
效果:
添加固定列表头
很多时候我们需要给列表添加一个固定表头,比如我们想实现一个商品列表,需要在列表顶部添加一个“商品列表”,期望的效果如图所示:
我们按照之前经验,写出如下代码:
Widget build(BuildContext context) { return Column(children: <Widget>[ ListTile(title:Text("商品列表")), ListView.builder(itemBuilder: (BuildContext context, int index) { return ListTile(title: Text("$index")); }), ]);}
然后运行,发现并没有出现我们期望的效果,相反触发了一个异常;
Error caught by rendering library, thrown during performResize()。Vertical viewport was given unbounded height ...
从异常信息中我们可以看到是因为ListView
高度边界无法确定引起,所以解决的办法也很明显,我们需要给ListView
指定边界,我们通过SizedBox
指定一个列表高度看看是否生效:
... //省略无关代码SizedBox( height: 400, // 指定列表高度为400 child: ListView.builder( itemBuilder: (BuildContext context, int index) { return ListTile(title: Text("$index")); }, ),),...
效果:
可以看到,现在没有触发异常并且列表已经显示出来了,但是我们的手机屏幕高度要大于 400
,所以底部会有一些空白。那如果我们要实现列表铺满除表头以外的屏幕空间应该怎么做?直观的方法是我们去动态计算,用屏幕高度减去状态栏、导航栏、表头的高度即为剩余屏幕高度,代码如下:
... //省略无关代码SizedBox( // Material设计规范中状态栏、导航栏、ListTile高度分别为24、56、56 height: MediaQuery.of(context).size.height - 24 - 56 - 56, child: ListView.builder(itemBuilder: (BuildContext context, int index) { return ListTile(title: Text("$index")); }),)...
效果:
可以看到,我们期望的效果实现了,但是这种方法并不优雅,如果页面布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。
那么有什么方法可以自动拉伸ListView
以填充屏幕剩余空间的方法吗?当然有!答案就是Flex
。前面已经介绍过在弹性布局中,可以使用Expanded
自动拉伸组件大小,并且我们也说过Column
是继承自Flex
的,所以我们可以直接使用Column + Expanded
来实现,代码如下:
Widget build(BuildContext context) { return Column(children: <Widget>[ ListTile(title:Text("商品列表")), Expanded( child: ListView.builder(itemBuilder: (BuildContext context, int index) { return ListTile(title: Text("$index")); }), ), ]);}
Expanded
的 flex
参数默认为 1
,所以上面代码中,Expanded
会占满 Column
中除 ListTile
以外的剩余空间,运行效果跟开头期望的图一致。
ListView.custom
它需要实现一个SliverChildDelegate
用来给 ListView
生成列表项组件,通常很少使用,下面是 API 文档中的一个示例:
class MyListView extends StatefulWidget { const MyListView({super.key}); State<MyListView> createState() => _MyListViewState();}class _MyListViewState extends State<MyListView> { List<String> items = <String>['1', '2', '3', '4', '5']; void _reverse() { setState(() { items = items.reversed.toList(); }); } Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: ListView.custom( childrenDelegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return KeepAlive( data: items[index], key: ValueKey<String>(items[index]), ); }, childCount: items.length, findChildIndexCallback: (Key key) { final ValueKey<String> valueKey = key as ValueKey<String>; final String data = valueKey.value; return items.indexOf(data); }), ), ), bottomNavigationBar: BottomAppBar( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ TextButton( onPressed: () => _reverse(), child: const Text('Reverse items'), ), ], ), ), ); }}class KeepAlive extends StatefulWidget { const KeepAlive({ required Key key, required this.data, }) : super(key: key); final String data; State<KeepAlive> createState() => _KeepAliveState();}class _KeepAliveState extends State<KeepAlive> with AutomaticKeepAliveClientMixin { bool get wantKeepAlive => true; Widget build(BuildContext context) { super.build(context); return Text(widget.data); }}
这个 ListView
使用自定义 SilverChildBuilderDelegate
来支持子元素的重排序。
AnimatedList
AnimatedList
和 ListView
的功能大体相似,不同的是, AnimatedList
可以在列表中插入或删除节点时执行一个动画,在需要添加或删除列表项的场景中会提高用户体验。
AnimatedList
是一个 StatefulWidget
,它对应的 State
类型为 AnimatedListState
,添加和删除元素的方法位于 AnimatedListState
中:
void insertItem(int index, { Duration duration = _kDuration });void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = _kDuration }) ;
下面我们看一个示例:实现下面这样的一个列表,点击底部 + 按钮时向列表追加一个列表项;点击每个列表项后面的删除按钮时,删除该列表项,添加和删除时分别执行指定的动画,运行效果如图所示:
初始的时候有5个列表项,先点击了 + 号按钮,会添加一个 6,添加过程执行渐显动画。然后点击了 4 后面的删除按钮,删除的时候执行了一个渐隐+收缩的合成动画。
下面是实现代码:
class AnimatedListRoute extends StatefulWidget { const AnimatedListRoute({Key? key}) : super(key: key); State createState() => _AnimatedListRouteState();}class _AnimatedListRouteState extends State<AnimatedListRoute> { var data = <String>[]; int counter = 5; final globalKey = GlobalKey<AnimatedListState>(); void initState() { for (var i = 0; i < counter; i++) { data.add('${i + 1}'); } super.initState(); } Widget build(BuildContext context) { return Stack( children: [ AnimatedList( key: globalKey, initialItemCount: data.length, itemBuilder: ( BuildContext context, int index, Animation<double> animation, ) { //添加列表项时会执行渐显动画 return FadeTransition( opacity: animation, child: buildItem(context, index), ); }, ), buildAddBtn(), ], ); } // 创建一个 “+” 按钮,点击后会向列表中插入一项 Widget buildAddBtn() { return Positioned( bottom: 30, left: 0, right: 0, child: FloatingActionButton( child: const Icon(Icons.add), onPressed: () { data.add('${++counter}'); // 添加一个列表项 // 告诉列表项有新添加的列表项 globalKey.currentState!.insertItem(data.length - 1); print('添加 $counter'); }, ), ); } // 构建列表项 Widget buildItem(context, index) { String char = data[index]; return ListTile( // 数字不会重复,所以作为Key key: ValueKey(char), title: Text(char), trailing: IconButton( icon: Icon(Icons.delete), onPressed: () => onDelete(context, index), // 点击时删除 ), ); } void onDelete(context, index) { setState(() { globalKey.currentState!.removeItem(index, (context, animation) { // 删除过程执行的是反向动画,animation.value 会从1变为0 var item = buildItem(context, index); print('删除 ${data[index]}'); data.removeAt(index); // 删除动画是一个合成动画:渐隐 + 缩小列表项告诉 return FadeTransition( opacity: CurvedAnimation(parent: animation, curve: const Interval(0.5, 1.0), // 让透明度变化的更快一些 ), child: SizeTransition( // 不断缩小列表项的高度 sizeFactor: animation, axisAlignment: 0.0, child: item, ), ); }, duration: const Duration(milliseconds: 200), // 动画时间为 200 ms ); }); }}
代码很简单,但我们需要注意,我们的数据是单独在 data
中维护的,调用 AnimatedListState
的插入和移除方法知识相当于一个通知:在什么位置执行插入或移除动画,仍然是数据驱动的(响应式并非命令式)。
其他ListView变种
除了上面的ListView
组件,Flutter SDK 中还提供了一些其他ListView
变种,它们可以支持一些酷炫的效果,例如:
-
ListWheelScrollView:它的渲染效果类似于车轮(或者滚筒),是一个3D滚动的效果。
-
ReorderableListView:它可以通过长按拖动某一项到另一个位置来重新排序的列表组
件。
ListWheelScrollView
ListWheelScrollView
的用法和 ListView
基本相同,基础用法:
ListWheelScrollView(itemExtent: 150, children: <Widget>[ ... ], );
children
是子控件,itemExtent
指定每一个 Item
的高度。
当有大量数据的时候可以使用ListWheelScrollView.useDelegate
方法,就像 ListView.builder
一样,用法如下:
ListWheelScrollView.useDelegate( itemExtent: 150, childDelegate: ListWheelChildBuilderDelegate( builder: (context, index) { return Container( margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 30), color: Colors.primaries[index % 10], alignment: Alignment.center, child: Text('$index'), ); }, childCount: 100), );
ListWheelScrollView
的一些常用属性:
-
diameterRatio
: 调整滚轮的直径,diameterRatio
是圆筒直径和主轴渲染窗口的尺寸的比,默认值是2
,如果是垂直方向,主轴渲染窗口的尺寸是ListWheelScrollView
的高。diameterRatio
越小表示圆筒越圆。 -
perspective
: 表示圆柱投影透视图,类似 OpenGLES 中透视投影,理解为看圆
柱的距离,为0
时表示从无限远处看,1
表示从无限近处看,值的范围(0,0.01]
,注
意是左开右闭区间,默认值是0.003
,值越大,渲染效果越圆。 -
offAxisFraction
: 表示车轮水平偏离中心的程度。 -
useMagnifier
和magnification
:实现放大镜效果,useMagnifier
是否启用
放大镜,magnification
属性是放大倍率。 -
squeeze
:表示车轮上的子控件数量与在同等大小的平面列表上的子控件数量之
比,例如,如果高度为100px
,itemExtent
为20px
,那么5
个项将放在一个等效
的平面列表中。当squeeze
为1
时,RenderListWheelViewport
中也会显示5
个子
控件。当squeeze
为2
时,RenderListWheelViewport
中将显示10
个子控件,默
认值为1
。
示例代码:
import 'package:flutter/material.dart';class CustomListWheelScrollView extends StatefulWidget { _CustomListWheelScrollViewState createState() => _CustomListWheelScrollViewState();}class _CustomListWheelScrollViewState extends State<CustomListWheelScrollView> { var data = <Color>[ Colors.orange[50], Colors.orange[100], Colors.orange[200], Colors.orange[300], Colors.orange[400], Colors.orange[500], Colors.orange[600], Colors.orange[700], Colors.orange[800], Colors.orange[900], ]; Color _color = Colors.blue; Widget build(BuildContext context) { return Column( children: <Widget>[ _buildCircle(), Container( height: 150, width: 300, child: ListWheelScrollView( perspective: 0.006, itemExtent: 50, onSelectedItemChanged: (index){ print('onSelectedItemChanged:$index'); setState(() => _color=data[index]); }, children: data.map((color) => _buildItem(color)).toList(), ), ), ], ); } Widget _buildCircle() => Container( margin: EdgeInsets.only(bottom: 5), width: 30, height: 30, decoration: BoxDecoration( color: _color, shape: BoxShape.circle ), ); Widget _buildItem(Color color) { return Container( key: ValueKey(color) , alignment: Alignment.center, height: 50, color: color, child: Text( colorString(color), style: TextStyle(color: Colors.white, shadows: [ Shadow(color: Colors.black, offset: Offset(.5, .5), blurRadius: 2) ]), ), ); } String colorString(Color color) => "#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}";}
效果:
ReorderableListView
ReorderableListView
需要设置 children
和 onReorder
属性,children
是子控件,onReorder
是拖动完成后的回调。
示例代码:
import 'package:flutter/material.dart';class CustomReorderableListView extends StatefulWidget { _CustomReorderableListViewState createState() => _CustomReorderableListViewState();}class _CustomReorderableListViewState extends State<CustomReorderableListView> { var data = <Color>[ Colors.yellow[50], Colors.yellow[100], Colors.yellow[200], Colors.yellow[300], Colors.yellow[400], Colors.yellow[500], Colors.yellow[600], Colors.yellow[700], Colors.yellow[800], Colors.yellow[900], ]; Widget build(BuildContext context) { return Container( height: 250, child: ReorderableListView( padding: EdgeInsets.all(10), header: Container( color: Colors.blue, alignment: Alignment.center, height: 50, child: Text('长按拖拽进行换位',style: TextStyle(color: Colors.white),)), onReorder: _handleReorder, children: data.map((color) => _buildItem(color)).toList(), ), ); } void _handleReorder(int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex -= 1; } setState(() { final element = data.removeAt(oldIndex); data.insert(newIndex, element); }); } Widget _buildItem(Color color) { return Container( key: ValueKey(color) , alignment: Alignment.center, height: 50, color: color, child: Text( colorString(color), style: TextStyle(color: Colors.white, shadows: [ Shadow(color: Colors.black, offset: Offset(.5, .5), blurRadius: 2) ]), ), ); } String colorString(Color color) => "#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}";}
效果:
ReorderableListView
除了可滚动组件的通用配置属性外,还提供了一个 header
参数,可以在列表的顶部显示一个顶部条。
另外,ReorderableListView
的每个子控件必须设置唯一的 key,ReorderableListView
没有“懒加载”模式,需要一次构建所有的子组件,所以 ReorderableListView
并不适合加载大量数据的列表,它适用于有限集合且需要排序的情况,比如手机系统里面设置语言的功能,通过拖动对语言排序。
onReorder
是拖动完成的回调,它的第一个参数是旧的数据索引,第二个参数是拖动到
位置的索引,回调里面需要对数据进行排序并通过 setState
刷新数据。
ScrollController 监听和控制滚动
前面提到,可滚动组件的通用配置都有一个 controller
属性,通过该属性我们可以指定一个 ScrollController
来控制可滚动组件的滚动,比如可以通过ScrollController
来同步多个组件的滑动联动。 ScrollController
需要结合可滚动组件一起工作。
下面是ScrollController
构造函数:
ScrollController({ double initialScrollOffset = 0.0, // 初始滚动位置 this.keepScrollOffset = true, // 是否保存滚动位置 ...})
ScrollController
常用的方法和属性:
controller.offset
:可以获得可滚动组件当前的滚动位置。jumpTo(double offset)
:跳转到指定的位置。animateTo(double offset,...)
:跳转到指定的位置,带动画。
滚动监听
ScrollController
间接继承自Listenable
,我们可以根据ScrollController
来监听滚动事件,如:
controller.addListener(()=>print(controller.offset))
实例:我们创建一个ListView
,当滚动位置发生变化时,我们先打印出当前滚动位置,然后判断当前位置是否超过1000
像素,如果超过则在屏幕右下角显示一个“返回顶部”的按钮,该按钮点击后可以使ListView
恢复到初始位置;如果没有超过1000
像素,则隐藏“返回顶部”按钮。
代码如下:
class ScrollControllerTestRoute extends StatefulWidget { ScrollControllerTestRouteState createState() { return ScrollControllerTestRouteState(); }}class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> { ScrollController _controller = ScrollController(); bool showToTopBtn = false; // 是否显示“返回到顶部”按钮 void initState() { super.initState(); // 监听滚动事件,打印滚动位置 _controller.addListener(() { print(_controller.offset); // 打印滚动位置 if (_controller.offset < 1000 && showToTopBtn) { setState(() { showToTopBtn = false; }); } else if (_controller.offset >= 1000 && showToTopBtn == false) { setState(() { showToTopBtn = true; }); } }); } void dispose() { _controller.dispose(); // 为了避免内存泄露,需要调用_controller.dispose super.dispose(); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("滚动控制")), body: Scrollbar( child: ListView.builder( itemCount: 100, itemExtent: 50.0, // 列表项高度固定时,显式指定高度是一个好习惯(性能消耗小) controller: _controller, itemBuilder: (context, index) { return ListTile(title: Text("$index"),); } ), ), floatingActionButton: !showToTopBtn ? null : FloatingActionButton( child: Icon(Icons.arrow_upward), onPressed: () { // 返回到顶部时执行动画 _controller.animateTo( .0, duration: Duration(milliseconds: 200), curve: Curves.ease, ); } ), ); }}
效果:
由于列表项高度为 50 像素,当滑动到第 20 个列表项后,右下角 “返回顶部” 按钮会显示,点击该按钮,ListView 会在返回顶部的过程中执行一个滚动动画。
滚动位置恢复 PageStorage
PageStorage
是一个用于保存页面(路由)相关数据的组件,它并不会影响子树的UI外观,其实,PageStorage
是一个功能型组件,它拥有一个存储桶(bucket),子树中的Widget可以通过指定不同的PageStorageKey
来存储各自的数据或状态。
每次滚动结束,可滚动组件都会将滚动位置offset
存储到PageStorage
中,当可滚动组件重新创建时再恢复。
- 如果
ScrollController.keepScrollOffset
为false
,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffset
; - 如果
ScrollController.keepScrollOffset
为true
,可滚动组件在第一次创建时,会滚动到initialScrollOffset
处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset
会被忽略。
当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定PageStorageKey
来分别跟踪不同的可滚动组件的位置,如:
ListView(key: PageStorageKey(1), ... );...ListView(key: PageStorageKey(2), ... );
不同的PageStorageKey
,需要不同的值,这样才可以为不同可滚动组件保存其滚动位置。
注意:一个路由中包含多个可滚动组件时,如果要分别跟踪它们的滚动位置,并非一定就得给他们分别提供
PageStorageKey
。这是因为Scrollable
本身是一个StatefulWidget
,它的状态中也会保存当前滚动位置,所以,只要可滚动组件本身没有被从树上移除(detach
),那么其State
就不会销毁(dispose
),滚动位置就不会丢失。只有当Widget
发生结构变化,导致可滚动组件的State
销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey
,通过PageStorage
来存储滚动位置,一个典型的场景是在使用TabBarView
时,在Tab
发生切换时,Tab
页中的可滚动组件的State
就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey
。
ScrollPosition
ScrollPosition
是用来保存可滚动组件的滚动位置的。
一个ScrollController
对象可以同时被多个可滚动组件使用,ScrollController
会为每一个可滚动组件创建一个ScrollPosition
对象,这些ScrollPosition
保存在ScrollController
的positions
列表中(List
)。ScrollPosition
是真正保存滑动位置信息的对象,而offset
只是一个便捷属性:
double get offset => position.pixels;
一个ScrollController
虽然可以对应多个可滚动组件,但是有一些操作,如读取滚动位置offset
,则需要一对一!在一对多的情况下,我们可以通过其他方法读取滚动位置,举个例子,假设一个ScrollController
同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置:
...controller.positions.elementAt(0).pixelscontroller.positions.elementAt(1).pixels...
我们可以通过controller.positions.length
来确定controller
被几个可滚动组件使用。
ScrollPosition
有两个常用方法:animateTo()
和 jumpTo()
,它们是真正来控制跳转滚动位置的方法,而 ScrollController
的两个同名方法,内部最终都会调用 ScrollPosition
的这两个方法。
ScrollController控制原理
我们来介绍一下ScrollController
的另外三个方法:
ScrollPosition createScrollPosition( ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition);void attach(ScrollPosition position) ;void detach(ScrollPosition position) ;
-
当
ScrollController
和可滚动组件关联时,可滚动组件首先会调用ScrollController
的createScrollPosition()
方法来创建一个ScrollPosition
来存储滚动位置信息, -
接着,可滚动组件会调用
attach()
方法,将创建的ScrollPosition
添加到ScrollController
的positions
列表属性中,这一步称为“注册位置”,只有注册后animateTo()
和jumpTo()
才可以被调用。 -
当可滚动组件销毁时,会调用
ScrollController
的detach()
方法,将其ScrollPosition
对象从ScrollController
的positions
列表中移除,这一步称为“注销位置”,注销后animateTo()
和jumpTo()
将不能再被调用。
需要注意的是,ScrollController
的animateTo()
和 jumpTo()
内部会调用所有ScrollPosition
的animateTo()
和 jumpTo()
,以实现所有和该ScrollController
关联的可滚动组件都滚动到指定的位置。
滚动通知
Flutter Widget树中子Widget可以通过发送通知(Notification)与父(包括祖先) Widget通信。父级组件可以通过NotificationListener
组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡。
可滚动组件在滚动时会发送ScrollNotification
类型的通知,ScrollBar
正是通过监听滚动通知来实现的。通过NotificationListener
监听滚动事件和通过ScrollController
监听滚动有两个主要的不同:
NotificationListener
可以在可滚动组件到widget树根之间任意位置监听。而ScrollController
只能和具体的可滚动组件关联后才可以。- 收到滚动事件后获得的信息不同;
NotificationListener
在收到滚动事件时,通知中会携带当前滚动位置和ViewPort
的一些信息,而ScrollController
只能获取当前滚动位置。
也就是说,NotificationListener
监听的位置和信息要更加全面和详细。
实例:下面代码监听ListView
的滚动通知,然后显示当前滚动进度百分比
class ScrollNotificationTestRoute extends StatefulWidget { const ScrollNotificationTestRoute({Key? key}) : super(key: key); State createState() => _ScrollNotificationTestRouteState();}class _ScrollNotificationTestRouteState extends State<ScrollNotificationTestRoute> { String _progress = "0%"; // 保存进度百分比 Widget build(BuildContext context) { return Scrollbar( // 滚动条 child: NotificationListener<ScrollNotification>( // 监听滚动通知 onNotification: (ScrollNotification notification) { double progress = notification.metrics.pixels / notification.metrics.maxScrollExtent; // 重新构建 setState(() { _progress = "${(progress * 100).toInt()}%"; }); print("BottomEdge: ${notification.metrics.extentAfter == 0}"); print("atEdge: ${notification.metrics.atEdge}"); return false; //return true; // 放开此行注释后,进度条将失效 }, child: Stack( alignment: Alignment.center, children: <Widget>[ ListView.builder( itemCount: 100, itemExtent: 50.0, itemBuilder: (context, index) => ListTile(title: Text("$index")), ), CircleAvatar( // 显示进度百分比 radius: 30.0, backgroundColor: Colors.black54, child: Text(_progress), ) ], ), ), ); }}
效果:
在接收到滚动事件时,参数类型为ScrollNotification
,它包括一个metrics
属性,它的类型是ScrollMetrics
,该属性包含当前ViewPort
及滚动位置等信息:
pixels
:当前滚动位置。maxScrollExtent
:最大可滚动长度。extentBefore
:滑出ViewPort
顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。extentInside
:ViewPort
内部长度;此示例中屏幕显示的列表部分的长度。extentAfter
:未滑入ViewPort
部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。atEdge
:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)。
ScrollMetrics
还有一些其他属性,可自行查阅API文档。
GridView
GridView 默认构造函数
GridView({ Key? key, Axis scrollDirection = Axis.vertical, bool reverse = false, ScrollController? controller, bool? primary, ScrollPhysics? physics, bool shrinkWrap = false, EdgeInsetsGeometry? padding, required this.gridDelegate, //下面解释 bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, double? cacheExtent, List<Widget> children = const <Widget>[], ... })
GridView
和ListView
的大多数参数都是相同的,它们的含义也都相同的,我们唯一需要关注的是gridDelegate
参数,类型是SliverGridDelegate
,它的作用是控制GridView
子组件如何排列(layout)。
SliverGridDelegate
是一个抽象类,定义了GridView
Layout
相关接口,子类需要通过实现它们来实现具体的布局算法。Flutter中提供了两个SliverGridDelegate
的子类:SliverGridDelegateWithFixedCrossAxisCount
和SliverGridDelegateWithMaxCrossAxisExtent
,我们可以直接使用,下面我们分别来介绍一下它们。
SliverGridDelegateWithFixedCrossAxisCount
该子类实现了一个横轴为固定数量子元素的layout算法,其构造函数为:
SliverGridDelegateWithFixedCrossAxisCount({ double crossAxisCount, double mainAxisSpacing = 0.0, double crossAxisSpacing = 0.0, double childAspectRatio = 1.0,})
crossAxisCount
:横轴子元素的数量。此属性值确定后子元素在横轴的长度就确定了,即ViewPort
横轴长度除以crossAxisCount
的商。mainAxisSpacing
:主轴方向的间距。crossAxisSpacing
:横轴方向子元素的间距。childAspectRatio
:子元素在横轴长度和主轴长度的比例。由于crossAxisCount
指定后,子元素横轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。
可以发现,子元素的大小是通过crossAxisCount
和childAspectRatio
两个参数共同决定的。注意,这里的子元素指的是子组件的最大显示空间,注意确保子组件的实际大小不要超出子元素的空间。
下面看一个例子:
GridView( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, // 横轴三个子widget childAspectRatio: 1.0 // 宽高比为 1 ), children:<Widget>[ Icon(Icons.ac_unit), Icon(Icons.airport_shuttle), Icon(Icons.all_inclusive), Icon(Icons.beach_access), Icon(Icons.cake), Icon(Icons.free_breakfast) ]);
运行效果如图所示:
SliverGridDelegateWithMaxCrossAxisExtent
该子类实现了一个横轴子元素为固定最大长度的layout算法,其构造函数为:
SliverGridDelegateWithMaxCrossAxisExtent({ double maxCrossAxisExtent, double mainAxisSpacing = 0.0, double crossAxisSpacing = 0.0, double childAspectRatio = 1.0,})
maxCrossAxisExtent
为子元素在横轴上的最大长度,之所以是“最大”长度,是因为横轴方向每个子元素的长度仍然是等分的,举个例子,如果ViewPort
的横轴长度是450
,那么当maxCrossAxisExtent
的值在区间[450/4,450/3)
内的话,子元素最终实际长度都为112.5
,而childAspectRatio
所指的子元素横轴和主轴的长度比为最终的长度比。其他参数和SliverGridDelegateWithFixedCrossAxisCount
相同。
下面我们看一个例子:
GridView( padding: EdgeInsets.zero, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 120.0, childAspectRatio: 2.0 // 宽高比为 2 ), children: <Widget>[ Icon(Icons.ac_unit), Icon(Icons.airport_shuttle), Icon(Icons.all_inclusive), Icon(Icons.beach_access), Icon(Icons.cake), Icon(Icons.free_breakfast), ],);
运行效果如图所示:
GridView.count
GridView.count
构造函数内部使用了SliverGridDelegateWithFixedCrossAxisCount
,我们通过它可以快速的创建横轴固定数量子元素的GridView
,我们可以通过以下代码实现和上面例子相同的效果等:
GridView.count( crossAxisCount: 3, childAspectRatio: 1.0, children: <Widget>[ Icon(Icons.ac_unit), Icon(Icons.airport_shuttle), Icon(Icons.all_inclusive), Icon(Icons.beach_access), Icon(Icons.cake), Icon(Icons.free_breakfast), ],);
GridView.extent
GridView.extent
构造函数内部使用了SliverGridDelegateWithMaxCrossAxisExtent
,我们通过它可以快速的创建横轴子元素为固定最大长度的的GridView
,上面的示例代码等价于:
GridView.extent( maxCrossAxisExtent: 120.0, childAspectRatio: 2.0, children: <Widget>[ Icon(Icons.ac_unit), Icon(Icons.airport_shuttle), Icon(Icons.all_inclusive), Icon(Icons.beach_access), Icon(Icons.cake), Icon(Icons.free_breakfast), ], );
GridView.builder
与ListView
类似,GridView
也有一个builder
方法,当子widget
比较多时,我们可以通过GridView.builder
来动态创建子widget
。GridView.builder
必须指定的参数有两个:
GridView.builder( ... required SliverGridDelegate gridDelegate, required IndexedWidgetBuilder itemBuilder,)
其中itemBuilder
为子widget
构建器,gridDelegate
就是前面提到的两种Delegate。
示例:假设我们需要从服务器获取分页获取一些商品列表,然后用GridView
来展示
实现代码:
import 'package:flutter/material.dart';class GridViewWidget extends StatelessWidget { const GridViewWidget({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("GridView"), ), body: const Wrap(alignment: WrapAlignment.center, children: <Widget>[ Padding( padding: EdgeInsets.all(10), child: Text("商品列表"), ), Divider(height: 1.0), InfiniteGridView(), ]), ); }}class InfiniteGridView extends StatefulWidget { const InfiniteGridView({Key? key}) : super(key: key); State createState() => _InfiniteGridViewState();}class _InfiniteGridViewState extends State<InfiniteGridView> { final ScrollController _controller = ScrollController(); final List<String> _dataList = []; bool _reachEnd = false; void initState() { super.initState(); // 初始化刷新数据 _onRefresh(); _controller.addListener(() { if (_controller.position.pixels == _controller.position.maxScrollExtent) { print("_loadMore"); _retrieveData(); } }); } Widget build(BuildContext context) { return SizedBox( //获取屏幕宽高度: // MediaQuery.of(context).size.width // MediaQuery.of(context).size.height width: MediaQuery.of(context).size.width, //可以充满屏幕并且不会报infinity size的错误 height: MediaQuery.of(context).size.height - (_reachEnd ? 120 : 0), //可以充满屏幕并且不会报infinity size的错误 child: Flex( direction: Axis.vertical, children: <Widget>[ Expanded( flex: 10, child: RefreshIndicator( onRefresh: _onRefresh, displacement: 20, //指示器距离屏幕顶部的距离 color: Colors.orange, backgroundColor: Colors.white, child: GridView.builder( controller: _controller, // 不加这行在条目比较小的时候不能下拉,原因是RefreshIndicator子元素必须是可滚动的组件, // 这里设置为永远可以滚动 physics: const AlwaysScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, //每行三列 childAspectRatio: 1.0 //显示区域宽高相等 ), itemCount: _dataList.length, itemBuilder: (context, index) { return DecoratedBox( decoration: const BoxDecoration(color: Colors.blue), child: SizedBox( width: 50, height: 50, child: Center(child: Text( "数据${_dataList[index]}", style: const TextStyle(color: Colors.white),), ), ) //Icon(_icons[index], color: Colors.white,), ); }), ), ), Expanded(child: loadMoreWidget()), ], ), ); } //模拟延时2s请求 Future<void> _onRefresh() async { await Future.delayed(const Duration(seconds: 2)); setState(() { _dataList.clear(); for (int i = 0; i < 21; i++) { _dataList.add(i.toString()); } }); } //模拟异步获取数据 void _retrieveData() { setState(() { _reachEnd = true; }); Future.delayed(const Duration(milliseconds: 2000)).then((e) { setState(() { int start = _dataList.length; for (int i = start; i < start + 21; i++) { _dataList.add(i.toString()); } _reachEnd = false; }); }); } //底部加载更多组件 Widget loadMoreWidget() { return Offstage( offstage: !_reachEnd, child: const Center( child: SizedBox( width: 30.0, height: 30.0, child: CircularProgressIndicator(strokeWidth: 2.0)), ), ); }}
另外,我们并没有发现GridView
像ListView
那样单独提供一个设置分割线的方法,其实可以通过GridView
的两种gridDelegate
的mainAxisSpacing
和crossAxisSpacing
分别设置主轴和交叉轴的子元素间距来实现:
修改GridView
的父容器的背景就可以修改分割线的颜色。
StaggeredGridView
如果要实现类似上面这样的瀑布流效果,使用GridView
无法实现,Flutter SDK 中也没有提供自带的类似组件,我们可以使用 pub.dev上著名的package库:flutter_staggered_grid_view 来实现,具体使用方法请参考其官方文档。
PageView
如果要实现页面切换和 Tab 布局,我们可以使用 PageView
组件。需要注意,PageView
是一个非常重要的组件,因为在移动端开发中很常用,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,这些都可以通过 PageView
轻松实现。
PageView({ Key? key, this.scrollDirection = Axis.horizontal, // 滑动方向 this.reverse = false, PageController? controller, this.physics, List<Widget> children = const <Widget>[], this.onPageChanged, // 每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面 this.pageSnapping = true, // 主要是配合辅助功能用的,后面解释 this.allowImplicitScrolling = false, // 后面解释 this.padEnds = true,})
我们看一个 Tab 切换的实例,为了突出重点,我们让每个 Tab 页都只显示一个数字。
// Tab 页面 class Page extends StatefulWidget { const Page({ Key? key, required this.text }) : super(key: key); final String text; _PageState createState() => _PageState();}class _PageState extends State<Page> { Widget build(BuildContext context) { print("build ${widget.text}"); return Center(child: Text("${widget.text}", textScaleFactor: 5)); }}
然后我们创建一个 PageView
:
Widget build(BuildContext context) { var children = <Widget>[]; // 生成 6 个 Tab 页 for (int i = 0; i < 6; ++i) { children.add( Page( text: '$i')); } return PageView( // scrollDirection: Axis.vertical, // 滑动方向为垂直方向 children: children, );}
效果:
如果将 PageView
的滑动方向指定为垂直方向(上面代码中注释部分),则会变为上下滑动切换页面。
PageView.builder
与ListView
一样,PageView
也有一个 PageView.builder
方法,使用方式都是类似的:
Widget build(BuildContext context) { var pageView = PageView.builder( // 子项构造器 itemBuilder: (context, index) => FadeInImage.assetNetwork( placeholder: "images/timg4.jpg", image: images[index], fit: BoxFit.fitWidth ), itemCount: images.length, // 数量 onPageChanged: (index) { // 监听事件 print('onPageChanged: index=====$index'); }, ); return Scaffold(body: pageView); }
PageView 的页面缓存
我们在运行上面示例时会发现:每当页面切换时都会触发新 Page
页的 build
,比如我们从第一页滑到第二页,然后再滑回第一页时,控制台打印如下:
flutter: build 0flutter: build 1flutter: build 0
可见 PageView
默认并没有缓存功能,一旦页面滑出屏幕它就会被销毁,这和我们前面讲过的 ListView/GridView
不一样,在创建 ListView/GridView
时我们可以手动指定 ViewPort
之外多大范围内的组件需要预渲染和缓存(通过 cacheExtent
指定),只有当组件滑出屏幕后又滑出预渲染区域,组件才会被销毁,但是不幸的是 PageView
并没有 cacheExtent
参数!但是在真实的业务场景中,对页面进行缓存是很常见的一个需求,比如一个新闻 App,下面有很多频道页,如果不支持页面缓存,则一旦滑到新的频道旧的频道页就会销毁,滑回去时又得重新请求数据和构建页面,这谁扛得住!
按道理 cacheExtent
是 Viewport
的一个配置属性,且 PageView
也是要构建 Viewport
的,那么为什么就不能透传一下这个参数呢?我们看一下 PageView
创建 Viewport
的源码:
child: Scrollable( ... viewportBuilder: (BuildContext context, ViewportOffset position) { return Viewport( // TODO(dnfield): we should provide a way to set cacheExtent // independent of implicit scrolling: // https://github.com/flutter/flutter/issues/45632 cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0, cacheExtentStyle: CacheExtentStyle.viewport, ... ); },)
我们发现,虽然 PageView
没有透传 cacheExtent
,但是却在allowImplicitScrolling
为 true
时设置了预渲染区域,注意,此时的缓存类型为 CacheExtentStyle.viewport
,则 cacheExtent
表示缓存的长度是几个 Viewport
的宽度,cacheExtent
为 1.0
,则代表前后各缓存一个页面宽度,即前后各一页。
既然如此,那我们将 PageView
的 allowImplicitScrolling
置为 true
则不就可以缓存前后两页了?我们修改代码,然后运行示例,发现在第一页时,控制台打印信息如下:
flutter: build 0flutter: build 1 // 预渲染第二页
滑到第二页时:
flutter: build 0flutter: build 1flutter: build 2 // 预渲染第三页
当再滑回第一页时,控制台信息不变,这也就意味着第一页缓存成功,它没有被重新构建。但是如果我们从第二页滑到第三页,然后再滑回第一页时,控制台又会输出 ”build 0
“,这也符合预期,因为我们之前分析的就是设置 allowImplicitScrolling
置为 true
时就只会缓存前后各一页,所以滑到第三页时,第一页就会销毁。其实这个效果就和Android的原生控件ViewPager
类似了。
OK,能缓存前后各一页也貌似比不能缓存好一点,但还是不能彻底解决不了我们的问题。为什么明明就是顺手的事, flutter 就不让开发者指定缓存策略呢?然后我们翻译一下源码中的注释:
Todo:我们应该提供一种独立于隐式滚动(implicit scrolling)的设置 cacheExtent 的机制。
放开 cacheExtent
透传是很简单的事情,为什么还要以后再做?是有什么难题么?要理解这个我们就需要看看 allowImplicitScrolling
到底是什么了,根据文档以及注释中 issue 的链接,发现PageView
中设置 cacheExtent
会和 iOS 中辅助功能有冲突,所以暂时还没有什么好的办法。看到这可能国内的很多开发者要说我们的 App 不用考虑辅助功能,既然如此,那问题很好解决,将 PageView
的源码拷贝一份,然后透传 cacheExtent
即可。
拷源码的方式虽然很简单,但毕竟不是正统做法,那有没有更通用的方法吗?有!实际上,可滚动组件提供了一种通用的缓存子项的解决方案,在下面介绍。
可滚动组件子项的缓存
使用 AutomaticKeepAlive 开启可滚动组件的子项缓存
ListView
有一个addAutomaticKeepAlives
属性,如果addAutomaticKeepAlives
为 true
,则 ListView
会为每一个列表项添加一个 AutomaticKeepAlive
父组件。虽然 PageView
的默认构造函数和 PageView.builder
构造函数中没有该参数,但它们最终都会生成一个 SliverChildDelegate
来负责列表项的按需加载,而在 SliverChildDelegate
中每当列表项构建完成后,SliverChildDelegate
都会为其添加一个 AutomaticKeepAlive
父组件。下面我们就先介绍一下 AutomaticKeepAlive
组件。
AutomaticKeepAlive
的组件的主要作用是将列表项的根 RenderObject
的 keepAlive
按需自动标记 为 true
或 false
。为了方便叙述,我们可以认为根 RenderObject
对应的组件就是列表项的根 Widget
,代表整个列表项组件,同时我们将列表组件的 Viewport
区域 + cacheExtent
(预渲染区域)称为加载区域 :
- 当
keepAlive
标记为false
时,如果列表项滑出加载区域时,列表组件将会被销毁。 - 当
keepAlive
标记为true
时,当列表项滑出加载区域后,Viewport
会将列表组件缓存起来;当列表项进入加载区域时,Viewport
从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。
那么 AutomaticKeepAlive
什么时候会将列表项的 keepAlive
标记为 true
或 false
呢?
- 答案是开发者说了算!Flutter 中实现了一套类似 C/S 的机制,
AutomaticKeepAlive
就类似一个 Server,它的子组件可以是 Client,这样子组件想改变是否需要缓存的状态时就向AutomaticKeepAlive
发一个通知消息(KeepAliveNotification
),AutomaticKeepAlive
收到消息后会去更改keepAlive
的状态,如果有必要同时做一些资源清理的工作(比如keepAlive
从true
变为false
时,要释放缓存)。
我们基于前面的 PageView
示例,实现页面缓存,根据上面的描述实现思路就很简单了:让Page
页变成一个 AutomaticKeepAlive
的 Client 即可。为了便于开发者实现,Flutter 提供了一个 AutomaticKeepAliveClientMixin
,我们只需要让 PageState
混入这个 mixin
,且同时添加一些必要操作即可:
class _PageState extends State<Page> with AutomaticKeepAliveClientMixin { Widget build(BuildContext context) { super.build(context); // 必须调用 return Center(child: Text("${widget.text}", textScaleFactor: 5)); } bool get wantKeepAlive => true; // 是否需要缓存}
代码很简单,我们只需要提供一个 wantKeepAlive
,它会表示 AutomaticKeepAlive
是否需要缓存当前列表项;另外我们必须在 build
方法中调用一下 super.build(context)
,该方法实现在 AutomaticKeepAliveClientMixin
中,功能就是根据当前 wantKeepAlive
的值给 AutomaticKeepAlive
发送消息,AutomaticKeepAlive
收到消息后就会开始工作,如图所示:
现在我们重新运行一下示例,发现每个 Page
页只会 build
一次,缓存成功了。
需要注意,如果我们采用 PageView.custom
构建页面时没有给列表项包装 AutomaticKeepAlive
父组件,则上述方案不能正常工作,因为此时 Client 发出消息后,找不到 Server,404 了,😀。
自定义 KeepAliveWrapper 缓存
虽然AutomaticKeepAliveClientMixin
可以快速的实现页面缓存功能,但是通过混入的方式实现不是很优雅, 前面例子中必须具有侵入性的修改Page
代码,不是很灵活,比如一个Page
组件需要同时在列表中和列表外使用,为了在列表中缓存它,则我们必须实现两份。所以我们有必要对AutomaticKeepAliveClientMixin
混入进行封装。
下面是一个简单封装:
import 'package:flutter/widgets.dart';/// 包括可滚动组件的子组件后,该子组件将会被缓存,意味着即使滑出屏幕也不会被销毁。/// KeepAliveWrapper can keep the item(s) of scrollview alive, **Not dispose**.class KeepAliveWrapper extends StatefulWidget { const KeepAliveWrapper({ Key? key, required this.child, this.keepAlive = true, }) : super(key: key); final bool keepAlive; final Widget child; State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();}class _KeepAliveWrapperState extends State<KeepAliveWrapper> with AutomaticKeepAliveClientMixin { Widget build(BuildContext context) { super.build(context); return widget.child; } void didUpdateWidget(covariant KeepAliveWrapper oldWidget) { if (oldWidget.keepAlive != widget.keepAlive) { // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中 updateKeepAlive(); } super.didUpdateWidget(oldWidget); } bool get wantKeepAlive => widget.keepAlive; void dispose() { // print("KeepAliveWrapper dispose"); super.dispose(); }}
这样如果哪个列表项需要缓存,只需要使用 KeepAliveWrapper
包裹一下它即可。例如:
Widget build(BuildContext context) { var children = <Widget>[]; for (int i = 0; i < 6; ++i) { // 只需要用 KeepAliveWrapper 包装一下即可 children.add(KeepAliveWrapper(child:Page( text: '$i')); } return PageView(children: children);}
下面我们再在 ListView
中测一下:
class KeepAliveTest extends StatelessWidget { const KeepAliveTest({Key? key}) : super(key: key); Widget build(BuildContext context) { return ListView.builder(itemBuilder: (_, index) { return KeepAliveWrapper( // 为 true 后会缓存所有的列表项,列表项将不会销毁。 // 为 false 时,列表项滑出预加载区域后将会别销毁。 // 使用时一定要注意是否必要,因为对所有列表项都缓存的会导致更多的内存消耗 keepAlive: true, child: ListItem(index: index), ); }); }}class ListItem extends StatefulWidget { const ListItem({Key? key, required this.index}) : super(key: key); final int index; _ListItemState createState() => _ListItemState();}class _ListItemState extends State<ListItem> { Widget build(BuildContext context) { return ListTile(title: Text('${widget.index}')); } void dispose() { print('dispose ${widget.index}'); super.dispose(); }}
因为每一个列表项都被缓存了,所以运行后滑动列表预期日志面板不会有任何日志。如果我们将 keepAlive
设为 false
,则当列表项滑出预渲染区域后则会销毁,日志面板将有输出。
CustomScrollView
前面介绍的 ListView、GridView、PageView
都是一个完整的可滚动组件,所谓完整是指它们都包括Scrollable
、 Viewport
和 Sliver
。假如我们想要在一个页面中,同时包含多个可滚动组件,且使它们的滑动效果能统一起来,比如:我们想将已有的两个沿垂直方向滚动的 ListView
成一个 ListView
,这样在第一个 ListView
滑动到底部时能自动接上第二个 ListView
,如果尝试写一个 demo:
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
只会响应自己可视区域中滑动,实现不了我们想要的效果。之所以会这样的原因是两个 ListView
都有自己独立的 Scrollable
、 Viewport
和 Sliver
,既然如此,我们自己创建一个共用的 Scrollable
和 Viewport
对象,然后再将两个 ListView
对应的 Sliver
添加到这个共用的 Viewport
对象中就可以实现我们想要的效果了。
如果这个工作让开发者自己来做无疑是比较麻烦的,因此 Flutter 提供了一个 CustomScrollView
组件来帮助我们创建一个公共的 Scrollable
和 Viewport
,然后它的 slivers
参数接受一个 Sliver
数组,这样我们就可以使用 CustomScrollView
方面的实现我们期望的功能了:
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
,CustomScrollView
的结构如图:
Flutter 中常用的 Sliver
之前介绍过的可滚动组件都有对应的 Sliver
:
Sliver名称 | 功能 | 对应的可滚动组件 |
---|---|---|
SliverList | 列表 | ListView |
SliverFixedExtentList | 高度固定的列表 | ListView ,指定itemExtent 时 |
SliverPrototypeExtentList | 根据原型生成高度固定的列表 | ListView ,指定prototypeItem 时 |
SliverAnimatedList | 添加/删除列表项可以执行动画 | AnimatedList |
SliverGrid | 网格 | GridView |
SliverFillViewport | 包含多个子组件,每个都可以填满屏幕 | PageView |
除了和列表对应的 Sliver
之外还有一些用于对 Sliver
进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:
Sliver名称 | 对应 RenderBox |
---|---|
SliverPadding | Padding |
SliverVisibility、SliverOpacity | Visibility、Opacity |
SliverFadeTransition | FadeTransition |
SliverLayoutBuilder | LayoutBuilder |
还有一些其他常用的 Sliver
:
Sliver名称 | 说明 |
---|---|
SliverAppBar | 对应 AppBar,主要是为了在 CustomScrollView 中使用。 |
SliverToBoxAdapter | 一个适配器,可以将 RenderBox 适配为 Sliver |
SliverPersistentHeader | 滑动到顶部时可以固定住 |
Sliver
系列Widget
比较多,只需记住它的特点,需要时再去查看文档即可。上面之所以说“大多数”Sliver都和可滚动组件对应,是由于还有一些如SliverPadding
、SliverAppBar
等是和可滚动组件无关的,它们主要是为了结合CustomScrollView
一起使用,这是因为CustomScrollView
的子组件必须都是Sliver
。
示例:
import 'package:flutter/material.dart';class CustomScrollViewTestRoute extends StatelessWidget { const CustomScrollViewTestRoute({Key? key}) : super(key: key); Widget build(BuildContext context) { //本路由没有使用Scaffold,为了让子级Widget(如Text)使用 //Material Design 默认的样式风格,我们使用Material作为本路由的根。 return Material( child: CustomScrollView( slivers: <Widget>[ //AppBar,包含一个导航栏 SliverAppBar( pinned: true, expandedHeight: 250.0, flexibleSpace: FlexibleSpaceBar( title: const Text('Demo'), background: Image.asset("images/timg4.jpg", fit: BoxFit.cover,), ), ), SliverPadding( padding: const EdgeInsets.all(8.0), sliver: SliverGrid( //Grid gridDelegate: const 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, ), ), ), //List SliverFixedExtentList( itemExtent: 50.0,//子元素高度50 delegate: SliverChildBuilderDelegate((BuildContext context, int index) { //创建列表项 return Container( alignment: Alignment.center, color: Colors.lightBlue[100 * (index % 9)], child: Text('list item $index'), ); }, childCount: 50, //50个列表项 ), ), ], ), ); }}
代码分为三部分:
- 头部
SliverAppBar
:SliverAppBar
对应AppBar
,两者不同之处在于SliverAppBar
可以集成到CustomScrollView
。SliverAppBar
可以结合FlexibleSpaceBar
实现Material Design中头部伸缩的模型,具体效果可以运行该示例查看。 - 中间的
SliverGrid
:它用SliverPadding
包裹以给SliverGrid
添加补白。SliverGrid
是一个两列,宽高比为4
的网格,它有20
个子组件。 - 底部
SliverFixedExtentList
:它是一个所有子元素高度都为50
像素的列表。
运行效果:
SliverToBoxAdapter
在实际布局中,我们通常需要往 CustomScrollView
中添加一些自定义的组件,而这些组件并非都有 Sliver
版本,为此 Flutter 提供了一个 SliverToBoxAdapter
组件,它是一个适配器:可以将 RenderBox
适配为 Sliver
。
比如我们想在列表顶部添加一个可以横向滑动的 PageView
,可以使用 SliverToBoxAdapter
来配置:
class CustomScrollViewSliverToBoxAdapter extends StatelessWidget { const CustomScrollViewSliverToBoxAdapter({Key? key}) : super(key: key); Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverToBoxAdapter( child: SizedBox( height: 300, child: PageView( children: const [Text("1"), Text("2")], ), ), ), buildSliverFixedList(), ], ); } Widget buildSliverFixedList() { return 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, ), ); }}
注意,上面的代码是可以正常运行的,但是如果将 PageView
换成一个滑动方向和 CustomScrollView
一致的 ListView
则不会正常工作!
- 原因是:
CustomScrollView
组合Sliver
的原理是为所有子Sliver
提供一个共享的Scrollable
,然后统一处理指定滑动方向的滑动事件,如果Sliver
中引入了其他的Scrollable
,则滑动事件便会冲突。
上例中 PageView
之所以能正常工作,是因为 PageView
的 Scrollable
只处理水平方向的滑动,而 CustomScrollView
是处理垂直方向的,两者并未冲突,所以不会有问题,但是换一个也是垂直方向的 ListView
时则不能正常工作,最终的效果是,在ListView
内滑动时只会对ListView
起作用,原因是滑动事件被 ListView
的 Scrollable
优先消费,CustomScrollView
的 Scrollable
便接收不到滑动事件了。
Flutter 中手势的冲突时,默认的策略是子元素生效。
所以我们可以得出一个结论:如果 CustomScrollView
有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView
不能正常工作。要解决这个问题,可以使用 NestedScrollView
,后面介绍。
SliverPersistentHeader
SliverPersistentHeader
的功能是当滑动到 CustomScrollView
的顶部时,可以将组件固定在顶部。
需要注意, Flutter 中设计 SliverPersistentHeader
组件的初衷是为了实现 SliverAppBar
,所以它的一些属性和回调在SliverAppBar
中才会用到。因此,如果我们要直接使用 SliverPersistentHeader
,看到它的一些配置和参数会感到疑惑,使用起来会感觉有心智成本,为此,下面我们重点关注哪些是需要的,哪些是可以忽略的。
我们先看看 SliverPersistentHeader
的定义:
const SliverPersistentHeader({ Key? key, required SliverPersistentHeaderDelegate delegate, this.pinned = false, this.floating = false, })
pinned
:header 滑动到可视区域顶部时是否固定在顶部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; 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); } double get maxExtent => maxHeight; double get minExtent => minHeight; bool shouldRebuild(SliverHeaderDelegate old) { return old.maxExtent != maxExtent || old.minExtent != minExtent; }}
下面我们看看如何使用:
class PersistentHeaderRoute extends StatelessWidget { const PersistentHeaderRoute({Key? key}) : super(key: key); 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
的 overlapsContent
值一直都是 false
,而 PersistentHeader 2
则是正常的,如果我们再添加几个 SliverPersistentHeader
,发现新添加的也都正常。
总结一下:当有多个 SliverPersistentHeader
时,需要注意第一个 SliverPersistentHeader
的 overlapsContent
值会一直为 false
。
这可能是一个 bug,也可能就是这么设计的,因为 SliverPersistentHeader
的设计初衷主要是为了实现 SliverAppBar
,可能并没有考虑到通用的场景。为此,我们可以定一条约定:如果我们在使用 SliverPersistentHeader
构建子组件时需要依赖 overlapsContent
参数,则必须保证之前至少还有一个 SliverPersistentHeader
或 SliverAppBar
(SliverAppBar
的实现中内部包含了SliverPersistentHeader
)。
CustomScrollView 总结:
CustomScrollView
组合Sliver
的原理是为所有子Sliver
提供一个共享的Scrollable
,然后统一处理指定滑动方向的滑动事件。CustomScrollView
和ListView、GridView、PageView
一样,都是完整的可滚动组件(同时拥有Scrollable、Viewport、Sliver
)。CustomScrollView
只能组合Sliver
,如果有孩子也是一个完整的可滚动组件(通过SliverToBoxAdapter
嵌入)且它们的滑动方向一致时便不能正常工作。
NestedScrollView
前面我们已经知道 CustomScrollView
只能组合 Sliver
,如果有孩子也是一个可滚动组件(通过 SliverToBoxAdapter
嵌入)且它们的滑动方向一致时便不能正常工作。为了解决这个问题,Flutter 中提供了一个NestedScrollView
组件,它的功能是组合(协调)两个可滚动组件,下面我们看看它的定义:
const NestedScrollView({ ... //省略可滚动组件的通用属性 // header,sliver构造器 required this.headerSliverBuilder, // 可以接受任意的可滚动组件 required this.body, this.floatHeaderSlivers = false,})
我们先看一个简单的示例,需要实现的页面的最终效果如下:
页面有三部分组成:
- 最上面是一个
AppBar
,实现导航,要能固定在顶端 AppBar
下面是一个SliverList
,可以有任意多个列表项,为了演示,我们指定5个列表项即可。- 最下面是一个
ListView
。
预期的效果是 SliverList
和 下面的 ListView
的滑动能够统一(而不是在下面ListView
上滑动时只有ListView
响应滑动),整个页面在垂直方向是一个整体。实现代码如下:
Material( child: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { // 返回一个 Sliver 数组给外部可滚动组件。 return <Widget>[ SliverAppBar( title: const Text('嵌套ListView'), pinned: true, // 固定在顶部 forceElevated: innerBoxIsScrolled, ), buildSliverList(5), // 构建一个 sliverList ]; }, body: ListView.builder( // 构建一个 ListView padding: const EdgeInsets.all(8), physics: const ClampingScrollPhysics(), //重要 itemCount: 30, itemBuilder: (BuildContext context, int index) { return SizedBox( height: 50, child: Center(child: Text('Item $index')), ); }, ), ),);
NestedScrollView
在逻辑上将可滚动组件分为了 header
和 body
两部分,header
部分我们可以认为是外部可滚动组件(outer scroll view),可以认为这个可滚动组件就是 CustomScrollView
,它只能接收 Sliver
,因此我们通过headerSliverBuilder
来构建一个 Sliver
列表给外部的可滚动组件;而 body
部分可以接收任意的可滚动组件,该可滚动组件称为内部可滚动组件 (inner scroll view)。
Flutter 的源码注释中和文档中会有 outer 和 inner 两个概念,分别指代外部和内部可滚动组件。
NestedScrollView 原理
NestedScrollView
的结构图如图所示:
有几点解释:
NestedScrollView
整体就是一个CustomScrollView
(实际上是CustomScrollView
的一个子类)header
和body
都是CustomScrollView
的子Sliver
,注意,虽然body
是一个RenderBox
,但是它会被包装为Sliver
。CustomScrollView
将其所有子Sliver
在逻辑上分为header
和body
两部分:header
是前面部分、body
是后面部分。- 当
body
是一个可滚动组件时, 它和CustomScrollView
分别有一个Scrollable
,由于body
在CustomScrollView
的内部,所以称其为内部可滚动组件,称CustomScrollView
为外部可滚动组件;同时 因为header
部分是Sliver
,所以没有独立的Scrollable
,滑动时是受CustomScrollView
的Scrollable
控制,所以为了区分,可以称header
为外部可滚动组件(Flutter 文档中是这么约定的)。 NestedScrollView
核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个controller
,然后通过这两个controller
来协调控制它们的滚动。
综上,在使用 NestedScrollView
有两点需要注意:
-
要确认内部的可滚动组件(
body
)的physics
是否需要设置为ClampingScrollPhysics
。比如上面的示例运行在 iOS 中时,ListView
如果没有设置为ClampingScrollPhysics
,则用户快速滑动到顶部时,会执行一个弹性效果,此时ListView
就会与header
显得割裂(滑动效果不统一),所以需要设置。但是,如果header
中只有一个SliverAppBar
则不应该加,因为SliverAppBar
是固定在顶部的,ListView
滑动到顶部时上面已经没有要继续往下滑动的元素了,所以此时出现弹性效果是符合预期的。 -
内部的可滚动组件(
body
的)不能设置controller
和primary
,这是因为NestedScrollView
的协调器中已经指定了它的controller
,如果重新设定则协调器将会失效。
SliverAppBar
前面我们已经使用过 SliverAppBar
,但是并没有仔细介绍,因为它最常见的使用场景是在作为 NestedScrollView
的 header
。
SliverAppBar
是 AppBar
的 Sliver
版,大多数参数都相同,但 SliverAppBar
会有一些特有的功能,下面是 SliverAppBar
特有的一些配置:
const SliverAppBar({ this.collapsedHeight, // 收缩起来的高度 this.expandedHeight,// 展开时的高度 this.pinned = false, // 是否固定 this.floating = false, //是否漂浮 this.snap = false, // 当漂浮时,此参数才有效 bool forceElevated //导航栏下面是否一直显示阴影 ...})
-
SliverAppBar
在NestedScrollView
中随着用户的滑动是可以收缩和展开的,因此我们需要分别指定收缩和展开时的高度。 -
pinned
为true
时SliverAppBar
会固定在NestedScrollView
的顶部,行为 和SliverPersistentHeader
的pinned
功能一致。 -
floating
和snap
:floating
为true
时,SliverAppBar
不会固定到顶部,当用户向上滑动到顶部时,SliverAppBar
也会滑出可视窗口。当用户反向滑动时,SliverAppBar
的snap
为true
时,此时无论SliverAppBar
已经滑出屏幕多远,都会立即回到屏幕顶部;但如果snap
为false
,则SliverAppBar
只有当向下滑到边界时才会重新回到屏幕顶部。这一点和SliverPersistentHeader
的floating
相似,但不同的是SliverPersistentHeader
没有snap
参数,当它的floating
为true
时,效果是等同于SliverAppBar
的floating
和snap
同时为true
时的效果。
我们可以看到 SliverAppBar
的一些参数和 SliverPersistentHeader
很像,这是因为 SliverAppBar
内部就包含了一个 SliverPersistentHeader
组件,用于实现顶部固定和漂浮效果。
下面我们看一个示例:
class SnapAppBar extends StatelessWidget { const SnapAppBar({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ // 实现 snap 效果 SliverAppBar( floating: true, snap: true, expandedHeight: 200, forceElevated: innerBoxIsScrolled, flexibleSpace: FlexibleSpaceBar( background: Image.asset("./imgs/sea.png",fit: BoxFit.cover,), ), ), ]; }, body: Builder(builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[ buildSliverList(100) ], ); }), ), ); }}
运行后效果:
当我们滑动到顶部时,然后反向轻微滑动一点点,这时 SliverAppBar
就会整体回到屏幕顶部,但这时有一个问题,注意图中红色圈出来的部分,我们发现 SliverAppBar
返回到屏幕后将 0 - 4
这几个列表项遮住了!而按照正常的交互逻辑,预期是不能遮住的,因为往下滑时,用户就是为了看上面的内容,SliverAppBar
突然整体回到屏幕后正好遮住了上面的内容,这时,用户不得不继续往下再滑动一些距离,这个体验很不好。
为了解决这个问题,能立马想到的思路就是当 SliverAppBar
在回到屏幕的过程中,底下的列表项也同时往下滑相应的偏移就 OK 了。但是我们要动手时发现了问题,因为无论是想监听 header
的滑动信息和控制 body
的滑动都需要用到内外部可滚动组件的 controller
,而 controller
的持有者是 NestedScrollView
的协调器,我们很难获取取,就算能获取(通过context
),那也是 NestedScrollView
的内部逻辑,我们不应在在外部去干涉,这样不符合职责分离模式,是有侵入性的 。 Flutter 的开发者也意识到了这点,于是提供了一个标准的解决方案,我们先看看如何解决,再解释,我们修改上面的代码:
class SnapAppBar extends StatelessWidget { const SnapAppBar({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( floating: true, snap: true, expandedHeight: 200, flexibleSpace: FlexibleSpaceBar( background: Image.asset("./imgs/sea.png",fit: BoxFit.cover,), ), forceElevated: innerBoxIsScrolled, ), ), ]; }, body: Builder(builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[ SliverOverlapInjector( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), ), buildSliverList(100) ], ); }), ), ); }}
需要注意的是和之前的代码相比有两个部分发生了变化:
-
SliverAppBar
用SliverOverlapAbsorber
包裹了起来,它的作用就是获取SliverAppBar
返回时遮住内部可滚动组件的部分的长度,这个长度就是overlap
(重叠) 的长度。 -
在
body
中往CustomScrollView
的Sliver
列表的最前面插入了一个SliverOverlapInjector
,它会将SliverOverlapAbsorber
中获取的overlap
长度应用到内部可滚动组件中。这样在SliverAppBar
返回时内部可滚动组件也会相应的同步滑动相应的距离。
SliverOverlapAbsorber
和 SliverOverlapInjector
都接收有一个 handle
,给它传入的是NestedScrollView.sliverOverlapAbsorberHandleFor(context)
。好家伙,名字一个比一个长!但不要被吓到, handle
就是 SliverOverlapAbsorber
和 SliverOverlapInjector
的通信桥梁,即传递 overlap
长度。
以上便是 NestedScrollView
提供的标准解决方案,可能直观上看起来不是很优雅,不过,幸运的是,这是一个标准方案,有需要直接复制代码即可。
实际上,当 snap
为 true
时,只需要给 SliverAppBar
包裹一个 SliverOverlapAbsorber
即可,而无需再给 CustomScrollView
添加 SliverOverlapInjector
,因为这种情况 SliverOverlapAbsorber
会自动吸收 overlap
,以调整自身的布局高度为 SliverAppBar
的实际高度,这样的话 header
的高度变化后就会自动将 body
向下撑(header
和 body
属于同一个 CustomScrollView
),同时,handle
中的 overlap
长度始终 0。而只有当 SliverAppBar
被 SliverOverlapAbsorber
包裹且为固定模式时(pinned
为 true
),CustomScrollView
中添加SliverOverlapInjector
才有意义, handle
中的 overlap
长度不为 0
。
注意:以上问题解决方式使用最新版本SDK经过模拟器实验仍然存在!由此猜想,同时设置floating: true
和 snap: true
的效果可能就是设计如此,在下拉时就是想header盖在列表之上,并不是bug。如果想要下拉时,header不挡住列表,最好是直接设置 pinned: true
,而不是使用 floating
和 snap
。例如:
class PinnedAppBar extends StatelessWidget { const PinnedAppBar({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( pinned: true, expandedHeight: 200, flexibleSpace: FlexibleSpaceBar( background: Image.asset("./images/timg4.jpg", fit: BoxFit.cover,), ), forceElevated: innerBoxIsScrolled, ), ]; }, body: Builder(builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[buildSliverList(100)], ); }), ), ); } // 构建固定高度的SliverList,count为列表项属相 Widget buildSliverList([int count = 5]) { return SliverFixedExtentList( itemExtent: 50, delegate: SliverChildBuilderDelegate( (context, index) { return ListTile(title: Text('$index')); }, childCount: count, ), ); }}
效果:
嵌套 TabBarView
我们实现一个商城主页,它有三个Tab,为了获得更大的商品显示空间,我们希望用户向上滑动时 导航栏能够滑出屏幕,当用户向下滑动时,导航栏能迅速回到屏幕,因为向下滑动时可能是用户想看之前的商品,也可能是用户向找到导航栏返回。
代码如下:
class NestedTabBarView extends StatelessWidget { const NestedTabBarView({Key? key}) : super(key: key); Widget build(BuildContext context) { final _tabs = <String>['猜你喜欢', '今日特价', '发现更多']; // 构建 tabBar return DefaultTabController( length: _tabs.length, // This is the number of tabs. child: Scaffold( body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( title: const Text('商城'), floating: true, snap: true, forceElevated: innerBoxIsScrolled, bottom: TabBar( tabs: _tabs.map((String name) => Tab(text: name)).toList(), ), ), ), ]; }, body: TabBarView( children: _tabs.map((String name) { return Builder( builder: (BuildContext context) { return CustomScrollView( key: PageStorageKey<String>(name), slivers: <Widget>[ SliverOverlapInjector( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), ), SliverPadding( padding: const EdgeInsets.all(8.0), sliver: buildSliverList(50), ), ], ); }, ); }).toList(), ), ), ), ); } // 构建固定高度的SliverList,count为列表项属相 Widget buildSliverList([int count = 5]) { return SliverFixedExtentList( itemExtent: 50, delegate: SliverChildBuilderDelegate((context, index) { return ListTile(title: Text('$index')); }, childCount: count, ), ); }}
效果:
ScrollPhysics
ScrollPhysics
并不是一个组件,它定义了可滚动组件的物理滚动特性。例如,当用户达到最大滚动范围时,是停止滚动,还是继续滚动。
滚动组件(CustomScrollView、ScrollView、GridView、ListView
等)的 physics
参数均表示此属性。
用法:
CustomScrollView(physics: AlwaysScrollableScrollPhysics()... )
系统提供的 ScrollPhysics
有:
AlwaysScrollableScrollPhysics
:总是可以滑动NeverScrollableScrollPhysics
:禁止滚动BouncingScrollPhysics
:内容超过一屏 上拉有回弹效果,iOS 系统中的效果ClampingScrollPhysics
:包裹内容,列表滑动到边界时将不能继续滑动,通常在Android 中 配合GlowingOverscrollIndicator
(实现微光效果的组件) 使用。FixedExtentScrollPhysics
:滚动条直接落在某一项上,而不是任何位置,类似于老虎机,只能在确定的内容上停止,而不能停在 2 个内容的中间,用于可滚动组件的FixedExtentScrollController
。PageScrollPhysics
:用于PageView
的滚动特性,停留在页面的边界
RefreshIndicator
RefreshIndicator
是 Material 风格的下拉刷新组件,下拉时顶部显示刷新图标,松手时会执行指定的异步方法,通常搭配可滚动组件一起使用。
属性 | 说明 |
---|---|
onRefresh | 下拉刷新的异步回调 |
displacement | 设置指示器到顶部或者底部到距离 |
color | 设置指示器图标颜色 |
backgroundColor | 设置指示器背景颜色 |
child | 设置子元素(可滚动组件) |
示例:
import 'package:flutter/material.dart';class CustomRefreshIndicator extends StatefulWidget { const CustomRefreshIndicator({Key? key}) : super(key: key); State createState() => _CustomRefreshIndicatorState();}class _CustomRefreshIndicatorState extends State<CustomRefreshIndicator> { final _list = [1, 2, 3, 4, 5]; Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("RefreshIndicator"), ), body: Stack( children: <Widget>[ RefreshIndicator( onRefresh: _increment, displacement: 20, color: Colors.orange, backgroundColor: Colors.white, child: ListView.builder( itemBuilder: (context, index) { return ListTile(title: Text('Item ${_list[index]}')); }, itemExtent: 50, itemCount: _list.length, // 不加这行在条目比较小的时候不能下拉,原因是RefreshIndicator子元素必须是可滚动的组件,这里设置为永远可以滚动 physics: const AlwaysScrollableScrollPhysics(), ) ) ], ), ); } Future<void> _increment() async { await Future.delayed(const Duration(seconds: 2), () { setState(() => _list.add(_list.length + 1)); }); }}
效果:
有两点需要注意:
-
RefreshIndicator
仅用于垂直可滚动组件。 -
最好指定
RefreshIndicator
的可滚动child的physics
为AlwaysScrollableScrollPhysics
,这是因为RefreshIndicator
默认只会在滚动内容大于viewport高度时才会显示,所以为了确保在列表项数量比较少的时候总是能够显示RefreshIndicator
,请添加下面这句:
ListView(physics: const AlwaysScrollableScrollPhysics(),// ...)
自定义 Sliver
Sliver 布局协议
Sliver
的布局协议如下:
Viewport
将当前布局和配置信息通过SliverConstraints
传递给Sliver
。Sliver
确定自身的位置、绘制等信息,保存在geometry
中(一个SliverGeometry
类型的对象)。Viewport
读取geometry
中的信息来对Sliver
进行布局和绘制。
可以看到,这个过程有两个重要的对象 SliverConstraints
和 SliverGeometry
,我们先看看 SliverConstraints
的定义:
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;}
可以看见 SliverConstraints
中包含的信息非常多。当列表滑动时,如果某个 Sliver
已经进入了需要构建的区域,则列表会将 SliverConstraints
信息传递给该 Sliver
,Sliver
就可以根据这些信息来确定自身的布局和绘制信息了。
Sliver
需要确定的是 SliverGeometry
:
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) //可以先进行修正,具体的作用在后面 SliverFlexibleHeader 示例中会介绍。 this.scrollOffsetCorrection, double? cacheExtent, // 在预渲染区域中占据的长度})
Sliver布局模型 VS 盒布局模型
两者布局流程基本相同:父组件告诉子组件约束信息 > 子组件根据父组件的约束确定自生大小 > 父组件获得子组件大小调整其位置。不同是:
- 父组件传递给子组件的约束信息不同。盒模型传递的是
BoxConstraints
,而Sliver
传递的是SliverConstraints
。 - 描述子组件布局信息的对象不同。盒模型的布局信息通过
Size
和offset
描述 ,而Sliver
的是通过SliverGeometry
描述。 - 布局的起点不同。
Sliver
布局的起点一般是Viewport
,而盒模型布局的起点可以是任意的组件。
SliverConstraints
和 SliverGeometry
属性比较多,只看它们的含义的话并不好理解,下面我们将通过两个例子,通过实践来理解。
自定义 SliverFlexibleHeader
1. SliverFlexibleHeader
我们实现一个类似旧版本微信朋友圈顶部头图的功能:即默认情况下顶部图片只显示一部分,当用户向下拽时图片的剩余部分会逐渐显示,如图所示。
我们的思路是实现一个 Sliver
,将它作为 CustomScrollView
的第一孩子,然后根据用户的滑动来动态调整 Sliver
的布局和显示。下面我们来实现一个 SliverFlexibleHeader
,它会结合 CustomScrollView
实现上述效果。我们先看一下页面的整体实现代码:
Widget build(BuildContext context) { return CustomScrollView( //为了能使CustomScrollView拉到顶部时还能继续往下拉,必须让 physics 支持弹性效果 physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), slivers: [ //我们需要实现的 SliverFlexibleHeader 组件 SliverFlexibleHeader( visibleExtent: 200,, // 初始状态在列表中占用的布局高度 // 为了能根据下拉状态变化来定制显示的布局,我们通过一个 builder 来动态构建布局。 builder: (context, availableHeight, direction) { return GestureDetector( onTap: () => print('tap'), //测试是否可以响应事件 child: Image( image: AssetImage("imgs/avatar.png"), width: 50.0, height: availableHeight, alignment: Alignment.bottomCenter, fit: BoxFit.cover, ), ); }, ), // 构建一个list buildSliverList(30), ], );}
接下来,我们的重点是实现 SliverFlexibleHeader
,由于涉及到 Sliver
布局,通过现有组件很难组合实现我们想要的功能,所以我们通过定制 RenderObject
的方式来实现它。为了能根据下拉位置的变化来动态调整,SliverFlexibleHeader
中我们通过一个 builder
来动态构建布局,当下拉位置发生变化时,builder
就会被回调。
为了清晰起见,我们先实现一个接收固定 widget
的 _SliverFlexibleHeader
组件,组件定义代码如下:
class _SliverFlexibleHeader extends SingleChildRenderObjectWidget { const _SliverFlexibleHeader({ Key? key, required Widget child, this.visibleExtent = 0, }) : super(key: key, child: child); final double visibleExtent; RenderObject createRenderObject(BuildContext context) { return _FlexibleHeaderRenderSliver(visibleExtent); } void updateRenderObject(BuildContext context, _FlexibleHeaderRenderSliver renderObject) { renderObject..visibleExtent = visibleExtent; }}
这里我们继承的既不是 StatelessWidget
,也不是 StatefulWidget
,这是因为这两个组件主要的作用是组合 Widget
,而我们要自定义 RenderObject
,则需要继承 RenderObjectWidget
,考虑到_SliverFlexibleHeader
有一个子节点,我们可以直接继承 SingleChildRenderObjectWidget
类,这样我们可以省去一些和布局无关的代码,比如绘制和事件的点击测试,这些功能 SingleChildRenderObjectWidget
中已经帮我们处理了。
下面我们实现 _FlexibleHeaderRenderSliver
,核心代码就在 performLayout
中,可参考注释:
class _FlexibleHeaderRenderSliver extends RenderSliverSingleBoxAdapter { _FlexibleHeaderRenderSliver(double visibleExtent) : _visibleExtent = visibleExtent; double _lastOverScroll = 0; double _lastScrollOffset = 0; late double _visibleExtent = 0; set visibleExtent(double value) { // 可视长度发生变化,更新状态并重新布局 if (_visibleExtent != value) { _lastOverScroll = 0; _visibleExtent = value; markNeedsLayout(); } } void performLayout() { // 滑动距离大于_visibleExtent时则表示子节点已经在屏幕之外了 if (child == null || (constraints.scrollOffset > _visibleExtent)) { geometry = SliverGeometry(scrollExtent: _visibleExtent); return; } // 测试overlap,下拉过程中overlap会一直变化. double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0; var scrollOffset = constraints.scrollOffset; // 在Viewport中顶部的可视空间为该 Sliver 可绘制的最大区域。 // 1. 如果Sliver已经滑出可视区域则 constraints.scrollOffset 会大于 _visibleExtent, // 这种情况我们在一开始就判断过了。 // 2. 如果我们下拉超出了边界,此时 overScroll>0,scrollOffset 值为0,所以最终的绘制区域为 // _visibleExtent + overScroll. double paintExtent = _visibleExtent + overScroll - constraints.scrollOffset; // 绘制高度不超过最大可绘制空间 paintExtent = min(paintExtent, constraints.remainingPaintExtent); //对子组件进行布局,关于 layout 详细过程我们将在本书后面布局原理相关章节详细介绍,现在只需知道 //子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints) child!.layout( constraints.asBoxConstraints(maxExtent: paintExtent), parentUsesSize: false, ); //最大为_visibleExtent,最小为 0 double layoutExtent = min(_visibleExtent, paintExtent); //设置geometry,Viewport 在布局时会用到 geometry = SliverGeometry( scrollExtent: layoutExtent, paintOrigin: -overScroll, paintExtent: paintExtent, maxPaintExtent: paintExtent, layoutExtent: layoutExtent, ); }}
在 performLayout
中我们通过 Viewport
传来的 SliverConstraints
结合子组件的高度,最终确定了 _SliverFlexibleHeader
的布局、绘制等相关信息,它们被保存在了 geometry
中,之后,Viewport
就可以读取 geometry
来确定 _SliverFlexibleHeader
在 Viewport
中的位置,然后进行绘制。可以手动修改 SliverGeometry
的各个属性,看看效果,这样可以加深理解。
现在还剩最后一个问题,_SliverFlexibleHeader
接收的是一个固定的 widget
,我们如何在下拉位置发生变化时来重新构建 widget
呢?上面代码中,我们在 _SliverFlexibleHeader
的 performLayout
方法中,每当下拉位置发生变化,我们都会对其子组件重新进行 layout。那既然如此,我们可以创建一个 LayoutBuilder
用于在子组件重新布局时来动态构建 child
。思路有了,那么实现很简单,我们看看最终的 SliverFlexibleHeader
实现:
typedef SliverFlexibleHeaderBuilder = Widget Function( BuildContext context, double maxExtent, //ScrollDirection direction,);class SliverFlexibleHeader extends StatelessWidget { const SliverFlexibleHeader({ Key? key, this.visibleExtent = 0, required this.builder, }) : super(key: key); final SliverFlexibleHeaderBuilder builder; final double visibleExtent; Widget build(BuildContext context) { return _SliverFlexibleHeader( visibleExtent: visibleExtent, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return builder( context, constraints.maxHeight ); }, ), ); }}
当_SliverFlexibleHeader
中每次对子组件进行布局时,都会触发 LayoutBuilder
来重新构建子 widget
,LayoutBuilder
中收到的 constraints
就是 _SliverFlexibleHeader
中对子组件进行布局时 传入的 constraints
,即:
...child!.layout( //对子组件进行布局 constraints.asBoxConstraints(maxExtent: paintExtent), parentUsesSize: true,);...
2. 传递额外的布局信息
在实际使用 SliverFlexibleHeader
时,我们有时在构建子 widget
时可能会依赖当前列表的滑动方向,当然我们可以在 SliverFlexibleHeader
的 builder
中记录前后的 availableHeight
的差来确定滑动方向,但是这样比较麻烦,需要使用者来手动处理。我们知道在滑动时,Sliver
的 SliverConstraints
中已经包含了 userScrollDirection
,如果我们能将它经过统一的处理然后透传给 LayoutBuilder
的话就非常好了,这样就不需要开发者在使用时自己维护滑动方向了!按照这个思路我们来实现一下。
首先我们遇到了第一个问题: LayoutBuilder
接收的参数我们没法指定。为此有两种方案:
- 我们知道在上面的场景中,在对子组件进行布局时我们传给子组件的约束只使用了最大长度,最小长度是没有用到的,那么我们可以将滑动方向通过最小长度传递给
LayoutBuilder
,然后再LayoutBuilder
中取出即可。 - 定义一个新类,让它继承自
BoxConstraints
,然后再添加一个可以保存scrollDirection
的属性。
这两种方案都能成功,那应该使用哪种方案呢?建议使用方案 2 ,因为方案 1 有一个副作用就是会影响子组件布局。我们知道 LayoutBuilder
是在子组件 build
阶段执行的,当我们设置了最小长度后,我们虽然在 build
阶段没有用到它,但是在子组件在布局阶段仍然会应用此约束,所以最终还会影响子组件的布局。
下面我们按照方案 2 来实现:定义一个 ExtraInfoBoxConstraints
类,它可以携带约束之外的信息,为了尽可能通用,我们使用泛型:
class ExtraInfoBoxConstraints<T> extends BoxConstraints { ExtraInfoBoxConstraints( this.extra, BoxConstraints constraints, ) : super( minWidth: constraints.minWidth, minHeight: constraints.minHeight, maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, ); // 额外的信息 final T extra; bool operator ==(Object other) { if (identical(this, other)) return true; return other is ExtraInfoBoxConstraints && super == other && other.extra == extra; } int get hashCode { return hashValues(super.hashCode, extra); }}
上面代码比较简单,要说明的是我们重载了“==
”运算符,这是因为 Flutter 在布局期间在特定的情况下会检测前后两次 constraints
是否相等然后来决定是否需要重新布局,所以我们需要重载“==
”运算符,否则可能会在最大/最小宽高不变但 extra
发生变化时不会触发 child
重新布局,这时也就不会触发 LayoutBuilder
,这明显不符合预期,因为我们希望 extra
发生变化时,会触发 LayoutBuilder
重新构建 child
。
首先我们修改 __FlexibleHeaderRenderSliver
的 performLayout
方法:
... //对子组件进行布局,子组件通过 LayoutBuilder可以拿到这里我们传递的约束对象(ExtraInfoBoxConstraints) child!.layout( ExtraInfoBoxConstraints( direction, //传递滑动方向 constraints.asBoxConstraints(maxExtent: paintExtent), ), parentUsesSize: false,);...
然后修改 SliverFlexibleHeader
实现,在 LayoutBuilder
中就可以获取到滑动方向:
typedef SliverFlexibleHeaderBuilder = Widget Function( BuildContext context, double maxExtent, ScrollDirection direction,);class SliverFlexibleHeader extends StatelessWidget { const SliverFlexibleHeader({ Key? key, this.visibleExtent = 0, required this.builder, }) : super(key: key); final SliverFlexibleHeaderBuilder builder; final double visibleExtent; Widget build(BuildContext context) { return _SliverFlexibleHeader( visibleExtent: visibleExtent, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return builder( context, constraints.maxHeight, // 获取滑动方向 (constraints as ExtraInfoBoxConstraints<ScrollDirection>).extra, ); }, ), ); }}
最后我们看一下 SliverFlexibleHeader
中确定滑动方向的逻辑:
// 下拉过程中overlap会一直变化.double overScroll = constraints.overlap < 0 ? constraints.overlap.abs() : 0;var scrollOffset = constraints.scrollOffset;_direction = ScrollDirection.idle;// 根据前后的overScroll值之差确定列表滑动方向。注意,不能直接使用 constraints.userScrollDirection,// 这是因为该参数只表示用户滑动操作的方向。比如当我们下拉超出边界时,然后松手,此时列表会弹回,即列表滚动// 方向是向上,而此时用户操作已经结束,ScrollDirection 的方向是上一次的用户滑动方向(向下),这是便有问题。var distance = overScroll > 0 ? overScroll - _lastOverScroll : _lastScrollOffset - scrollOffset;_lastOverScroll = overScroll;_lastScrollOffset = scrollOffset;if (constraints.userScrollDirection == ScrollDirection.idle) { _direction = ScrollDirection.idle; _lastOverScroll = 0;} else if (distance > 0) { _direction = ScrollDirection.forward;} else if (distance < 0) { _direction = ScrollDirection.reverse;}
3. 高度修正 scrollOffsetCorrection
如果 visibleExtent
变化时,我们看看效果:
可以看到有一个突兀地跳动,这是因为 visibleExtent
变化时会导致 layoutExtent
发生变化,也就是说 SliverFlexibleHeader
在屏幕中所占的布局高度会发生变化,所以列表就出现跳动。但这个跳动效果太突兀了,我们知道每一个 Sliver
的高度是通过 scrollExtent
属性预估出来的,因此我们需要修正一下 scrollExtent
,但是我们不能直接修改 scrollExtent
的值,直接修改不会有任何动画效果,仍然会跳动,为此,SliverGeometry
提供了一个 scrollOffsetCorrection
属性,它专门用于修正 scrollExtent
,我们只需要将要修正差值传给scrollOffsetCorrection
,然后 Sliver
会自动执行一个动画效果过渡到我们期望的高度。
// 是否需要修正scrollOffset。当_visibleExtent值更新后,为了防止 // 视觉上突然地跳动,要先修正 scrollOffset。 double? _scrollOffsetCorrection; set visibleExtent(double value) { // 可视长度发生变化,更新状态并重新布局 if (_visibleExtent != value) { _lastOverScroll = 0; _reported = false; // 计算修正值 _scrollOffsetCorrection = value - _visibleExtent; _visibleExtent = value; markNeedsLayout(); } } void performLayout() { // _visibleExtent 值更新后,为了防止突然的跳动,先修正 scrollOffset if (_scrollOffsetCorrection != null) { geometry = SliverGeometry( //修正 scrollOffsetCorrection: _scrollOffsetCorrection, ); _scrollOffsetCorrection = null; return; } ... }
运行后效果:
4. 边界
在 SliverFlexibleHeader
构建子组件时开发者可能会依赖“当前的可用高度是否为0”来做一些特殊处理,比如记录是否子组件已经离开了屏幕。但是根据上面的实现,当用户滑动非常快时,子组件离开屏幕时的最后一次布局时传递的约束的 maxExtent
可能不为 0
,而当 constraints.scrollOffset
大于 _visibleExtent
时我们在 performLayout
的一开始就返回了,因此 LayoutBuilder
的 builder
中就有可能收不到 maxExtent
为 0
时的回调。为了解决这个问题,我们只需要在每次 Sliver
离开屏幕时调用一次 child.layout
同时 将maxExtent
指定为 0
即可,为此我们修改一下:
void performLayout() { if (child == null) { geometry = SliverGeometry(scrollExtent: _visibleExtent); return; } //当已经完全滑出屏幕时 if (constraints.scrollOffset > _visibleExtent) { geometry = SliverGeometry(scrollExtent: _visibleExtent); // 通知 child 重新布局,注意,通知一次即可,如果不通知,滑出屏幕后,child 在最后 // 一次构建时拿到的可用高度可能不为 0。因为使用者在构建子节点的时候,可能会依赖 // "当前的可用高度是否为0" 来做一些特殊处理,比如记录是否子节点已经离开了屏幕, // 因此,我们需要在离开屏幕时确保LayoutBuilder的builder会被调用一次(构建子组件)。 if (!_reported) { _reported = true; child!.layout( ExtraInfoBoxConstraints( _direction, //传递滑动方向 constraints.asBoxConstraints(maxExtent: 0), ), //我们不会使用自节点的 Size, 关于此参数更详细的内容见本书后面关于layout原理的介绍 parentUsesSize: false, ); } return; } //子组件回到了屏幕中,重置通知状态 _reported = false; ...}
至此大功告成!完整源码查看这里:sliver_flexible_header
自定义 SliverPersistentHeaderToBox
我们在上面介绍了 SliverPersistentHeader
,在使用时需要遵守两个规则 :
-
必须显式指定高度。
-
如果我们在使用
SliverPersistentHeader
构建子组件时需要依赖overlapsContent
参数,则必须保证之前至少还有一个SliverPersistentHeader
或SliverAppBar
。
遵守上面这两条规则对于开发者来说心智负担还是较重的,比如对于规则 1,大多数时候我们是不知道 Header
具体的高度的,我们期望直接传一个 widget
,这个 widget
的实际高度 SliverPersistentHeader
能自动算出来。对于规则 2 就更不用说,不知道这个准是要踩坑的。
综上,本节我们自定义一个 SliverPersistentHeaderToBox
,它可以将任意 RenderBox
适配为可以固定到顶部的 Sliver
而不用显式指定高度,同时避免上面的问题 2。
第一步:我们先看一下定义 SliverPersistentHeaderToBox
。
typedef SliverPersistentHeaderToBoxBuilder = Widget Function( BuildContext context, double maxExtent, //当前可用最大高度 bool fixed, // 是否已经固定);class SliverPersistentHeaderToBox extends StatelessWidget { // 默认构造函数,直接接受一个 widget,不用显式指定高度 SliverPersistentHeaderToBox({ Key? key, required Widget child, }) : builder = ((a, b, c) => child), super(key: key); // builder 构造函数,需要传一个 builder,同样不需要显式指定高度 SliverPersistentHeaderToBox.builder({ Key? key, required this.builder, }) : super(key: key); final SliverPersistentHeaderToBoxBuilder builder; Widget build(BuildContext context) { return _SliverPersistentHeaderToBox( // 通过 LayoutBuilder接收 Sliver 传递给子组件的布局约束信息 child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return builder( context, constraints.maxHeight, //约束中需要传递的额外信息是一个bool类型,表示 Sliver 是否已经固定到顶部 (constraints as ExtraInfoBoxConstraints<bool>).extra, ); }, ), ); }}
和上面的 SliverFlexibleHeader
很像,不同的是SliverPersistentHeaderToBox
传递给 child
的约束中的额外信息是一个 bool
类型,表示是否已经固定到顶部。
第二步:实现 _SliverPersistentHeaderToBox
。
class _RenderSliverPersistentHeaderToBox extends RenderSliverSingleBoxAdapter { void performLayout() { if (child == null) { geometry = SliverGeometry.zero; return; } child!.layout( ExtraInfoBoxConstraints( //只要 constraints.scrollOffset不为0,则表示已经有内容在当前Sliver下面了,即已经固定到顶部了 constraints.scrollOffset != 0, constraints.asBoxConstraints( // 我们将剩余的可绘制空间作为 header 的最大高度约束传递给 LayoutBuilder maxExtent: constraints.remainingPaintExtent, ), ), //我们要根据child大小来确定Sliver大小,所以后面需要用到child的大小(size)信息 parentUsesSize: true, ); // 子节点 layout 后就能获取它的大小了 double childExtent; switch (constraints.axis) { case Axis.horizontal: childExtent = child!.size.width; break; case Axis.vertical: childExtent = child!.size.height; break; } geometry = SliverGeometry( scrollExtent: childExtent, paintOrigin: 0, // 固定,如果不想固定应该传` - constraints.scrollOffset` paintExtent: childExtent, maxPaintExtent: childExtent, ); } // 重要,必须重写,下面介绍。 double childMainAxisPosition(RenderBox child) => 0.0;}
上面代码有四点需要注意:
constraints.scrollOffset
不为0
时,则表示已经固定到顶部了。- 我们在布局阶段拿到子组件的
size
信息,然后通过通过子组件的大小来确定Sliver
大小(设置geometry
)。 这样就不再需要我们显式传高度值了。 - 我们通过给
paintOrigin
设为0
来实现顶部固定效果;不固定到顶部时应该传- constraints.scrollOffset
,这个可以通过运行示例修改一下参数值查看效果来理解。 - 必须要重写
childMainAxisPosition
,否则事件便会失效,该方法的返回值在“点击测试”中会用到。该函数应该返回paintOrigin
的位置。
大功告成!下面我们来测试一下!我们创建两个 header
:
- 第一个 header:当没有滑动到顶部时,外观和正常列表项一样;当固定到顶部后,显示一个阴影。为了实现这个效果我们需要通过
SliverPersistentHeaderToBox.builder
来动态创建。 - 第二个 header: 一个普通的列表项,它接受一个 widget。
class SliverPersistentHeaderToBoxRoute extends StatelessWidget { const SliverPersistentHeaderToBoxRoute({Key? key}) : super(key: key); Widget build(BuildContext context) { return CustomScrollView( slivers: [ buildSliverList(5), SliverPersistentHeaderToBox.builder(builder: headerBuilder), buildSliverList(5), SliverPersistentHeaderToBox(child: wTitle('Title 2')), buildSliverList(50), ], ); } // 当 header 固定后显示阴影 Widget headerBuilder(context, maxExtent, fixed) { // 获取当前应用主题,关于主题相关内容将在后面章节介绍,现在 // 我们要从主题中获取一些颜色。 var theme = Theme.of(context); return Material( child: Container( color: fixed ? Colors.white : theme.canvasColor, child: wTitle('Title 1'), ), elevation: fixed ? 4 : 0, shadowColor: theme.appBarTheme.shadowColor, ); } // 我们约定小写字母 w 开头的函数代表是需要构建一个 Widget,这比 buildXX 会更简洁 Widget wTitle(String text) => ListTile(title: Text(text), onTap: () => print(text));}
运行效果:
我们实现的 SliverPersistentHeaderToBox
不仅不需要显式指定高度,而且它的 builder
函数的第三个参数值也正常了(和SliverPersistentHeaderToBox
数量无关)。
完整源码查看这里:sliver_persistent_header_to_box
注意:
- 如果我们要使用
SliverAppBar
,则建议搭配SliverPersistentHeader
使用,因为SliverPersistentHeader
设计的初衷就是为了实现SliverAppBar
,所以它们一起使用时会有更好的协同。如果将SliverPersistentHeaderToBox
和SliverAppBar
一起使用,则可能又会导致其他问题,所以建议就是:在没有使用SliverAppBar
时,用SliverPersistentHeaderToBox
,如果使用了SliverAppBar
,就用SliverPersistentHeader
。
来源地址:https://blog.csdn.net/lyabc123456/article/details/130845923