Flutter 可滚动组件 之 ListView (十六)

约定:后面如果我们说一个组件是Sliver 则表示它是基于Sliver布局的组件,同理,说一个组件是 RenderBox,则代表它是基于盒模型布局的组件,并不是说它就是 RenderBox 类的实例。

ListView是最常用的可滚动组件之一,它可以沿一个方向线性排布所有子组件,并且它也支持列表项懒加载(在需要时才会创建)。我们看看ListView的默认构造函数定义:

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的长度,默认值为false 。默认情况下,ListView会在滚动方向尽可能多的占用空间。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true。

  • addAutomaticKeepAlives:该属性我们将在介绍 PageView 组件时详细解释。

  • addRepaintBoundaries:该属性表示是否将列表项(子组件)包裹在RepaintBoundary组件中。RepaintBoundary 读者可以先简单理解为它是一个”绘制边界“,将列表项包裹在RepaintBoundary中可以避免列表项不必要的重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary反而会更高效(具体原因会在本书后面 Flutter 绘制原理相关章节中介绍)。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该指定为 false。

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

1. 默认构造函数

默认构造函数有一个children参数,它接受一个Widget列表(List<Widget>)。这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder 按需动态构建列表项

注意,虽然这种方式将所有children一次性传递给 ListView,但子组件仍然是在需要时才会加载(build(如有)、布局、绘制),也就是说通过默认构造函数构建的 ListView 也是基于 Sliver 的列表懒加载模型。

示例1

class ListViewDemo extends StatelessWidget {
  ListViewDemo({
    Key? key,
  }) : super(key: key);
  
  final TextStyle textStyle = TextStyle(fontSize: 20, color: Colors.redAccent);

  @override
  Widget build(BuildContext context) {
    return ListView(
      shrinkWrap: true,
      children: [
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("人的一切痛苦,本质上都是对自己无能的愤怒。", style: textStyle)),
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("人活在世界上,不可以有偏差;而且多少要费点劲儿,才能把自己保持到理性的轨道上。",
                style: textStyle)),
        Padding(
            padding: EdgeInsets.all(8),
            child: Text("我活在世上,无非想要明白些道理,遇见些有趣的事。。", style: textStyle))
      ],
    );
  }
}
image.png

示例2

class ListViewDemo1 extends StatelessWidget {
  const ListViewDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: List.generate(100, (index) {
        // ListTile 小分片
        return ListTile(
          leading: Icon(Icons.people),
          trailing: Icon(Icons.delete),
          title: Text(
            "联系人 ${index + 1}",
            style: TextStyle(color: Colors.orange, fontSize: 20),
          ),
          subtitle: Text(
            "联系方式: 18826625555",
            style: TextStyle(color: Colors.grey, fontSize: 16),
          ),
        );
      }),
    );
  }
}

image.png

2. ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况,下面看一下ListView.builder的核心参数列表

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder, 
  int itemCount, // item数量
  ...
})
  • itemBuilder:它是列表项的构建器,类型为IndexedWidgetBuilder,返回值为一个widget。当列表滚动到具体的index位置时,会调用该构建器构建列表项。
    *itemCount:列表项的数量,如果为null,则为无限列表。

示例1

class ListViewBuilderDemo extends StatelessWidget {
  const ListViewBuilderDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 100,
      itemExtent: 50, // 主轴方向高度
      itemBuilder: (BuildContext ctx, int index) {
        return Text("Item ${index + 1}");
      },
    );
  }
}
image.png

3. ListView.separated

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

示例1

class ListViewSeparatedDemo extends StatelessWidget {
  const ListViewSeparatedDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
        itemBuilder: (BuildContext ctx, int index) {
          return ListTile(
            leading: Icon(Icons.people),
            trailing: Icon(Icons.delete),
            title: Text(
              "联系人 ${index + 1}",
              style: TextStyle(fontSize: 20),
            ),
          );
        },
        separatorBuilder: (BuildContext ctx, int index) {
          return Divider(
            height: 20, // Divider 高度,不是线的高度
            thickness: 5, // 线的高度
            color: index % 2 == 0 ? Colors.orange : Colors.blue,
            indent: 16, // 左侧间距
            endIndent: 16, // 右侧间距
          );
        },
        itemCount: 100);
  }
}
image.png

4. ListView.custom

我们看下ListView.custom的定义

  const ListView.custom({
    ...
    required this.childrenDelegate,
    ...
  }) 

ListView.custom 主要是传一个SliverChildDelegate代理, SliverChildDelegate是abstract(抽象类),它有两个子类SliverChildBuilderDelegateSliverChildListDelegate

** SliverChildListDelegate**
定义如下:

  SliverChildListDelegate(
    this.children, {
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  })

children 是必传的,是不是很眼熟,ListView默认构造函数里也是传一个children,实际上ListView默认构造函数中是通过children创建一个SliverChildListDelegate的

ListView构造函数处理children源码如下:

       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),

** SliverChildBuilderDelegate**
定义如下:

  const SliverChildBuilderDelegate(
    this.builder, {
    this.findChildIndexCallback,
    this.childCount,
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  })

builder是必传的,是个NullableIndexedWidgetBuilder类型,和IndexedWidgetBuilder类似,ListView.builder源码中是通过builder创建个SliverChildBuilderDelegate的

ListView.builder 处理 itemBuilder 源码如下

       childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),

示例1 - SliverChildListDelegate

class ListViewCustomDemo1 extends StatelessWidget {
  const ListViewCustomDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.custom(
      itemExtent: 50, // 高度
      childrenDelegate: SliverChildListDelegate(
        List.generate(100, (index) {
          return ListTile(
            title: Text("商品Item ${index + 1}",
                style: TextStyle(color: Colors.red, fontSize: 18)),
            trailing: Icon(
              Icons.favorite,
              color: Colors.white,
            ),
          );
        }),
      ),
    );
  }
}
image.png

示例2 - SliverChildBuilderDelegate

class ListViewCustomDemo2 extends StatelessWidget {
  const ListViewCustomDemo2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.custom(
      itemExtent: 100,
      childrenDelegate:
          SliverChildBuilderDelegate((BuildContext ctx, int index) {
        return Container(
          color: Color.fromARGB(Random().nextInt(256), Random().nextInt(256),
              Random().nextInt(256), Random().nextInt(256)),
        );
      }, childCount: 100),
    );
  }
}
image.png

5. 固定高度列表

默认情况下,列表中的Item的高度是随内容自适应的。
但给列表指定 itemExtent 或 prototypeItem 会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定 itemExtent 或 prototypeItem

示例1

class ListViewFixedExtentDemo1 extends StatelessWidget {
  const ListViewFixedExtentDemo1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemExtent: 56,
      // prototypeItem: ListTile(title: Text("Item")),
      itemBuilder: (BuildContext ctx, int index) {
        return ListTile(
          title: Text("Item $index"),
        );
      },
    );
  }
}

自定义个LayoutLogPrint组件,在布局时可以打印当前上下文中父组件给子组件的约束信息

完整代码如下

class ListViewFixedExtentDemo2 extends StatelessWidget {
  const ListViewFixedExtentDemo2({
    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 LayoutLogPrint(
          tag: index,
          child: ListTile(title: Text("$index")),
        );
      },
    );
  }
}

class LayoutLogPrint<T> extends StatelessWidget {
  final Widget child;
  final T? tag;
  const LayoutLogPrint({Key? key, required this.child, this.tag})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, constraints) {
      // assert在编译release版本时会被去除
      assert(() {
        print('${tag ?? key ?? child}: $constraints');
        return true;
      }());
      return child;
    });
  }
}

因为列表项都是一个 ListTile,高度相同,但是我们不知道 ListTile 的高度是多少,所以指定了prototypeItem ,运行后,控制台打印:

flutter: 0: BoxConstraints(w=375.0, h=56.0)
flutter: 1: BoxConstraints(w=375.0, h=56.0)
flutter: 2: BoxConstraints(w=375.0, h=56.0)
flutter: 3: BoxConstraints(w=375.0, h=56.0)
...

可见 ListTile 的高度是 56 ,指定itemExtent为56也是可以的,建议优先指定原型,这样的话在列表项布局修改后,仍然可以正常工作(前提是每个列表项的高度相同)

如果本例中不指定 itemExtent 或 prototypeItem ,我们看看控制台日志信息

flutter: 0: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 1: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 2: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
flutter: 3: BoxConstraints(w=375.0, 0.0<=h<=Infinity)
...

可以发现,列表不知道列表项的具体高度,高度约束变为 0.0 到 Infinity。

6.列表的原理

ListView 内部组合了 Scrollable、Viewport 和 Sliver,需要注意:

    1. ListView 中的列表项组件都是 RenderBox,并不是 Sliver, 这个一定要注意。
    1. 一个 ListView 中只有一个Sliver,对列表项进行按需加载的逻辑是 Sliver 中实现的。
    1. ListView 的 Sliver 默认是 SliverList,如果指定了 itemExtent ,则会使用 SliverFixedExtentList;如果 prototypeItem 属性不为空,则会使用 SliverPrototypeExtentList,无论是是哪个,都实现了子组件的按需加载模型。

我们解释下ListView的Sliver
ListView 是继承于BoxScrollView,BoxScrollView继承于ScrollView,ScrollView继承于StatelessWidget,因而ScrollView是我们需要读的最深层级,在ScrollView的方法中,是通过buildSlivers来获取slivers的,而buildSlivers方法在ScrollView中是抽象方法,因而它的子类需要实现。现在我们需要看下BoxScrollView中关于buildSlivers的实现,在BoxScrollView的 buildSlivers方法中是通过buildChildLayout来获取Sliver,而buildChildLayout在BoxScrollView中也是抽象方法,因而我们去看BoxScrollView子类ListView中buildChildLayout的实现,ListView中buildChildLayout源码如下

  Widget buildChildLayout(BuildContext context) {
    if (itemExtent != null) {
      return SliverFixedExtentList(
        delegate: childrenDelegate,
        itemExtent: itemExtent!,
      );
    } else if (prototypeItem != null) {
      return SliverPrototypeExtentList(
        delegate: childrenDelegate,
        prototypeItem: prototypeItem!,
      );
    }
    return SliverList(delegate: childrenDelegate);
  }

默认情况下 ListView的Sliver是SliverList,在itemExtent不为空时,是SliverFixedExtentList,在prototypeItem不为空时是SliverPrototypeExtentList

7 实例:无限加载列表

class _MSHomePageContentState extends State<MSHomePageContent> {
  static const loadingTag = "##loading##";
  var _words = <String>[loadingTag];

  @override
  void initState() {
    super.initState();
    _retrieveData();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
        itemBuilder: (BuildContext ctx, int index) {
          if (_words[index] == loadingTag) {
            if (_words.length <= 100) {
              _retrieveData();
              // 加载时显示loading
              return Container(
                padding: EdgeInsets.all(16),
                alignment: Alignment.center,
                child: SizedBox(
                  width: 24,
                  height: 24,
                  child: CircularProgressIndicator(strokeWidth: 2.0),
                ),
              );
            } else {
              // 已经加载了100条数据,不再获取数据。
              return Container(
                alignment: Alignment.center,
                padding: EdgeInsets.all(8),
                child: Text("没有更多", style: TextStyle(color: Colors.grey)),
              );
            }
          }
          // 显示单词列表
          return ListTile(title: Text(_words[index]));
        },
        separatorBuilder: (BuildContext ctx, int index) {
          return Divider(
              color: Colors.amber, thickness: 3, indent: 16, endIndent: 16);
        },
        itemCount: _words.length);
  }

  _retrieveData() {
    Future.delayed(Duration(seconds: 3)).then((value) {
      // 每次生成20对单词
      List<String> newData =
          generateWordPairs().take(20).map((e) => e.asSnakeCase).toList();
      _words.insertAll(_words.length - 1, newData);

      setState(() {});

      // List<WordPair> newData = generateWordPairs().take(10).toList();
      // List<String> data = newData.map((e) => e.asPascalCase).toList();
      // _words.addAll(data);
    });
  }
}


image.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容