iOS-Flutter 可滚动组件ListView

77 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 27 天,点击查看活动详情

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>[],
})
  • itemExtent:该参数如果不为bull,则会强制children的长度为itemExtent的值;这里的长度是指滚动方向上子组件的长度,也就是说如果滚动方向是垂直方向,则itemExtent代表子组件的高度;如果滚动方向为水平方向,则itemExtent就代表子组件的宽度。在ListView中,指定itemExtent比让子组件自己决定自身长度会有更好的性能,这是因为指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时。
  • prototypeItem:可以指定一个列表项,指定后,可滚动组件会在layout时计算一次它延主轴方向长度,这样也就预先知道了所有列表项的延主轴方向的长度,所以和itemExtent一样,指定prototypeItem会有更好的性能,但是itemExtent和prototypeItem互斥,不能同时指定。
  • skrinkWrap:根据子组件的总长度来设置ListView的长度,默认false。默认情况下,ListView会在滚动方向尽可能多的占用空间,当ListView在一个无边界的容器中时,skrinkWrap必须为true。
  • addAutomaticKeepAlives:是否缓存,在pageView中有介绍。
  • addRepaintBoundaries:表示是否将列表项(子组件)包裹在RepaintBoundary组件中。RepaintBoundary可以先理解为它是一个绘制边界,将列表项包裹在RepaintBoundary中可以避免列表项不必要的重绘,但是当列表项重绘时开销非常小时,不添加RepaintBooundary反而会更高效。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该为false。

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

ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    const Text('I'm dedicating every day to you'),
    const Text('Domestic life was never quite my style'),
    const Text('When you smile, you knock me out, I fall apart'),
    const Text('And I thought I was so smart'),
  ],
);

虽然使用默认构造函数创建的列表也是懒加载,但还是需要提前将Widget创建好,等到真正需要加载的时候才会对Widget进行布局和绘制。

ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况下。

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • itemBuilder:列表项构建器,类型为IndexedWidgetBuilder,返回一个Widget。当列表滚动到具体的index位置时,会调用该构建器列表项。
  • itemCount:列表项的数量,如果为null,则为无限列表。
ListView.builder(
  itemCount: 100,
  itemExtent: 50.0, //强制高度为50.0
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }
);

ListView.separated

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

实例:

class ListView3 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //下划线widget预定义以供复用。  
    Widget divider1=Divider(color: Colors.blue,);
    Widget divider2=Divider(color: Colors.green);
    return ListView.separated(
      itemCount: 100,
      //列表项构造器
      itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      },
      //分割器构造器
      separatorBuilder: (BuildContext context, int index) {
        return index%2==0?divider1:divider2;
      },
    );
  }
}

固定高度列表

当知道列表高度都相同时,指定itemExtent或prototypeItem。

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 LayoutLogPrint(
          tag: index, 
          child: ListTile(title: Text("$index")),
        );
      },
    );
  }
}

因为列表项都是一个ListTitle,高度相同,但是不知道ListTitle的高度是多少,这个时候指定PrototypeItem。指定itemExtent也可以,但是在列表项布局修改后,prototypeItem仍可以工作。当列表不知道具体高度,高度约束变为0.0到Infinity。

ListView原理

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

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

实例:无限加载列表

class InfiniteListView extends StatefulWidget {
  @override
  _InfiniteListViewState createState() => _InfiniteListViewState();
}

class _InfiniteListViewState extends State<InfiniteListView> {
  static const loadingTag = "##loading##"; //表尾标记
  var _words = <String>[loadingTag];

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

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: _words.length,
      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: SizedBox(
                width: 24.0,
                height: 24.0,
                child: CircularProgressIndicator(strokeWidth: 2.0),
              ),
            );
          } else {
            //已经加载了100条数据,不再获取数据。
            return Container(
              alignment: Alignment.center,
              padding: EdgeInsets.all(16.0),
              child: Text(
                "没有更多了",
                style: TextStyle(color: Colors.grey),
              ),
            );
          }
        }
        //显示单词列表项
        return ListTile(title: Text(_words[index]));
      },
      separatorBuilder: (context, index) => Divider(height: .0),
    );
  }
//模拟异步获取数据
  void _retrieveData() {
    Future.delayed(Duration(seconds: 2)).then((e) {
      setState(() {
        //重新构建列表
        _words.insertAll(
          _words.length - 1,
          //每次生成20个单词
         // generateWordPairs 三方库函数,生产单词。
generateWordPairs().take(20).map((e) => e.asPascalCase).toList(),
        );
      });
    });
  }
}

在使用的时候需要注意,比如给表添加一个固定表头。

@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
    }),
  ]);
}

这样运行会有问题,看日志可以看到是因为ListView高度无法确定,解决方法:

SizedBox(
  height: 400, //指定列表高度为400
  child: ListView.builder(
    itemBuilder: (BuildContext context, int index) {
      return ListTile(title: Text("$index"));
    },
  ),
),

可以正常运行,但是设备高度不固定,通常列表高度也是会随设备高度变化,修改如下:

SizedBox(
  //Material设计规范中状态栏、导航栏、ListTile高度分别为24、56、56 
  height: MediaQuery.of(context).size.height-24-56-56,
  child: ListView.builder(itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text("$index"));
  }),
)

但是这种方法不优雅,比如页面布局发生变化,导致表头布局调整导致表头高度改变,那么剩余空间的高度就得重新计算。解决方案是使用Flex。可以使用Expand自动拉伸组件大小,并且我们也说过Column是继承自Flex的,代码如下:

@override
Widget build(BuildContext context) {
  return Column(children: <Widget>[
    ListTile(title:Text("商品列表")),
    Expanded(
      child: ListView.builder(itemBuilder: (BuildContext context, int index) {
        return ListTile(title: Text("$index"));
      }),
    ),
  ]);
}