列表布局是我们项目开发中最常用的一种布局方式。

ListView

https://book.flutterchina.club/chapter6/listview.html
ListView是最常用的可滚动组件之一,它可以沿一个方向线性排布所有子组件,并且它也支持基于Sliver的延迟构建模型。

我们看看ListView的默认构造函数定义:

  1. ListView({
  2. ...
  3. //可滚动widget公共参数
  4. Axis scrollDirection = Axis.vertical, //滚动方向 horizontal 水平;vertical 垂直(默认)
  5. bool reverse = false, //组件反向排序
  6. ScrollController controller,
  7. bool primary,
  8. ScrollPhysics physics,
  9. EdgeInsetsGeometry padding, //内边距
  10. //ListView各个构造函数的共同参数
  11. double itemExtent,
  12. bool shrinkWrap = false,
  13. bool addAutomaticKeepAlives = true,
  14. bool addRepaintBoundaries = true,
  15. double cacheExtent,
  16. List<Widget> children = const <Widget>[],
  17. })

上面参数分为两组:第一组是可滚动组件的公共参数,本章第一节中已经介绍过,不再赘述;第二组是ListView各个构造函数(ListView有多个构造函数)的共同参数,我们重点来看看这些参数,:·

  • itemExtent:该参数如果不为null,则会强制children的“长度”为itemExtent的值;这里的“长度”是指滚动方向上子组件的长度,也就是说如果滚动方向是垂直方向,则itemExtent代表子组件的高度;如果滚动方向为水平方向,则itemExtent就代表子组件的宽度。在ListView中,指定itemExtent比让子组件自己决定自身长度会更高效,这是因为指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。
  • shrinkWrap:该属性表示是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认情况下,ListView的会在滚动方向尽可能多的占用空间。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true
  • addAutomaticKeepAlives:该属性表示是否将列表项(子组件)包裹在AutomaticKeepAlive 组件中;典型地,在一个懒加载列表中,如果将列表项包裹在AutomaticKeepAlive中,在该列表项滑出视口时它也不会被GC(垃圾回收),它会使用KeepAliveNotification来保存其状态。如果列表项自己维护其KeepAlive状态,那么此参数必须置为false
  • addRepaintBoundaries:该属性表示是否将列表项(子组件)包裹在RepaintBoundary组件中。当可滚动组件滚动时,将列表项包裹在RepaintBoundary中可以避免列表项重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary反而会更高效。和addAutomaticKeepAlive一样,如果列表项自己维护其KeepAlive状态,那么此参数必须置为false

    注意:上面这些参数并非ListView特有,在本章后面介绍的其它可滚动组件也可能会拥有这些参数,它们的含义是相同的。

默认构造函数

默认构造函数有一个children参数,它接受一个Widget列表(List)。这种方式适合只有少量的子组件的情况,因为这种方式需要将所有children都提前创建好(这需要做大量工作),而不是等到子widget真正显示的时候再创建,也就是说通过默认构造函数构建的ListView没有应用基于Sliver的懒加载模型。实际上通过此方式创建的ListView和使用SingleChildScrollView+Column的方式没有本质的区别。

  1. ListView(
  2. shrinkWrap: true,
  3. children: [
  4. Text('1'),
  5. Text('2'),
  6. Text('3'),
  7. Text('4'),
  8. ],
  9. ),

再次强调,可滚动组件通过一个List来作为其children属性时,只适用于子组件较少的情况,这是一个通用规律,并非ListView自己的特性,像GridView也是如此。

ListView嵌套示例

image.png

  1. ListView(
  2. scrollDirection: Axis.horizontal,
  3. children: [
  4. Container(width: 100, color: Colors.blue),
  5. Container(width: 50, color: Colors.red),
  6. Text('hello'),
  7. Container(
  8. width: 100,
  9. color: Colors.grey,
  10. child: ListView(
  11. scrollDirection: Axis.vertical, //默认
  12. children: [
  13. Container(height: 30, color: Colors.orange),
  14. Container(height: 20, color: Colors.yellow),
  15. ],
  16. ),
  17. ),
  18. ],
  19. ),

ListView.builder 列表项构造器

ListView.builder适合列表项比较多(或者无限)的情况,因为只有当子组件真正显示的时候才会被创建,也就说通过该构造函数创建的ListView是支持基于Sliver的懒加载模型的

  1. ListView.builder({
  2. ...
  3. @required IndexedWidgetBuilder itemBuilder,
  4. int itemCount,
  5. ...
  6. })
  • itemBuilder:它是列表项的构建器,类型为IndexedWidgetBuilder,返回值为一个widget。当列表滚动到具体的index位置时,会调用该构建器构建列表项。
  • itemCount:列表项的数量,如果为null,则为无限列表。

    可滚动组件的构造函数如果需要一个列表项Builder,那么通过该构造函数构建的可滚动组件通常就是支持基于Sliver的懒加载模型的,反之则不支持,这是个一般规律。我们在后面在介绍可滚动组件的构造函数时将不再专门说明其是否支持基于Sliver的懒加载模型了。

示例

image.png

  1. ListView.builder(
  2. itemCount: 10,
  3. itemExtent: 50, //强制ListView子组件高度为 50.0
  4. itemBuilder: (BuildContext context, int index) { //context 上下文;index索引
  5. return Container(
  6. color: Colors.primaries[index % Colors.primaries.length],
  7. child: Text('$index'),
  8. );
  9. },
  10. ),

ListView.separated 分割器构造器

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器。

示例:偶数行添加一条蓝色下划线,奇数行添加一条绿色下划线。

image.png

  1. ListView.separated(
  2. itemCount: 5,
  3. itemBuilder: (context, index) {
  4. return Text('$index');
  5. },
  6. separatorBuilder: (context, index) {
  7. return Divider(color: index % 2 == 0 ? Colors.blue : Colors.green);
  8. },
  9. ),

例子:动态列表(处理循环数据)

image.png

方法1:使用map方法,再用toList()转换为 List

  1. const listData = [
  2. {
  3. 'name': '张三',
  4. 'brief': '前端开发工程师',
  5. 'avatar':
  6. 'https://himg.bdimg.com/sys/portraitn/item/d4c8b0c1b7e7b2d0d4c232303132522e',
  7. },
  8. {
  9. 'name': '李四',
  10. 'brief': 'UI 设计师',
  11. 'avatar':
  12. 'https://himg.bdimg.com/sys/portraitn/item/d4c8b0c1b7e7b2d0d4c232303132522e',
  13. },
  14. ];
  15. class _MyHomePageState extends State<MyHomePage> {
  16. getData() {
  17. var temp = listData.map((value) {
  18. return ListTile(
  19. leading: Image.network(value['avatar']),
  20. title: Text(value['name']),
  21. subtitle: Text(value['brief']),
  22. );
  23. });
  24. return temp.toList();
  25. }
  26. @override
  27. Widget build(BuildContext context) {
  28. return ListView(
  29. children: this.getData(),
  30. );
  31. }
  32. }

方法2:使用 ListView.builder()

  1. const listData = [
  2. {
  3. 'name': '张三',
  4. 'brief': '前端开发工程师',
  5. 'avatar':
  6. 'https://himg.bdimg.com/sys/portraitn/item/d4c8b0c1b7e7b2d0d4c232303132522e',
  7. },
  8. {
  9. 'name': '李四',
  10. 'brief': 'UI 设计师',
  11. 'avatar':
  12. 'https://himg.bdimg.com/sys/portraitn/item/d4c8b0c1b7e7b2d0d4c232303132522e',
  13. },
  14. ];
  15. class _MyHomePageState extends State<MyHomePage> {
  16. @override
  17. Widget build(BuildContext context) {
  18. return ListView.builder(
  19. itemCount: listData.length,
  20. itemBuilder: (context, index) {
  21. var info = listData[index];
  22. return ListTile(
  23. leading: Image.network(info['avatar']),
  24. title: Text(info['name']),
  25. subtitle: Text(info['brief']),
  26. );
  27. },
  28. );
  29. }
  30. }

例子:上拉加载(触底加载下一页)

sdf.gif

/lib/loading/InnerLoading.dart
  1. import "package:flutter/material.dart";
  2. class InnerLoading extends StatelessWidget {
  3. InnerLoading({key, this.width = 24.0, this.padding = 16.0}) : super(key: key);
  4. final width;
  5. final padding;
  6. @override
  7. Widget build(BuildContext context) {
  8. return Container(
  9. alignment: Alignment.center,
  10. padding: EdgeInsets.all(this.padding),
  11. child: SizedBox(
  12. width: this.width,
  13. height: this.width,
  14. child: CircularProgressIndicator(),
  15. ),
  16. );
  17. }
  18. }

/lib/MyHomePage.dart
  1. import "package:flutter/material.dart";
  2. import "package:english_words/english_words.dart";
  3. import "./loading/InnerLoading.dart";
  4. class MyHomePage extends StatefulWidget {
  5. @override
  6. _MyHomePageState createState() => _MyHomePageState();
  7. }
  8. class _MyHomePageState extends State<MyHomePage> {
  9. static const loadingTag = '##loading##'; //表尾标记
  10. bool isFirstHasPost = false;
  11. var _words = <String>[loadingTag];
  12. @override
  13. initState() {
  14. super.initState();
  15. _retrieveData();
  16. }
  17. // _retrieveData 模拟从数据源异步获取数据
  18. _retrieveData() {
  19. Future.delayed(Duration(seconds: 2)).then((val) {
  20. setState(() {
  21. // 生成20个单词组成的数组
  22. var postWords = generateWordPairs().take(20).map((e) => e.asPascalCase).toList();
  23. _words.insertAll(_words.length - 1, postWords);
  24. isFirstHasPost = true;
  25. });
  26. });
  27. }
  28. @override
  29. Widget build(BuildContext context) {
  30. return ListView.separated(
  31. itemCount: _words.length,
  32. itemBuilder: (context, index) {
  33. var len = _words.length;
  34. // 第一次接口返回前,先loading。
  35. if (!isFirstHasPost) {
  36. return InnerLoading();
  37. } else {
  38. // 如果未到末尾,加载单词列表项
  39. if (_words[index] != loadingTag) {
  40. return ListTile(title: Text(_words[index]));
  41. } else {
  42. // 不足80条,继续获取数据
  43. if (len - 1 < 80) {
  44. _retrieveData(); // 通过 ListView.separated 创建的ListView是支持基于Sliver的懒加载模型的。
  45. return InnerLoading(width: 24.0, padding: 16.0);
  46. } else {
  47. // 已经加载了80条数据,不再获取数据
  48. return Container(
  49. alignment: Alignment.center,
  50. padding: EdgeInsets.all(16),
  51. child: Text('没有更多了'),
  52. );
  53. }
  54. }
  55. }
  56. },
  57. separatorBuilder: (context, index) => Divider(height: 0.0),
  58. );
  59. }
  60. }

例子:添加固定列表头

很多时候我们需要给列表添加一个固定表头,比如我们想实现一个商品列表,需要在列表顶部添加一个“商品列表”标题,期望的效果如图所示:
列表组件 ListView、ListTile - 图6

我们按照之前经验,写出如下代码:

  1. @override
  2. Widget build(BuildContext context) {
  3. return Column(children: <Widget>[
  4. ListTile(title:Text("商品列表")),
  5. ListView.builder(itemBuilder: (BuildContext context, int index) {
  6. return ListTile(title: Text("$index"));
  7. }),
  8. ]);
  9. }

然后运行,发现并没有出现我们期望的效果,相反触发了一个异常;

  1. Error caught by rendering library, thrown during performResize()。
  2. Vertical viewport was given unbounded height ...

从异常信息中我们可以看到是因为ListView高度边界无法确定引起,所以解决的办法也很明显,我们需要给ListView指定边界,我们通过SizedBox指定一个列表高度看看是否生效:

  1. ... //省略无关代码
  2. SizedBox(
  3. height: 400, //指定列表高度为400
  4. child: ListView.builder(itemBuilder: (BuildContext context, int index) {
  5. return ListTile(title: Text("$index"));
  6. }),
  7. ),
  8. ...

运行效果:
列表组件 ListView、ListTile - 图7

可以看到,现在没有触发异常并且列表已经显示出来了,但是我们的手机屏幕高度要大于400,所以底部会有一些空白。那如果我们要实现列表铺满除表头以外的屏幕空间应该怎么做?直观的方法是我们去动态计算,用屏幕高度减去状态栏、导航栏、表头的高度即为剩余屏幕高度,代码如下:

  1. ... //省略无关代码
  2. SizedBox(
  3. //Material设计规范中状态栏、导航栏、ListTile高度分别为24、56、56
  4. height: MediaQuery.of(context).size.height-24-56-56,
  5. child: ListView.builder(itemBuilder: (BuildContext context, int index) {
  6. return ListTile(title: Text("$index"));
  7. }),
  8. )
  9. ...

运行效果:
列表组件 ListView、ListTile - 图8
可以看到,我们期望的效果实现了,但是这种方法并不优雅,如果页面布局发生变化,比如表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。那么有什么方法可以自动拉伸ListView以填充屏幕剩余空间的方法吗?当然有!答案就是Flex。前面已经介绍过在弹性布局中,可以使用Expanded自动拉伸组件大小,并且我们也说过Column是继承自Flex的,所以我们可以直接使用Column+Expanded来实现,代码如下:

  1. @override
  2. Widget build(BuildContext context) {
  3. return Column(children: <Widget>[
  4. ListTile(title:Text("商品列表")),
  5. Expanded(
  6. child: ListView.builder(itemBuilder: (BuildContext context, int index) {
  7. return ListTile(title: Text("$index"));
  8. }),
  9. ),
  10. ]);
  11. }

运行后,和上图一样,完美实现了!

总结

本节主要介绍了ListView的一些公共参数以及常用的构造函数。不同的构造函数对应了不同的列表项生成模型,如果需要自定义列表项生成模型,可以通过ListView.custom来自定义,它需要实现一个SliverChildDelegate用来给ListView生成列表项组件,更多详情请参考API文档。

ListTile

https://flutterchina.club/tutorials/layout/#listtile
将最多3行文字,以及可选的行前和和行尾的图标排成一行。

属性
  • title 标题
  • subtitle 副标题
  • leading 前缀
  • trailing 后缀
  • dense 是否紧凑模式
  • visualDensity 紧凑。horizontal 水平紧凑; vertical 垂直紧凑;限制范围 [-4, 4]

visualDensity: VisualDensity(horizontal: -4, vertical: -4)

  • contentPadding 内边距
  • tileColor 背景色
  • selectedTileColor 选中状态背景色,需搭配 selected: true 才能生效。
  • selected: true 是否选中

示例:
  1. ListView(
  2. scrollDirection: Axis.vertical, //horizontal 水平;vertical 垂直
  3. children: [
  4. ListTile(
  5. title: Text('我是一级标题'),
  6. subtitle: Text('我是二级标题'),
  7. ),
  8. ListTile(
  9. leading: Icon(Icons.search),
  10. title: Text('我是一级标题'),
  11. subtitle: Text('我是二级标题'),
  12. ),
  13. ListTile(
  14. trailing: Icon(Icons.search),
  15. title: Text('我是一级标题'),
  16. subtitle: Text('我是二级标题'),
  17. isThreeLine: true,
  18. dense: true, //是否紧凑
  19. contentPadding: EdgeInsets.all(0), //内边距
  20. tileColor: Colors.blue, //背景色
  21. ),
  22. ListTile(
  23. leading: Icon(Icons.search),
  24. title: Text('我是一级标题'),
  25. subtitle: Text('我是二级标题'),
  26. selectedTileColor: Colors.red, //背景色
  27. selected: true,
  28. ),
  29. ],
  30. );

image.png