You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

308 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 13 | 经典控件UITableView/ListView在Flutter中是什么
你好,我是陈航。
在上一篇文章中我和你一起学习了文本、图片和按钮这3大经典组件在Flutter中的使用方法以及如何在实际开发中根据不同的场景去自定义展示样式。
文本、图片和按钮这些基本元素需要进行排列组合才能构成我们看到的UI视图。那么当这些基本元素的排列布局超过屏幕显示尺寸即超过一屏我们就需要引入列表控件来展示视图的完整内容并根据元素的多少进行自适应滚动展示。
这样的需求在Android中是由ListView或RecyclerView实现的在iOS中是用UITableView实现的而在Flutter中实现这种需求的则是列表控件ListView。
## ListView
在Flutter中ListView可以沿一个方向垂直或水平方向来排列其所有子Widget因此常被用于需要展示一组连续视图元素的场景比如通信录、优惠券、商家列表等。
我们先来看看ListView怎么用。**ListView提供了一个默认构造函数ListView**我们可以通过设置它的children参数很方便地将所有的子Widget包含到ListView中。
不过这种创建方式要求提前将所有子Widget一次性创建好而不是等到它们真正在屏幕上需要显示时才创建所以有一个很明显的缺点就是性能不好。因此**这种方式仅适用于列表中含有少量元素的场景**。
如下所示我定义了一组列表项组件并将它们放在了垂直滚动的ListView中
```
ListView(
children: <Widget>[
//设置ListTile组件的标题与图标
ListTile(leading: Icon(Icons.map), title: Text('Map')),
ListTile(leading: Icon(Icons.mail), title: Text('Mail')),
ListTile(leading: Icon(Icons.message), title: Text('Message')),
]);
```
> 备注ListTile是Flutter提供的用于快速构建列表项元素的一个小组件单元用于1~3行leading、title、subtitle展示文本、图标等视图元素的场景通常与ListView配合使用。
> 上面这段代码中用到ListTile是为了演示ListView的能力。关于ListTile的具体使用细节并不是本篇文章的重点如果你想深入了解的话可以参考[官方文档](https://api.flutter.dev/flutter/material/ListTile-class.html)。
运行效果,如下图所示:
![](https://static001.geekbang.org/resource/image/b1/01/b152f47246c851c3c1878564f07de101.png)
图1 ListView默认构造函数
除了默认的垂直方向布局外ListView还可以通过设置scrollDirection参数支持水平方向布局。如下所示我定义了一组不同颜色背景的组件将它们的宽度设置为140并包在了水平布局的ListView中让它们可以横向滚动
```
ListView(
scrollDirection: Axis.horizontal,
itemExtent: 140, //item延展尺寸(宽度)
children: <Widget>[
Container(color: Colors.black),
Container(color: Colors.red),
Container(color: Colors.blue),
Container(color: Colors.green),
Container(color: Colors.yellow),
Container(color: Colors.orange),
]);
```
运行效果,如下图所示:
![](https://static001.geekbang.org/resource/image/df/ac/df382224daeca7067d3a9c5acc5febac.gif)
图2 水平滚动的ListView
在这个例子中我们一次性创建了6个子Widget。但从图2的运行效果可以看到由于屏幕的宽高有限同一时间用户只能看到3个Widget。也就是说是否一次性提前构建出所有要展示的子Widget与用户而言并没有什么视觉上的差异。
所以考虑到创建子Widget产生的性能问题更好的方法是抽象出创建子Widget的方法交由ListView统一管理在真正需要展示该子Widget时再去创建。
**ListView的另一个构造函数ListView.builder则适用于子Widget比较多的场景**。这个构造函数有两个关键参数:
* itemBuilder是列表项的创建方法。当列表滚动到相应位置时ListView会调用该方法创建对应的子Widget。
* itemCount表示列表项的数量如果为空则表示ListView为无限列表。
同样地我通过一个案例与你说明itemBuilder与itemCount这两个参数的具体用法。
我定义了一个拥有100个列表元素的ListView在列表项的创建方法中分别将index的值设置为ListTile的标题与子标题。比如第一行列表项会展示title 0 body 0
```
ListView.builder(
itemCount: 100, //元素个数
itemExtent: 50.0, //列表项高度
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))
);
```
这里需要注意的是,**itemExtent并不是一个必填参数。但对于定高的列表项元素我强烈建议你提前设置好这个参数的值。**
因为如果这个参数为nullListView会动态地根据子Widget创建完成的结果决定自身的视图高度以及子Widget在ListView中的相对位置。在滚动发生变化而列表项又很多时这样的计算就会非常频繁。
但如果提前设置好itemExtentListView则可以提前计算好每一个列表项元素的相对位置以及自身的视图高度省去了无谓的计算。
因此在ListView中指定itemExtent比让子Widget自己决定自身高度会更高效。
运行这个示例,效果如下所示:
![](https://static001.geekbang.org/resource/image/d6/5a/d654c5a28056afc017fe3f085230745a.png)
图3 ListView.builder构造函数
可能你已经发现了我们的列表还缺少分割线。在ListView中有两种方式支持分割线
* 一种是在itemBuilder中根据index的值动态创建分割线也就是将分割线视为列表项的一部分
* 另一种是使用ListView的另一个构造方法ListView.separated单独设置分割线的样式。
第一种方式实际上是视图的组合,之前的分享中我们已经多次提及,对你来说应该已经比较熟悉了,这里我就不再过多地介绍了。接下来,我和你演示一下**如何使用ListView.separated设置分割线。**
与ListView.builder抽离出了子Widget的构建方法类似ListView.separated抽离出了分割线的创建方法separatorBuilder以便根据index设置不同样式的分割线。
如下所示我针对index为偶数的场景创建了绿色的分割线而针对index为奇数的场景创建了红色的分割线
```
//使用ListView.separated设置分割线
ListView.separated(
itemCount: 100,
separatorBuilder: (BuildContext context, int index) => index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数创建绿色分割线index为奇数则创建红色分割线
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))//创建子Widget
)
```
运行效果,如下所示:
![](https://static001.geekbang.org/resource/image/5e/3b/5e1ef0977150346fa95f23232d628e3b.png)
图4 ListView.separated构造函数
好了我已经与你分享完了ListView的常见构造函数。接下来我准备了一张表格总结了ListView常见的构造方法及其适用场景供你参考以便理解与记忆
![](https://static001.geekbang.org/resource/image/00/18/00e6c9f8724fcf50757b4a76fa4c9b18.png)
图5 ListView常见的构造方法及其适用场景
## CustomScrollView
好了ListView实现了单一视图下可滚动Widget的交互模型同时也包含了UI显示相关的控制逻辑和布局模型。但是对于某些特殊交互场景比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等还需要嵌套多个ListView来实现。这时各自视图的滚动和布局模型就是相互独立、分离的就很难保证整个页面统一一致的滑动效果。
那么,**Flutter是如何解决多ListView嵌套时页面滑动效果不一致的问题的呢**
在Flutter中有一个专门的控件CustomScrollView用来处理多个需要自定义滚动效果的Widget。在CustomScrollView中**这些彼此独立的、可滚动的Widget被统称为Sliver**。
比如ListView的Sliver实现为SliverListAppBar的Sliver实现为SliverAppBar。这些Sliver不再维护各自的滚动状态而是交由CustomScrollView统一管理最终实现滑动效果的一致性。
接下来我通过一个滚动视差的例子与你演示CustomScrollView的使用方法。
**视差滚动**是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。
以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。
经分析得出要实现这样的需求我们需要两个Sliver作为头图的SliverAppBar与作为列表的SliverList。具体的实现思路是
* 在创建SliverAppBar时把flexibleSpace参数设置为悬浮头图背景。flexibleSpace可以让背景图显示在AppBar下方高度和SliverAppBar一样
* 而在创建SliverList时通过SliverChildBuilderDelegate参数实现列表项元素的创建
* 最后将它们一并交由CustomScrollView的slivers参数统一管理。
具体的示例代码如下所示:
```
CustomScrollView(
slivers: <Widget>[
SliverAppBar(//SliverAppBar作为头图控件
title: Text('CustomScrollView Demo'),//标题
floating: true,//设置悬浮样式
flexibleSpace: Image.network("https://xx.jpg",fit:BoxFit.cover),//设置悬浮头图背景
expandedHeight: 300,//头图控件高度
),
SliverList(//SliverList作为列表控件
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item #$index')),//列表项创建方法
childCount: 100,//列表元素个数
),
),
]);
```
运行一下,视差滚动效果如下所示:
![](https://static001.geekbang.org/resource/image/dc/21/dcf89204e537f828d197dc3b916ca321.gif)
图6 CustomScrollView示例
## ScrollController与ScrollNotification
现在,你应该已经知道如何实现滚动视图的视觉和交互效果了。接下来,我再与你分享一个更为复杂的问题:在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了?
对于前两个问题我们可以使用ScrollController进行滚动信息的监听以及相应的滚动控制而最后一个问题则需要接收ScrollNotification通知进行滚动事件的获取。下面我将分别与你介绍。
在Flutter中因为Widget并不是渲染到屏幕的最终视觉元素RenderObject才是所以我们无法像原生的Android或iOS系统那样向持有的Widget对象获取或设置最终渲染相关的视觉信息而必须通过对应的组件控制器才能实现。
ListView的组件控制器则是ScrollControler我们可以通过它来获取视图的滚动信息更新视图的滚动位置。
一般而言获取视图的滚动信息往往是为了进行界面的状态控制因此ScrollController的初始化、监听及销毁需要与StatefulWidget的状态保持同步。
如下代码所示我们声明了一个有着100个元素的列表项当滚动视图到特定位置后用户可以点击按钮返回列表顶部
* 首先我们在State的初始化方法里创建了ScrollController并通过\_controller.addListener注册了滚动监听方法回调根据当前视图的滚动位置判断当前是否需要展示“Top”按钮。
* 随后在视图构建方法build中我们将ScrollController对象与ListView进行了关联并且在RaisedButton中注册了对应的回调方法可以在点击按钮时通过\_controller.animateTo方法返回列表顶部。
* 最后在State的销毁方法中我们对ScrollController进行了资源释放。
```
class MyAPPState extends State<MyApp> {
ScrollController _controller;//ListView控制器
bool isToTop = false;//标示目前是否需要启用"Top"按钮
@override
void initState() {
_controller = ScrollController();
_controller.addListener(() {//为控制器注册滚动监听方法
if(_controller.offset > 1000) {//如果ListView已经向下滚动了1000则启用Top按钮
setState(() {isToTop = true;});
} else if(_controller.offset < 300) {//如果ListView向下滚动距离不足300则禁用Top按钮
setState(() {isToTop = false;});
}
});
super.initState();
}
Widget build(BuildContext context) {
return MaterialApp(
...
//顶部Top按钮根据isToTop变量判断是否需要注册滚动到顶部的方法
RaisedButton(onPressed: (isToTop ? () {
if(isToTop) {
_controller.animateTo(.0,
duration: Duration(milliseconds: 200),
curve: Curves.ease
);//做一个滚动到顶部的动画
}
}:null),child: Text("Top"),)
...
ListView.builder(
controller: _controller,//初始化传入控制器
itemCount: 100,//列表元素总数
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项构造方法
)
...
);
@override
void dispose() {
_controller.dispose(); //销毁控制器
super.dispose();
}
}
```
ScrollController的运行效果如下所示
![](https://static001.geekbang.org/resource/image/61/1b/61533dc0e445bd529879698ad3491b1b.gif)
图7 ScrollController示例
介绍完了如何通过ScrollController来监听ListView滚动信息以及怎样进行滚动控制之后接下来我们再看看**如何获取ScrollNotification通知从而感知ListView的各类滚动事件**。
在Flutter中ScrollNotification通知的获取是通过NotificationListener来实现的。与ScrollController不同的是NotificationListener是一个Widget为了监听滚动类型的事件我们需要将NotificationListener添加为ListView的父容器从而捕获ListView中的通知。而这些通知需要通过onNotification回调函数实现监听逻辑
```
Widget build(BuildContext context) {
return MaterialApp(
title: 'ScrollController Demo',
home: Scaffold(
appBar: AppBar(title: Text('ScrollController Demo')),
body: NotificationListener<ScrollNotification>(//添加NotificationListener作为父容器
onNotification: (scrollNotification) {//注册通知回调
if (scrollNotification is ScrollStartNotification) {//滚动开始
print('Scroll Start');
} else if (scrollNotification is ScrollUpdateNotification) {//滚动位置更新
print('Scroll Update');
} else if (scrollNotification is ScrollEndNotification) {//滚动结束
print('Scroll End');
}
},
child: ListView.builder(
itemCount: 30,//列表元素个数
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项创建方法
),
)
)
);
}
```
相比于ScrollController只能和具体的ListView关联后才可以监听到滚动信息通过NotificationListener则可以监听其子Widget中的任意ListView不仅可以得到这些ListView的当前滚动位置信息还可以获取当前的滚动事件信息 。
## 总结
在处理用于展示一组连续、可滚动的视图元素的场景Flutter提供了比原生Android、iOS系统更加强大的列表组件ListView与CustomScrollView不仅可以支持单一视图下可滚动Widget的交互模型及UI控制模型对于某些特殊交互需要嵌套多重可滚动Widget的场景也提供了统一管理的机制最终实现体验一致的滑动效果。这些强大的组件使得我们不仅可以开发出样式丰富的界面更可以实现复杂的交互。
接下来,我们简单回顾一下今天的内容,以便加深你的理解与记忆。
首先我们认识了ListView组件。它同时支持垂直方向和水平方向滚动不仅提供了少量一次性创建子视图的默认构造方式也提供了大量按需创建子视图的ListView.builder机制并且支持自定义分割线。为了节省性能对于定高的列表项视图提前指定itemExtent比让子Widget自己决定要更高效。
随后我带你学习了CustomScrollView组件。它引入了Sliver的概念将多重嵌套的可滚动视图的交互与布局进行统一接管使得像视差滚动这样的高级交互变得更加容易。
最后我们学习了ScrollController与NotificationListener前者与ListView绑定进行滚动信息的监听进行相应的滚动控制而后者通过将ListView纳入子Widget实现滚动事件的获取。
我把今天分享讲的三个例子视差、ScrollController、ScrollNotification放到了[GitHub](https://github.com/cyndibaby905/13_listview_demo)上你可以下载后在工程中实际运行并对照着今天的知识点进行学习体会ListView的一些高级用法。
## 思考题
最后,我给你留下两个小作业吧:
1. 在ListView.builder方法中ListView根据Widget是否将要出现在可视区域内按需创建。对于一些场景为了避免Widget渲染时间过长比如图片下载我们需要提前将可视区域上下一定区域内的Widget提前创建好。那么在Flutter中如何才能实现呢
2. 请你使用NotificationListener来实现图7 ScrollController示例中同样的功能。
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。