05.Flutter可滚动的Widget
可滚动组件介绍
Sliver 布局模型
Flutter 有两种布局模型:
- 基于 RenderBox 的盒模型布局。
- 基于 Sliver ( RenderSliver ) 按需加载列表布局。
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 关键属性:
1
2
3
4
5
6
7
Scrollable({
// ...
this.axisDirection = AxisDirection.down,
this.controller,
this.physics,
required this.viewportBuilder,
})
- axisDirection 滚动方向。
- controller ScrollController 的主要作用是控制滚动位置和监听滚动事件。默认情况下,Widget 树中会有一个默认的 PrimaryScrollController,如果子树中的可滚动组件没有显式的指定 controller,并且 primary 属性值为 true 时(默认就为 true),可滚动组件会使用这个默认的 PrimaryScrollController。这种机制带来的好处是父组件可以控制子树中可滚动组件的滚动行为,例如,Scaffold 正是使用这种机制在 iOS 中实现了点击导航栏回到顶部的功能
- physics ScrollPhysics,它决定可滚动组件如何响应用户操作。比如用户滑动完抬起手指后,继续执行动画;或者滑动到边界时,如何显示。默认情况下,Flutter 会根据具体平台分别使用不同的 ScrollPhysics 对象,应用不同的显示效果,如当滑动到边界时,继续拖动的话,在 iOS 上会出现弹性效果,而在 Android 上会出现微光效果。如果你想在所有平台下使用同一种效果,可以显式指定一个固定的 ScrollPhysics,Flutter SDK 中包含了两个 ScrollPhysics 的子类,可以直接使用:
- ClampingScrollPhysics:列表滑动到边界时将不能继续滑动,通常在 Android 中 配合 GlowingOverscrollIndicator(实现微光效果的组件) 使用。
- BouncingScrollPhysics:iOS 下弹性效果。
- viewportBuilder 构建 Viewport 的回调。当用户滑动时,Scrollable 会调用此回调构建新的 Viewport,同时传递一个 ViewportOffset 类型的 offset 参数,该参数描述 Viewport 应该显示那一部分内容。注意重新构建 Viewport 并不是一个昂贵的操作,因为 Viewport 本身也是 Widget,只是配置信息,Viewport 变化时对应的 RenderViewport 会更新信息,并不会随着 Widget 进行重新构建。
主轴和纵轴
在可滚动组件的坐标描述中,通常将滚动方向称为主轴,非滚动方向称为纵轴。由于可滚动组件的默认方向一般都是沿垂直方向,所以默认情况下主轴就是指垂直方向,水平方向同理
Viewport
Viewport 比较简单,用于渲染当前视口中需要显示 Sliver。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Viewport({
Key? key,
this.axisDirection = AxisDirection.down,
this.crossAxisDirection,
this.anchor = 0.0,
required ViewportOffset offset, // 用户的滚动偏移
// 类型为Key,表示从什么地方开始绘制,默认是第一个元素
this.center,
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 的值为预渲染区域的具体像素长度;当值为 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,这些属性我们可以认为是可滚动组件的通用属性。
- scrollDirection: 列表的滚动方向,一个是横向滚动,一个是纵向滚动。默认的就是垂直滚动,所以如果是垂直滚动,我们一般都不进行设置
- Axis.horizontal: 横向滚动或者叫水平方向滚动
- Axis.vertical: 纵向滚动或者叫垂直方向滚动
- reverse: 表示是否按照阅读方向相反的方向滑动,如:scrollDirection 值为 Axis.horizontal 时,即滑动方向为水平,如果阅读方向是从左到右(取决于语言环境,阿拉伯语就是从右到左)。reverse 为 true 时,那么滑动方向就是从右往左。
- controller:SrollController 控制器,与列表滚动相关,比如监听列表的滚动事件;我们多用于上拉加载更多,通过监听滑动的距离来执行操作。可滚动组件都有一个 controller 属性,通过该属性我们可以指定一个 ScrollController 来控制可滚动组件的滚动,比如可以通过 ScrollController 来同步多个组件的滑动联动。ScrollController 是需要结合可滚动组件一起工作
- physics:列表滚动至边缘后继续拖动的物理效果
Android 与 iOS 效果不同。 Android 会呈现出一个波纹状(对应ClampingScrollPhysics), 而 iOS 上有一个回弹的弹性效果(对应BouncingScrollPhysics)。 如果你想不同的平台上呈现各自的效果可以使用AlwaysScrollableScrollPhysics, 它会根据不同平台自动选用各自的物理效果。如果你想禁用在边缘的拖动效果, 那可以使用NeverScrollableScrollPhysics
- **cacheExtent **设置预加载的区域
设置预加载的区域 cacheExtent 强制设置为了 0.0,从而关闭了 “ 预加载 “
子节点缓存
为了方便控制子组件在滑出可视区域后是否缓存,可滚动组件提供了一种缓存子节点的通用解决方案,它允许开发者对特定的子界限进行缓存。
如 ListView 的 Header 一般要进行缓存
Scrollbar
Scrollbar 是一个 Material 风格的滚动指示器(滚动条),如果要给可滚动组件添加滚动条,只需将 Scrollbar 作为可滚动组件的任意一个父级组件即可。
1
2
3
4
5
Scrollbar(
child: SingleChildScrollView(
...
),
);
Scrollbar 和 CupertinoScrollbar 都是通过监听滚动通知来确定滚动条位置的。
CupertinoScrollbar 是 iOS 风格的滚动条,如果你使用的是 Scrollbar,那么在 iOS 平台它会自动切换为 CupertinoScrollbar。
SingleChildScrollView
SingleChildScrollView 类似于 Android 中的 ScrollView,它只能接收一个子组件,不支持懒加载,性能较差。SingleChildScrollView 定义:
1
2
3
4
5
6
7
8
9
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。
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SingleChildScrollViewTestRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
return Scrollbar( // 显示进度条
child: SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Center(
child: Column(
//动态创建一个List<Widget>
children: str.split("")
//每一个字母都用一个Text显示,字体为原来的两倍
.map((c) => Text(c, textScaleFactor: 2.0,))
.toList(),
),
),
),
);
}
}
ListView
ListView 是最常用的可滚动组件之一,它可以沿一个方向线性排布所有子组件,并且它也支持列表项懒加载(在需要时才会创建)。
默认构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ListView({
// ...
// 可滚动widget公共参数
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
EdgeInsetsGeometry? padding,
// ListView各个构造函数的共同参数
double? itemExtent,
Widget? prototypeItem, // 列表项原型,后面解释
bool shrinkWrap = false,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
double? cacheExtent, // 预渲染区域长度
// 子widget列表
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 的长度)。 当 ListView 嵌在一个无限长的容器组件中时, shrinkWrap 必须为 true,否则 Flutter 会给出警告
child 高度会适配 item 填充的内容的高度,我们非常的不希望 child 的高度固定,因为这样的话,如果里面的内容超出就会造成布局的溢出。 shrinkWrap 多用于嵌套 listView 中 内容大小不确定 比如 垂直布局中 先后放入文字 listView (需要 Expend 包裹否则无法显示无穷大高度 但是需要确定 listview 高度 shrinkWrap 使用内容适配不会有这样的影响)
- addAutomaticKeepAlives
- addRepaintBoundaries 该属性表示是否将列表项(子组件)包裹在 RepaintBoundary 组件中。RepaintBoundary 理解为它是一个 “ 绘制边界 “,将列表项包裹在 RepaintBoundary 中可以避免列表项不必要的重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加 RepaintBoundary 反而会更高效。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该指定为 false。
- padding:列表的内边距
- children:容纳子元素的组件数组;适合只有少量的子组件数量已知且比较少的情况,反之用 ListView.builder 按需动态构建列表项。
注意:虽然这种方式将所有 children 一次性传递给 ListView,但子组件)仍然是在需要时才会加载(build(如有)、布局、绘制),也就是说通过默认构造函数构建的 ListView 也是基于 Sliver 的列表懒加载模型。
- primary: false,如果内容不足,则用户无法滚动 而如果 [primary] 为 true,controller 滑动监听就不能使用了。
ListView.builder()
ListView.builder 适合列表项比较多或者列表项不确定的情况:
1
2
3
4
5
6
7
ListView.builder({
// ListView公共参数已省略
// ...
required IndexedWidgetBuilder itemBuilder,
int itemCount,
// ...
})
- itemCount:指定被循环数组的长度
- itemBuilder:它是列表项的构建器,类型为 IndexedWidgetBuilder,返回值为一个 widget。当列表滚动到具体的 index 位置时,会调用该构建器构建列表项
- 其他属性同 ListView 默认构造函数里的属性
1
2
3
4
5
6
7
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
参数,该参数是一个分割组件生成器。
案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static ListView list_divider(BuildContext context) {
return ListView.separated(
separatorBuilder: (context, index) {
//和itemBuilder 同级别的执行
if (index == 2) {
return Container(
height: 40.0,
color: Colors.red,
child: const Center(
child: Text("类型1"),
),
);
} else if (index == 7) {
return Container(
height: 40.0,
color: Colors.blue,
child: const Center(
child: Text("类型2"),
),
);
} else if (index == 14) {
return Container(
height: 40.0,
color: Colors.yellow,
child: const Center(
child: Text("类型3"),
),
);
} else {
return Container();
}
},
shrinkWrap: true,
primary: true,
physics: const BouncingScrollPhysics(),
//是否根据子组件的总长度来设置ListView的长度,默认值为false
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
// ListView的内边距 const EdgeInsets.all(30),
scrollDirection: Axis.vertical,
//沿竖直方向上布局
itemBuilder: (context, index) {
return Text("ListView item $index");
},
itemCount: 100);
}
固定高度列表
给列表指定 itemExtent 或 prototypeItem 会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtent
或 prototypeItem
。
- 知道具体高度用 itemExtent
- 不知道具体高度用 prototypeItem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FixedExtentList extends StatelessWidget {
const FixedExtentList({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView.builder(
prototypeItem: ListTile(title: Text("1")),
//itemExtent: 56,
itemBuilder: (context, index) {
//LayoutLogPrint是一个自定义组件,在布局时可以打印当前上下文中父组件给子组件的约束信息
return ListTile(title: Text("$index"));
},
);
}
}
- 因为列表项都是一个 ListTile,高度相同,但是我们不知道 ListTile 的高度是多少,所以指定了 prototypeItem
- 如果知道 ListTile 的高度是 56 ,所以我们指定 itemExtent 为 56 也是可以的
其他
ListTile Flutter 内置的 itemView
1
2
3
4
5
6
7
8
9
10
11
this.leading, // 内容的==>前置图标
this.title, // 内容的==>标题
this.subtitle, // 内容的==>副标题
this.trailing, // 内容的==>后置图标
this.isThreeLine = false, // 内容的==>是否三行显示
this.dense, // 内容的==>直观感受是整体大小
this.contentPadding, // 内容的==>内容内边距
this.enabled = true, // 内容 是否禁用
this.onTap, // item onTap 点击事件
this.onLongPress, // item onLongPress 长按事件
this.selected = false, // item 是否选中状态
ListView children 与 ListView.builder 的区别
- 通过 children 参数的形式接受的子组件列表。 这种方式需要将所有的 children 都提前创建好; 因此需要提前做大量的工作; 所以: 这种形式只适合少量的子组件的情况
- istView.builder 适合列表项比较多(或者无限)的情况, 只有当子组件真正显示的时候列表才会被创建, 也就说通过该构造函数创建的 ListView 是支持基于 Sliver 的懒加载模型的。
ListView 案例
新闻列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class ListViewDemo {
static ListView list3(BuildContext context) {
return ListView(scrollDirection: Axis.vertical, children: const <Widget>[
ListTile(
// 主标题
title: Text('Flutter 由 Google 的工程师团队打造,用于创建高性能、跨平台的移动应用',
//文字左对齐
textAlign: TextAlign.left,
//超出显示省略号
overflow: TextOverflow.ellipsis,
style: TextStyle(
//数字必须是Double类型的
fontSize: 20.0,
// 设置字体的颜色
color: Color.fromARGB(200, 100, 100, 8))),
// 副标题
subtitle: Text('你好flutter'),
),
ListTile(
title: Text('Flutter 由 Google 的工程师团队打造,用于创建高性能、跨平台的移动应用'),
subtitle: Text('你好flutter'),
),
ListTile(
// 主标题
// 在前面设置图标
leading: Icon(
//设置图标类型
Icons.settings,
//0x后面开始 两位FF表示透明度16进制,
color: Color(0xFFFFB6C1),
//这是图标的大小
size: 30.0),
// 在后面设置图标
trailing: Icon(Icons.accessible),
title: Text('flutter教程_2021 Dart Flutter入门实战视频教程132讲',
//文字左对齐
textAlign: TextAlign.left,
//超出显示省略号
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.0, //数字必须是Double类型的
// 设置字体的颜色
color: Color(0xFFFFB6C1))),
subtitle: Text('不管是Ios还是Android开发都可以在flutter官网上查到安装及使用步骤,这里我就不累述太多'),
),
ListTile(
// 主标题
// 通过leading可以将图片放在前面
leading: Image(
image: NetworkImage("https://10.url.cn/eth/ajNVdqHZLLB2ibIiaR23jaQpq0rTL1eXfBDkQzHc15ZH2qbl5Tn7A6HMnGfCfU3nDSqHHEuh8Lw7I/"),
fit: BoxFit.scaleDown,
color: Colors.blueGrey,
colorBlendMode: BlendMode.multiply,
),
title: Text('flutter教程_2021 Dart Flutter入门实战视频教程132讲',
textAlign: TextAlign.left, //文字左对齐
overflow: TextOverflow.ellipsis, //超出显示省略号
style: TextStyle(
fontSize: 20.0, //数字必须是Double类型的
// 设置字体的颜色
color: Color(0xFFFFB6C1))),
subtitle: Text('不管是Ios还是Android开发都可以在flutter官网上查到安装及使用步骤,这里我就不累述太多'),
),
]);
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demos',
home: Scaffold(
appBar: AppBar(
title: const Text("Flutter Demos"),
),
// body: list(context),
body: Container(
color: const Color.fromARGB(220, 135, 167, 11), child:ListViewDemo.list3(context)),
),
);
}
}
ListView.builder 构建动态列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import 'package:flutter/material.dart';
void main() => runApp(MyApp(
items: List<String>.generate(1000, (i) => "Item $i"),
));
class MyApp extends StatelessWidget {
final List<String> items;
const MyApp({Key key, this.items}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "ListView Widget",
home: Scaffold(
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text('$items[index]'),
);
},
),
),
);
}
}
案例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
typedef OnItemClickListener = void Function(int position, ItemBean itemBean);
class ItemWidget extends StatefulWidget {
final int position;
final ItemBean itemBean;
final OnItemClickListener? onItemClickListener;
const ItemWidget(this.position, this.itemBean, this.onItemClickListener,
{super.key});
@override
ItemWidgetState createState() => ItemWidgetState();
}
class ItemWidgetState extends State<ItemWidget> {
Color _color = Colors.white;
@override
void initState() {
super.initState();
_color = Colors.white;
}
@override
Widget build(BuildContext context) {
Column column = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: 8,
),
Text(widget.itemBean.title),
const SizedBox(
height: 6,
),
Text(widget.itemBean.description),
const SizedBox(
height: 8,
),
const Divider(
color: Colors.grey,
height: 0.5,
),
],
);
var container = Container(
color: _color,
padding: const EdgeInsets.only(left: 16.0),
child: column,
);
return GestureDetector(
child: container,
onTap: () {
print('onTap ${widget.position} ${widget.itemBean.title}');
widget.onItemClickListener?.call(widget.position, widget.itemBean);
},
onTapDown: (_) => _updatePressedColor(),
onTapUp: (_) => _updateNormalColor(),
onTapCancel: () => _updateNormalColor(),
);
}
void _updateNormalColor() {
Future.delayed(const Duration(milliseconds: 100), () {
// 解决快速点击没有效果的问题
setState(() {
_color = Colors.white;
});
});
}
void _updatePressedColor() {
setState(() {
_color = const Color(0xFFF0F1F2);
});
}
}
class ListViewWidget extends StatefulWidget {
final OnItemClickListener listener;
const ListViewWidget(this.listener, {super.key});
@override
ListViewWidgetState createState() => ListViewWidgetState();
}
class ListViewWidgetState extends State<ListViewWidget> {
final List<ItemBean> itemBeans = [];
@override
void initState() {
super.initState();
_initData();
}
// 实际场景可能是从网络拉取,这里演示就直接填充数据源了
void _initData() {
itemBeans.add(ItemBean('第一句', '关注微信公众号「AndroidTraveler」'));
itemBeans.add(ItemBean('第二句', '星河滚烫,你是人间理想'));
itemBeans.add(ItemBean('第三句', '我明白你会来,所以我等。'));
itemBeans.add(ItemBean('第四句', '家人闲坐,灯火可亲。'));
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: ListView.builder(
itemCount: itemBeans.length,
itemBuilder: (context, pos) {
return ItemWidget(pos, itemBeans[pos], widget.listener);
},
),
);
}
}
class MyListViewApp extends StatelessWidget {
const MyListViewApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: _buildWidget(),
),
),
);
}
Widget _buildWidget() {
return ListViewWidget((pos, itemBean) => print('点击了第 $pos 项 $itemBean'));
}
}
无限加载列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class InfiniteListView extends StatefulWidget {
const InfiniteListView({super.key});
@override
InfiniteListViewState createState() => InfiniteListViewState();
}
class InfiniteListViewState extends State<InfiniteListView> {
static const loadingTag = "##loading##"; //表尾标记
final _words = <String>[loadingTag];
@override
void initState() {
super.initState();
_retrieveData();
}
List<String> _generateWordPair() {
var list = <String>[];
const char = "abcdefghijklmnopqrstuvwxyz";
for (var i = 0; i < 20; i++) {
Random random = Random();
var index = random.nextInt(char.length);
list.add(char[index]);
}
return list;
}
void _retrieveData() {
Future.delayed(const Duration(seconds: 2)).then((value) => {
setState(() {
List<String> list = _generateWordPair();
_words.insertAll(
_words.length - 1,
// 每次生成20个单词
list.take(20).map((e) => e).toList());
})
});
}
@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: (context, index) {
// 如果到了表尾
if (_words[index] == loadingTag) {
// 不足100条,继续获取数据
if (_words.length - 1 < 100) {
_retrieveData();
// 加载时显示loading
return Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: const SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
);
} else {
//已经加载了100条数据,不再获取数据。
return Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16.0),
child: const Text(
"没有更多了",
style: TextStyle(color: Colors.grey),
),
);
}
}
//显示单词列表项
return ListTile(title: Text(_words[index]));
},
separatorBuilder: (context, index) => const Divider(height: .2),
itemCount: _words.length);
}
}
ListView 原理
ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:
- ListView 中的列表项组件都是 RenderBox,并不是 Sliver, 这个一定要注意。
- 一个 ListView 中只有一个 Sliver,对列表项进行按需加载的逻辑是 Sliver 中实现的。
- ListView 的 Sliver 默认是 SliverList,如果指定了 itemExtent ,则会使用 SliverFixedExtentList;如果 prototypeItem 属性不为空,则会使用 SliverPrototypeExtentList,无论是是哪个,都实现了子组件的按需加载模型。
AnimatedList
AnimatedList 和 ListView 的功能大体相似,不同的是, AnimatedList 可以在列表中插入或删除节点时执行一个动画,在需要添加或删除列表项的场景中会提高用户体验。
AnimatedList 是一个 StatefulWidget,它对应的 State 类型为 AnimatedListState,添加和删除元素的方法位于 AnimatedListState 中:
1
2
3
void insertItem(int index, { Duration duration = _kDuration });
void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = _kDuration }) ;
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
class AnimatedListViewPage extends StatelessWidget {
const AnimatedListViewPage({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(
title: const Text("AnimatedListView Demo"),
),
body: Container(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: const AnimatedListRoute(),
),
),
);
}
}
class AnimatedListRoute extends StatefulWidget {
const AnimatedListRoute({Key? key}) : super(key: key);
@override
_AnimatedListRouteState createState() => _AnimatedListRouteState();
}
class _AnimatedListRouteState extends State<AnimatedListRoute> {
var data = <String>[]; // 列表数据
int counter = 5;
final globalKey = GlobalKey<AnimatedListState>();
@override
void initState() {
for (var i = 0; i < counter; i++) {
data.add('${i + 1}');
}
super.initState();
}
@override
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: const 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
);
});
}
}
ScrollController
ScrollController 属性和方法
1
2
3
4
5
ScrollController({
double initialScrollOffset = 0.0, // 初始滚动位置
this.keepScrollOffset = true, // 是否保存滚动位置
// ...
});
- offset:可滚动组件当前的滚动位置
- jumpTo(double offset)、animateTo(double offset,…):这两个方法用于跳转到指定的位置,它们不同之处在于,后者在跳转时会执行一个动画,而前者不会
- addListener ScrollController 间接继承自 Listenable,我们可以根据 ScrollController 来监听滚动事件
滚动位置恢复
PageStorage
是一个用于保存页面 (路由) 相关数据的组件,它并不会影响子树的 UI 外观,其实,PageStorage 是一个功能型组件,它拥有一个存储桶(bucket),子树中的 Widget 可以通过指定不同的 PageStorageKey 来存储各自的数据或状态。每次滚动结束,可滚动组件都会将滚动位置 offset 存储到 PageStorage 中,当可滚动组件重新创建时再恢复。
- 如果 ScrollController.keepScrollOffset 为 false,则滚动位置将不会被存储,可滚动组件重新创建时会使用 ScrollController.initialScrollOffset
- 如果 ScrollController.keepScrollOffset 为 true 时,可滚动组件在第一次创建时,会滚动到 initialScrollOffset 处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而 initialScrollOffset 会被忽略
当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定 PageStorageKey 来分别跟踪不同的可滚动组件的位置,如:
1
2
3
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>
)。ScrollPosition 是真正保存滑动位置信息的对象,offset 只是一个便捷属性:
double get offset => position.pixels;
一个 ScrollController 虽然可以对应多个可滚动组件,但是有一些操作,如读取滚动位置 offset,则需要一对一;但是我们仍然可以在一对多的情况下,通过其他方法读取滚动位置,举个例子,假设一个 ScrollController 同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置:
1
2
3
4
...
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
...
ScrollPosition 有两个常用方法:animateTo()
和 ` jumpTo() `,它们是真正来控制跳转滚动位置的方法,ScrollController 的这两个同名方法,内部最终都会调用 ScrollPosition 的。
ScrollController 控制原理
1
2
3
4
5
6
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 关联的可滚动组件都滚动到指定的位置
案例
示例 1
我们创建一个 ListView,当滚动位置发生变化时,我们先打印出当前滚动位置,然后判断当前位置是否超过 1000 像素,如果超过则在屏幕右下角显示一个 “ 返回顶部 “ 的按钮,该按钮点击后可以使 ListView 恢复到初始位置;如果没有超过 1000 像素,则隐藏 “ 返回顶部 “ 按钮。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class ScrollControllerTestRoute extends StatefulWidget {
const ScrollControllerTestRoute({super.key});
@override
ScrollControllerTestRouteState createState() {
return ScrollControllerTestRouteState();
}
}
class ScrollControllerTestRouteState extends State<ScrollControllerTestRoute> {
final ScrollController _controller = ScrollController();
bool showToTopBtn = false; // 是否显示“返回到顶部”按钮
@override
void initState() {
super.initState();
_controller.addListener(() {
print(_controller.offset);
if (_controller.offset < 1000 && showToTopBtn) {
// 滚动位置小于1000像素时,隐藏“返回到顶部”按钮
setState(() {
showToTopBtn = false;
});
} else if (_controller.offset >= 1000 && showToTopBtn == false) {
// 滚动位置超过1000像素时,显示“返回到顶部”按钮
setState(() {
showToTopBtn = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("ListView 滚动控制 ScrollController")),
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: const Icon(Icons.arrow_upward),
onPressed: () {
print(
'ScrollControllerTestRouteState onPressed offset=${_controller.offset} position=${_controller.position}');
// 返回到顶部时执行动画
_controller.animateTo(.0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease);
}),
);
}
@override
void dispose() {
_controller.dispose(); // 释放资源
print('ScrollControllerTestRouteState dispose');
super.dispose();
}
}
GridView
GridView 构造函数
GridView 默认构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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>[],
// ...
})
- 大多数参数和 ListView 是一样的
- gridDelegate SliverGridDelegate 它的作用是控制 GridView 子组件如何排列 (layout)
- SliverGridDelegateWithFixedCrossAxisCount
- SliverGridDelegateWithMaxCrossAxisExtent
SliverGridDelegateWithFixedCrossAxisCount
实现了一个横轴为固定数量子元素的 layout 算法:
1
2
3
4
5
6
SliverGridDelegateWithFixedCrossAxisCount({
@required double crossAxisCount,
double mainAxisSpacing = 0.0,
double crossAxisSpacing = 0.0,
double childAspectRatio = 1.0,
})
- crossAxisCount 副轴子元素的数量,此属性值确定后子元素在副轴的长度就确定了,即 ViewPort 横轴长度除以 crossAxisCount 的商。(vertical 的话副轴就是横轴)
- mainAxisSpacing 主轴方向的间隔
- crossAxisSpacing 副轴方向子元素的间隔
- childAspectRatio 子元素在副轴长度和主轴长度的比例。由于 crossAxisCount 指定后,子元素副轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。
子元素的大小是通过 crossAxisCount 和 childAspectRatio 两个参数共同决定的
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
GridView(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, //横轴三个子widget
childAspectRatio: 1.0 //宽高比为1时,子widget
),
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 算法
1
2
3
4
5
6
SliverGridDelegateWithMaxCrossAxisExtent({
double maxCrossAxisExtent,
double mainAxisSpacing = 0.0,
double crossAxisSpacing = 0.0,
double childAspectRatio = 1.0,
})
- maxCrossAxisExtent 子元素在副轴上的最大长度
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Widget gridview_demo2() {
return GridView(
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 120.0, childAspectRatio: 2.0 //宽高比为2
),
children: const <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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Widget gridview_demo3() {
return GridView.count(
crossAxisCount: 3,
childAspectRatio: 1.0,
children: const <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
1
2
3
4
5
6
7
8
9
10
11
12
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
1
2
3
4
5
GridView.builder(
// ...
required SliverGridDelegate gridDelegate,
required IndexedWidgetBuilder itemBuilder,
)
- itemBuilder 为子 widget 构建器
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class InfiniteGridView extends StatefulWidget {
@override
_InfiniteGridViewState createState() => _InfiniteGridViewState();
}
class _InfiniteGridViewState extends State<InfiniteGridView> {
List<IconData> _icons = []; //保存Icon数据
@override
void initState() {
super.initState();
// 初始化数据
_retrieveIcons();
}
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, //每行三列
childAspectRatio: 1.0, //显示区域宽高相等
),
itemCount: _icons.length,
itemBuilder: (context, index) {
//如果显示到最后一个并且Icon总数小于200时继续获取数据
if (index == _icons.length - 1 && _icons.length < 200) {
_retrieveIcons();
}
return Icon(_icons[index]);
},
);
}
//模拟异步获取数据
void _retrieveIcons() {
Future.delayed(Duration(milliseconds: 200)).then((e) {
setState(() {
_icons.addAll([
Icons.ac_unit,
Icons.airport_shuttle,
Icons.all_inclusive,
Icons.beach_access,
Icons.cake,
Icons.free_breakfast,
]);
});
});
}
}
GridView 网格列表组件
- padding: 表示内边距,这个小伙伴们应该很熟悉。
- crossAxisSpacing: 网格间的空当,相当于每个网格之间的间距。
- crossAxisCount: 网格的列数,相当于一行放置的网格数量。
- childAspectRatio: 宽高比,这个值的意思是宽是高的多少倍,如果宽是高的 2 倍,那我们就写 2.0,如果高是宽的 2 倍,我们就写 0.5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class MyGridView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 2.0,
crossAxisSpacing: 2.0,
childAspectRatio: 0.7),
children: <Widget>[
new Image.network(
'http://img5.mtime.cn/mt/2018/10/22/104316.77318635_180X260X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/10/10/112514.30587089_180X260X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/13/093605.61422332_180X260X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/07/092515.55805319_180X260X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/21/090246.16772408_135X190X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/17/162028.94879602_135X190X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/19/165350.52237320_135X190X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/16/115256.24365160_180X260X4.jpg',
fit: BoxFit.cover),
new Image.network(
'http://img5.mtime.cn/mt/2018/11/20/141608.71613590_135X190X4.jpg',
fit: BoxFit.cover),
],
);
}
}
案例:仿微信支付页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
class WxPageTest extends StatelessWidget {
const WxPageTest({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(100.0),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomCenter,
colors: [
Color(0xFF56AF6D),
Color(0x21FF00FF),
Color(0xFF56AA6D),
])),
);
}
}
class WxPage extends StatelessWidget {
const WxPage({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('微信支付页面模拟',
style: Theme.of(context)
.textTheme
.headline4), // brightness: Brightness.dark,
),
body: ListView(
children: [
_headerGridButtons(),
_dynamicGridButtons(GridMockData.financeGrids(), "金融理财"),
_dynamicGridButtons(GridMockData.serviceGrids(), "生活服务"),
_dynamicGridButtons(GridMockData.thirdpartyGrids(), "购物消费")
],
),
),
);
}
}
/// 菜单项Widget
Column _getMenuItem(String icon, String name, {Color color = Colors.black}) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50,
height: 50,
child: Image.asset(icon),
),
const SizedBox(
height: 5,
),
Text(
name,
style: TextStyle(fontSize: 14.0, color: color, height: 2),
)
],
);
}
/// 顶部两个按钮
const double MARGIN = 10;
Widget _headerGridButtons() {
List<Map<String, String>> buttons = GridMockData.headerGrids();
return Container(
height: 144,
margin: const EdgeInsets.fromLTRB(MARGIN, MARGIN, MARGIN, MARGIN / 2.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF56AF6D),
Color(0xFF56AA6D),
]),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons
.map((item) => _getMenuItem(item['icon'] ?? "", item['name'] ?? "",
color: Colors.white))
.toList(),
),
),
);
}
/// 菜单布局
Widget _dynamicGridButtons(List<Map<String, String>> buttons, String title,
{int crossAxisCount = 4}) {
return Container(
margin: const EdgeInsets.fromLTRB(MARGIN, MARGIN, MARGIN, MARGIN / 2),
padding: const EdgeInsets.all(MARGIN),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0), color: Colors.white),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(color: Colors.grey[700])),
SizedBox(height: 20),
_gridButtons(buttons, crossAxisCount, textColor: Colors.black)
],
),
);
}
Widget _gridButtons(List<Map<String, String>> buttons, int crossAxisCount,
{Color textColor = Colors.white}) {
double gridSpace = 5.0;
return GridView.count(
crossAxisSpacing: gridSpace,
mainAxisSpacing: gridSpace,
crossAxisCount: crossAxisCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: buttons
.map((item) => _getMenuItem(item['icon'] ?? "", item['name'] ?? "",
color: textColor))
.toList(),
);
}
模拟数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class GridMockData {
static List<Map<String, String>> headerGrids() {
return [
{'name': '收付款', 'icon': 'images/grid-buttons/grid-3-1.png'},
{'name': '钱包', 'icon': 'images/grid-buttons/grid-3-2.png'},
];
}
static List<Map<String, String>> financeGrids() {
return [
{'name': '信用卡还款', 'icon': 'images/grid-buttons/grid-1-1.png'},
{'name': '借钱', 'icon': 'images/grid-buttons/grid-1-2.png'},
{'name': '理财', 'icon': 'images/grid-buttons/grid-1-3.png'},
{'name': '保险', 'icon': 'images/grid-buttons/grid-1-4.png'},
];
}
static List<Map<String, String>> serviceGrids() {
return [
{'name': '手机充值', 'icon': 'images/grid-buttons/grid-2-1.png'},
{'name': '生活缴费', 'icon': 'images/grid-buttons/grid-2-2.png'},
{'name': '充值', 'icon': 'images/grid-buttons/grid-2-3.png'},
{'name': '城市服务', 'icon': 'images/grid-buttons/grid-2-4.png'},
{'name': '公益', 'icon': 'images/grid-buttons/grid-4-1.png'},
{'name': '医疗', 'icon': 'images/grid-buttons/grid-4-2.png'},
{'name': '健康码', 'icon': 'images/grid-buttons/grid-4-3.png'},
];
}
static List<Map<String, String>> thirdpartyGrids() {
return [
{'name': '某东购物', 'icon': 'images/grid-buttons/grid-5-1.png'},
{'name': '某团外卖', 'icon': 'images/grid-buttons/grid-5-2.png'},
{'name': '电影票务', 'icon': 'images/grid-buttons/grid-5-3.png'},
{'name': '某团团购', 'icon': 'images/grid-buttons/grid-5-4.png'},
{'name': '拼夕夕', 'icon': 'images/grid-buttons/grid-5-5.png'},
{'name': '女装', 'icon': 'images/grid-buttons/grid-5-6.png'},
{'name': '某特卖', 'icon': 'images/grid-buttons/grid-5-7.png'},
{'name': '某二手', 'icon': 'images/grid-buttons/grid-5-8.png'},
{'name': '找房', 'icon': 'images/grid-buttons/grid-5-9.png'},
];
}
}
PageView
用于实现页面切换、Tab 布局。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,
// 主要是配合辅助功能用的,设置为true和ViewPager一样缓存前后各一页
this.allowImplicitScrolling = false,
//
this.padEnds = true,
})
- allowImplicitScrolling 为 true 表示前后各缓存一个页面宽度,即前后各一页
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Widget demoWidget() {
return PageView(
scrollDirection: Axis.horizontal,
allowImplicitScrolling: true,
children: const <Widget>[
Page(text: 'A'),
Page(text: 'B'),
Page(text: 'C'),
],
);
}
// Tab 页面
class Page extends StatefulWidget {
const Page({Key? key, required this.text}) : super(key: key);
final String text;
@override
_PageState createState() => _PageState();
}
class _PageState extends State<Page> {
@override
Widget build(BuildContext context) {
print("build ${widget.text}");
return Center(
child: Container(
decoration: const BoxDecoration(color: Colors.green),
child: Text(widget.text, textScaleFactor: 5),
));
}
}
可滚动子组件缓存
AutomaticKeepAlive
AutomaticKeepAlive 介绍
AutomaticKeepAlive 的组件的主要作用是将列表项的根 RenderObject 的 keepAlive 按需自动标记 为 true 或 false。为了方便叙述,我们可以认为根 RenderObject 对应的组件就是列表项的根 Widget,代表整个列表项组件,同时我们将列表组件的 Viewport 区域 + cacheExtent(预渲染区域)称为加载区域 :
- 当 keepAlive 标记为 false 时,如果列表项滑出加载区域时,列表组件将会被销毁。
- 当 keepAlive 标记为 true 时,当列表项滑出加载区域后,Viewport 会将列表组件缓存起来;当列表项进入加载区域时,Viewport 从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。
Flutter 中实现了一套类似 C/S 的机制,AutomaticKeepAlive 就类似一个 Server,它的子组件可以是 Client,这样子组件想改变是否需要缓存的状态时就向 AutomaticKeepAlive 发一个通知消息(KeepAliveNotification),AutomaticKeepAlive 收到消息后会去更改 keepAlive 的状态,如果有必要同时做一些资源清理的工作(比如 keepAlive 从 true 变为 false 时,要释放缓存)。
AutomaticKeepAlive 缓存示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class AutomaticKeepAliveDemo extends StatelessWidget {
const AutomaticKeepAliveDemo({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(
title: const Text("AutomaticKeepAlive Demo"),
),
body: Container(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: demoWidget(),
),
),
);
}
Widget demoWidget() {
return PageView(
scrollDirection: Axis.horizontal,
allowImplicitScrolling: true,
children: const <Widget>[
Page(text: 'A'),
Page(text: 'B'),
Page(text: 'C'),
Page(text: 'D'),
Page(text: 'E'),
],
);
}
}
class Page extends StatefulWidget {
const Page({Key? key, required this.text}) : super(key: key);
final String text;
@override
_PageState createState() => _PageState();
}
class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
print("AutomaticKeepAliveClientMixin build ${widget.text}");
return Center(
child: Container(
decoration: const BoxDecoration(color: Colors.green),
child: Text(widget.text, textScaleFactor: 5),
));
}
@override
bool get wantKeepAlive => true; // 是否需要缓存 保持状态
}
KeepAliveWrapper
复用带 cache 的 list
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class KeepAliveWrapper extends StatefulWidget {
const KeepAliveWrapper({
Key? key,
this.keepAlive = true,
required this.child,
}) : super(key: key);
final bool keepAlive;
final Widget child;
@override
_KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
@override
void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
if (oldWidget.keepAlive != widget.keepAlive) {
// keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
updateKeepAlive();
}
super.didUpdateWidget(oldWidget);
}
@override
bool get wantKeepAlive => widget.keepAlive;
}
TabBarView & TabBar
TabBarView
TabBarView 是 Material 组件库中提供了 Tab 布局组件,通常和 TabBar 配合使用。
1
2
3
4
5
6
7
TabBarView({
Key? key,
required this.children, // tab 页
this.controller, // TabController
this.physics,
this.dragStartBehavior = DragStartBehavior.start,
})
TabController 用于监听和控制 TabBarView 的页面切换,通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController
TabBarView 的页面缓存参考 PageView
TabBar
TabBar 为 TabBarView 的导航标题。
TabBar 有很多配置参数,通过这些参数我们可以定义 TabBar 的样式,很多属性都是在配置 indicator 和 label
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const TabBar({
Key? key,
required this.tabs, // 具体的 Tabs,需要我们创建
this.controller,
this.isScrollable = false, // 是否可以滑动
this.padding,
this.indicatorColor,// 指示器颜色,默认是高度为2的一条下划线
this.automaticIndicatorColorAdjustment = true,
this.indicatorWeight = 2.0,// 指示器高度
this.indicatorPadding = EdgeInsets.zero, //指示器padding
this.indicator, // 指示器
this.indicatorSize, // 指示器长度,有两个可选值,一个tab的长度,一个是label长度
this.labelColor,
this.labelStyle,
this.labelPadding,
this.unselectedLabelColor,
this.unselectedLabelStyle,
this.mouseCursor,
this.onTap,
// ...
})
TabBar 通常位于 AppBar 的底部,它也可以接收一个 TabController ,如果需要和 TabBarView 联动, TabBar 和 TabBarView 使用同一个 TabController 即可,注意,联动时 TabBar 和 TabBarView 的孩子数量需要一致。如果没有指定 controller,则会在组件树中向上查找并使用最近的一个 DefaultTabController 。另外我们需要创建需要的 tab 并通过 tabs 传给 TabBar, tab 可以是任何 Widget,不过 Material 组件库中已经实现了一个 Tab 组件,我们一般都会直接使用它:
1
2
3
4
5
6
7
8
const Tab({
Key? key,
this.text, //文本
this.icon, // 图标
this.iconMargin = const EdgeInsets.only(bottom: 10.0),
this.height,
this.child, // 自定义 widget
})
注意,text 和 child 是互斥的,不能同时制定。
案例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class TabBarDemo extends StatelessWidget {
const TabBarDemo({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const TabViewRoute1(),
);
}
}
class TabViewRoute1 extends StatefulWidget {
const TabViewRoute1({super.key});
@override
_TabViewRoute1State createState() => _TabViewRoute1State();
}
class _TabViewRoute1State extends State<TabViewRoute1>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List tabs = ["新闻", "历史", "图片"];
@override
void initState() {
super.initState();
_tabController = TabController(length: tabs.length, vsync: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("TabBar Demo"),
bottom: TabBar(
controller: _tabController,
tabs: tabs.map((e) => Tab(text: e)).toList(),
),
),
body: TabBarView(
//构建
controller: _tabController,
children: tabs.map((e) {
return KeepAliveWrapper(
child: Container(
alignment: Alignment.center,
child: Text(e, textScaleFactor: 5),
),
);
}).toList(),
),
);
}
@override
void dispose() {
// 释放资源
_tabController.dispose();
super.dispose();
}
}
class KeepAliveWrapper extends StatefulWidget {
const KeepAliveWrapper({
Key? key,
this.keepAlive = true,
required this.child,
}) : super(key: key);
final bool keepAlive;
final Widget child;
@override
_KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
@override
void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
if (oldWidget.keepAlive != widget.keepAlive) {
// keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
updateKeepAlive();
}
super.didUpdateWidget(oldWidget);
}
@override
bool get wantKeepAlive => widget.keepAlive;
}
- 由于 TabController 中会执行动画,持有一些资源,所以我们在页面销毁时必须得释放资源(dispose)
- 发现创建 TabController 的过程还是比较复杂,实战中,如果需要 TabBar 和 TabBarView 联动,通常会创建一个 DefaultTabController 作为它们共同的父级组件,这样它们在执行时就会从组件树向上查找,都会使用我们指定的这个 DefaultTabController。修改后:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class TabViewRoute2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
List tabs = ["新闻", "历史", "图片"];
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: Text("App Name"),
bottom: TabBar(
tabs: tabs.map((e) => Tab(text: e)).toList(),
),
),
body: TabBarView( //构建
children: tabs.map((e) {
return KeepAliveWrapper(
child: Container(
alignment: Alignment.center,
child: Text(e, textScaleFactor: 5),
),
);
}).toList(),
),
),
);
}
}
可以看到我们无需去手动管理 Controller 的生命周期,也不需要提供 SingleTickerProviderStateMixin,同时也没有其他的状态需要管理,也就不需要用 StatefulWidget 了
CustomScrollView
CustomScrollView 概述
CustomScrollView 的主要功能是提供一个公共的 Scrollable 和 Viewport,来组合多个 Sliver,CustomScrollView:
ListView、GridView、PageView 都是一个完整的可滚动组件,所谓完整是指它们都包括 Scrollable 、 Viewport 和 Sliver。 Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 Scrollable 和 Viewport ,然后它的 slivers 参数接受一个 Sliver 数组。
如果 CustomScrollView 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView。
CustomScrollView 常用属性
- slivers 最重要的属性,由多个 SliverXX 组件组成的数组,包括如
SliverList
(对应 ListView),SliverGrid
(对应 GridView),SliverAppBar
等,如果普通组件无法直接使用,而需要使用SliverToBoxAdapter
包裹 - reverse 是否反向滚动,如果为 true,则反方向滚动。
- scrollDirection 滚动方向,可以是横向或纵向。
常用的 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,主要是为了在 CustomScrollView 中使用。 |
SliverToBoxAdapter | 一个适配器,可以将 RenderBox 适配为 Sliver |
SliverPersistentHeader | 滑动到顶部时可以固定住 |
Sliver 系列 Widget 比较多,只需记住它的特点,需要时再去查看文档即可。上面之所以说 “ 大多数 “Sliver 都和可滚动组件对应,是由于还有一些如 SliverPadding、SliverAppBar 等是和可滚动组件无关的,它们主要是为了结合 CustomScrollView 一起使用,这是因为 CustomScrollView 的子组件必须都是 Sliver
SliverAppBar
SliverAppBar 对应 AppBar,两者不同之处在于 SliverAppBar 可以集成到 CustomScrollView。SliverAppBar 可以结合 FlexibleSpaceBar 实现 Material Design 中头部伸缩的模型;但有些其他的属性:
- floating:浮动,即便是滚动视图不在顶部,SliverAppBar 也会跟随滚动出现
- snap:手指放开时会根据当前状态决定是否展开或收起。如果为 false,则导航栏会停留在上次滑动位置
- pinned:滚动到顶部后,导航栏是否可见,默认是 false。若为 false,则滚动出顶部后导航栏将消失
- expandedHeight:导航栏展开后的高度
- flexibleSpace:扩展弹性空间,即导航栏滑动时的收起或展开组件,可以有背景图片和导航栏文字,当滑动到顶部后只显示文字导航栏,当下滑后,会逐步显示背景内容,从而实现动态导航栏的效果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SliverAppBar _getAppBar(String title) {
return SliverAppBar(
pinned: true,
snap: true,
floating: true,
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(
title: Text(title),
background: Image.network(
imageUrl,
fit: BoxFit.cover,
),
),
);
}
SliverPersistentHeader
SliverPersistentHeader 的功能是当滑动到 CustomScrollView 的顶部时,可以将组件固定在顶部。
需要注意, Flutter 中设计 SliverPersistentHeader 组件的初衷是为了实现 SliverAppBar,所以它的一些属性和回调在 SliverAppBar 中才会用到。
1
2
3
4
5
6
7
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,它是一个抽象类,需要我们自己实现,定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 指定为同样的值即可。
封装一个通用的委托构造器 SliverHeaderDelegate,通过它可以快速构建 SliverPersistentHeaderDelegate,实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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;
}
}
使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 的 overlapsContent 值一直都是 false,而 PersistentHeader 2 则是正常的,如果我们再添加几个 SliverPersistentHeader ,发现新添加的也都正常。总结一下:当有多个 SliverPersistentHeader 时,需要注意第一个 SliverPersistentHeader 的 overlapsContent 值会一直为 false。
这可能是一个 bug,也可能就是这么设计的,因为 SliverPersistentHeader 的设计初衷主要是为了实现 SliverAppBar,可能并没有考虑到通用的场景,但是不管怎样,flutter 2.5 版本中表现就是如此。为此,我们可以定一条约定:如果我们在使用 SliverPersistentHeader 构建子组件时需要依赖 overlapsContent 参数,则必须保证之前至少还有一个 SliverPersistentHeader 或 SliverAppBar(SliverAppBar 在当前 Flutter 版本的实现中内部包含了 SliverPersistentHeader)。
SliverToBoxAdapter
在实际布局中,我们通常需要往 CustomScrollView 中添加一些自定义的组件,而这些组件并非都有 Sliver 版本,为此 Flutter 提供了一个 SliverToBoxAdapter 组件,它是一个适配器:可以将 RenderBox 适配为 Sliver。
比如我们想在列表顶部添加一个可以横向滑动的 PageView,可以使用 SliverToBoxAdapter 来配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
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 是处理垂直方向的,两者并未冲突,所以不会有问题,但是换一个也是垂直方向的 ListView 时则不能正常工作,最终的效果是,在 ListView 内滑动时只会对 ListView 起作用,原因是滑动事件被 ListView 的 Scrollable 优先消费,CustomScrollView 的 Scrollable 便接收不到滑动事件了。
CustomScrollView 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
class WxPage2 extends StatelessWidget {
const WxPage2({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: CustomScrollView(
slivers: [
_getAppBar('个人中心'),
_headerGridButtons(),
_getMenuTitle('金融理财'),
_gridButtons(GridMockData.financeGrids()),
_getMenuTitle('生活服务'),
_gridButtons(GridMockData.serviceGrids()),
_getMenuTitle('购物消费'),
_gridButtons(GridMockData.thirdpartyGrids()),
],
),
);
}
}
const String imageUrl =
'https://t7.baidu.com/it/u=1595072465,3644073269&fm=193&f=GIF';
SliverAppBar _getAppBar(String title) {
return SliverAppBar(
pinned: true,
snap: true,
floating: true,
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(
title: Text(title),
background: Image.network(
imageUrl,
fit: BoxFit.cover,
),
),
);
}
SliverGrid _gridButtons(List<Map<String, String>> buttons,
{int crossAxisCount = 4, Color textColor = Colors.black}) {
double gridSpace = 5.0;
return SliverGrid.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: gridSpace,
crossAxisSpacing: gridSpace,
children: buttons.map((item) {
return _getMenus(item['icon'] ?? "", item['name'] ?? "",
color: textColor);
}).toList(),
);
}
Column _getMenus(String icon, String name, {Color color = Colors.black}) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 50,
height: 50,
child: Image.asset(icon),
),
const SizedBox(
height: 5,
),
Text(name, style: TextStyle(fontSize: 14.0, color: color, height: 1)),
],
);
}
Widget _getMenuTitle(String title) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(margin, margin, margin, margin / 2.0),
padding: const EdgeInsets.all(margin),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0), color: Colors.white),
child: Text(
title,
style: TextStyle(color: Colors.grey[700]),
),
),
);
}
/// 菜单项Widget
Column _getMenuItem(String icon, String name, {Color color = Colors.black}) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50,
height: 50,
child: Image.asset(icon),
),
const SizedBox(
height: 5,
),
Text(
name,
style: TextStyle(fontSize: 14.0, color: color, height: 2),
)
],
);
}
/// 顶部两个按钮
const double margin = 10;
Widget _headerGridButtons() {
double height = 144;
List<Map<String, String>> buttons = GridMockData.headerGrids();
return SliverToBoxAdapter(
child: Container(
height: height,
margin: const EdgeInsets.fromLTRB(margin, margin, margin, margin / 2.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4.0),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF56AF6D),
Color(0xFF56AA6D),
]),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons
.map((item) => _getMenuItem(
item['icon'] ?? "", item['name'] ?? "",
color: Colors.white))
.toList(),
),
),
),
);
}
效果:
NestedScrollView 嵌套可滚动组件
CustomScrollView 只能组合 Sliver,如果有孩子也是一个可滚动组件(通过 SliverToBoxAdapter 嵌入)且它们的滑动方向一致时便不能正常工作。为了解决这个问题,Flutter 中提供了一个 NestedScrollView 组件,它的功能是组合(协调)两个可滚动组件。
1
2
3
4
5
6
7
8
const NestedScrollView({
// ... //省略可滚动组件的通用属性
// header,sliver构造器
required this.headerSliverBuilder,
// 可以接受任意的可滚动组件
required this.body,
this.floatHeaderSlivers = false,
}