Flutter - Listview 详解

7,734 阅读11分钟

未完~

列表到哪里都是一个麻烦的东西,因为现在开发出来的玩法太多啦,像:基本的多类型 item适配器设计复杂item 视图设计item 更新粒度层次优化列表缓存设计原理和优化列表显示优化列表嵌套滑动冲突嵌套滚动和其他 view 的联动 等等方面,不管到哪个平台都得搞懂这些才能玩的转

Flutter 中的列表我个人觉得比 Android 设计的更好一些,首先名字回归 listview,见名知已,简单明了。其次 Flutter 使用 build 接口来代替 adapter,并且分隔线之类的也看做 item 使用 build来处理,这就比 android 好写,好明白多了,也更灵活,对列表本身的破坏更小


4种构建方式

Flutter 的 listview 有4种构建方式,也就是有4个 构造方法,核心思路就是把生成 item 的部分看成一个 对象函数(各种build) 传进构造方法中。 Flutter 的 listview 真的喜欢 build 设计模式,不光 item,分割线,多类型都是包装成 build

  • listview - 这种构建方式是把固定数据变成 item 传给 childen
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView(
      padding: EdgeInsets.all(20),
       children: <Widget>[
         Text("AA"),
         Text("BB"),
         Text("CC"),
       ],
    );
  }
  • ListView.builder - 构建一种 item 类型的列表,当然在 itemBuilder 使用 if、slse 也是能支持多类型 item 的,itemCount 是列表数量
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
      padding: EdgeInsets.all(20),
      itemCount: 50,
      itemBuilder: (context, index) {
        return Text("item:${index}");
      },
    );
  }
  • ListView.separated - 带分割线的列表, Flutter中把分割线也是看成一个 widget 来处理的,这里新增了 separatorBuilder 来返回分割线 widget,不过分割线的特点是:不包含最后一项,也就是分割线不会出列表的范围。这中设计我很喜欢,把分割线或是分隔符看成列表的 item,可以极大的方便我们设计列表,可玩性灵活性可以大大增加,至少我们可以根据 index 找到前后 item 的类型,然后考虑可以采取不同类型的分隔 item,最典型的应用就是插入广告了,不用对列表有任何修改
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.separated(
      padding: EdgeInsets.all(20),
      itemCount: 50,
      itemBuilder: (context, index) {
        return Text("item:${index}");
      },
      separatorBuilder: (context,index){
        return Container(
          height: 1,
          color: Colors.pink,
        );
      },
    );
  }
  • ListView.custom() - 需要传入一个实现了 SliverChildDelegate 的组件,如 SliverChildListDelegate 和 SliverChildBuilderDelegate。这里我不详细说,因为没必要,SliverChildListDelegate 就是 listview,SliverChildBuilderDelegate 就是 ListView.builder。这个 ListView.custom 用的也比较少

常用属性

  • ScrollDirection - 滚动方向,支持横向滚动,默认是纵向滚动
scrollDirection: Axis.horizontal,
  • reverse - 决定滚动方向是否与阅读方向一致,true 表示不一样,显示时是列表直接滚动到最底下,最后一个 item 显示第一个数据
  • scrollController - 主要作用是控制滚动位置和监听滚动事件,下面我会单开一节详细的解释下
  • primary - 当内容不足以滚动时,是否支持滚动;对于iOS系统还有一个效果:当用户点击状态栏时是否滑动到顶部。这个挺重要的,尤其是使用列表构建页面时配合下拉刷新,要是数据量小于屏幕高度的话,这个就管用了。此时系统会自动给 listview 设置一个默认的滚动控制器 PrimaryScrollController,好处是父组件可以控制子树中可滚动组件的滚动行为
  • itemExtent - 可以直接设置列表项高度,可以提高列表性能
  • shrinkWrap - 是否根据子组件的总长度来设置 ListView 的长度,默认值为 false,所以能滚动。滚动组件相互嵌套时,shrinkWrap 属性要设置 true 才行,和 NeverScrollableScrollPhysics 配合就能解决滚动冲突
  • addAutomaticKeepAlives - 该属性表示是否将列表项(子组件)包裹在AutomaticKeepAlive组件中,在一个懒加载列表中,如果将列表项包裹在AutomaticKeepAlive中,在该列表项滑出视口时也不会被回收,它会使用KeepAliveNotification来保存其状态。如果列表项自己维护其KeepAlive状态,那么此参数必须置为false
  • addRepaintBoundaries - 该属性表示是否将列表项(子组件)包裹在RepaintBoundary组件中。当可滚动组件滚动时,将列表项包裹在RepaintBoundary中可以避免列表项重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary反而会更高效。和addAutomaticKeepAlive一样,如果列表项自己维护其KeepAlive状态,那么此参数必须置为false
  • cacheExtent - 列表在你快要滑到加载数据的时候,会提前一步加载好,等到你滑到的时候就会显示出来,而不至于用户滑到的时候还需要等待一会儿,cacheExtent 就是列表显示的 item 数量,包活预加载的 item,我们可以根据列表长度和 item' 高度自己计算下,合理的配置

scrollController 滚动控制

大家一看这个名字里带 Controller 那就说明是对列表进行控制的功能的。scrollController 主要功能就是监听滚动状态,提供方法滚动到列表指定位置

1. scrollController 构造函数

ScrollController({
  double initialScrollOffset = 0.0, //初始滚动位置
  this.keepScrollOffset = true,//是否保存滚动位置
  ...
})

scrollController 需要我们 new 一个对象出来,通过构造函数中的这2个参数,我们可以设置选择列表从哪里开始显示,配合 itemExtent 每列固定高度设置是个不错的思路

2. 核心参数、方法:

  • offset - 当前滚动到的位置,注意这个数据是累计值,不是每次滚动的量
  • jumpTo(double offset) - 滚动到指定位置,不带动画
  • animateTo(double offset,...) - 滚动到指定位置,带动画,可以指定时间

3. 使用

  • 先 new 一个 scrollController 对象出来
  • 在 initState 中给 scrollController 添加监听
  • 在 布局中把 scrollController 设置给 listview
class TestWidgetState extends State<TestWidget> {
  var scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    scrollController.addListener(() {
      print("当前滚动位置:${scrollController.offset}");
    });
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
      padding: EdgeInsets.all(20),
      physics: BouncingScrollPhysics(),
      itemCount: 50,
      controller: scrollController,
      itemBuilder: (context, index) {
        return Container(
          width: 50,
          height: 30,
          alignment: Alignment.center,
          child: Text("item:${index}"),
        );
      },
    );
  }
  
  void dispose() {
    //为了避免内存泄露,需要调用 dispose
    scrollController.dispose();
    super.dispose();
  }
}

4. 特点

  • scrollController 可以添加多个 Listener 呢的,从其方法 scrollController.addListener 就能看的出来,走进源码中 _listeners 是一个集合类型
ObserverList<VoidCallback> _listeners = ObserverList<VoidCallback>();
  • scrollController.offset 滚动的数值是从 0 开始的,可见和 Android 一样,滚动数值,可滚动的最大数值,屏幕可见高度 这3者是相互分开的
I/flutter ( 7435): 当前滚动位置:1.1666666666666667
I/flutter ( 7435): 当前滚动位置:2.621212121212163
I/flutter ( 7435): 当前滚动位置:3.7121212121212848
I/flutter ( 7435): 当前滚动位置:4.803030303030293
I/flutter ( 7435): 当前滚动位置:5.530303030303041
I/flutter ( 7435): 当前滚动位置:6.621212121212163
I/flutter ( 7435): 当前滚动位置:7.348484848484911
I/flutter ( 7435): 当前滚动位置:8.07575757575766

5. jumpTo、animateTo

这2个方法都是指定列表滚动带指定位置,目前只能滚动到指定 px,正在研究没有没方法指定到指定 index 的 item

  • jumpTo - 这个方法好说,只有一个 offset 的参数就完了
scrollController.jumpTo(500);
  • animateTo - 这可就不一样了,是带动画的,可以指定时间,插值器,不过这2者要设置必须一起设置,单个不行
scrollController.animateTo(500,duration: Duration(milliseconds: 300), curve: Curves.ease);

下面是一个 GIF 演示:

6. ScrollPosition

ScrollController 可是能设置给多个可滚动组件的,其原理就是 ScrollController 会给每一个设置到其中的可滚动组件生成一个 ScrollPosition,ScrollController positions 属性存储这些数据

自然所有关于滚动的数据都存储在 ScrollPosition 里面,比如滚动数值:

double get offset => position.pixels;

但是 offset 这个数值返回的处于显示的或是最上层位置的滚动组件的滚动数据,ScrollController 若是绑定了多个可滚动组件的话这个 offset 就不准了,并且 jumpTo、animateTo 方法会对 ScrollController 中所有已绑定的可滚动组件进行形同数值的滚动,这点要注意啦

当然我们也不是一点办法都没有:

controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels

这样可以拿到不通滚动组件的滚动值,但是我们得明确的知道可滚动组件当初的插入顺序


NotificationListener 滚动监听

上文我们用 scrollController 监听列表滚动,这里我们还有另一种思路,传承与 Android 的嵌套滚动思路

Android 中有 NestedScrollingParent、NestedScrollingChild 这一对接口,NestedScrollingChild 发送滚动事件,NestedScrollingParent 控制滚动事件的传播和数值

Flutter 继承这一思路。在 widget 树中,可滚动 widget 滚动时会逐次向上传递滚动事件 notification,我们可以通过 NotificationListener 可以监控到该 notification,NotificationListener 也是一个 widget,只要 NotificationListener 的布局层级比 listview 高就行,隔几层都没关系,一样可以监听的到

只要是 Flutter 中的滚动 widget,如:ScrollView、ListView、PageView 等都能使用 NotificationListener 监听滚动事件

  • android 中的经典应用的是:Behavior CoordinatorLayout RecycleView
  • flutter 中则是: NotificationListener listview

1. 监听方法

NotificationListener 中 onNotification(ScrollNotification notification) 方法可以拿到滚动事件,数值包裹在 ScrollNotification 这个参数总。需要我们返回一个 boolean 值:

  • true - 那么 notifcation 到此为止,我们拦截了滚动事件,不会继续上传滚动事件,但是不影响 listview 自身 widget 的滚动
  • false - 那 么notification 会继续向更外层 widget 传递

2. 监听到的数据

滚动事件的数据是 ScrollNotification 类型的,具体参数都在其 metrics 参数中:

  • metrics.pixels - 当前位置,以 0 开始,默认是0
  • metrics.atEdge - 是否在顶部或底部
  • metrics.axis - 垂直或水平滚动
  • metrics.axisDirection - 滚动方向是 down 还是 up,测试了下,都是 down
  • metrics.extentAfter - widget 底部距离列表底部有多大
  • metrics.extentBefore - widget 顶部距离列表顶部有多大
  • metrics.extentInside - widget 范围内的列表长度
  • metrics.maxScrollExtent - 最大滚动距离,列表长度 - widget 长度
  • metrics.minScrollExtent - 最小滚动距离
  • metrics.viewportDimension - widget 长度
  • metrics.outOfRange - 是否越过边界

3. 经测试

文档的解释不一定准,还得我们自己试下才行的

  • axisDirection - 一直都是 down 的,我们还是根据数值的变化自己判断更准确
  • pixels - 上拉加载更多数值越来越大,下拉刷新数值越来越小,一直到0
  • atEdge - 的确可以判断是否到顶或是到底,到顶或是底时,的确会变成 true 的
  • 滚动数值这块 - extentAfter + extentBefore 的确= maxScrollExtent,说明数值这块大家不用担心
  • widget 长度 - viewportDimension 和 extentInside 数值是一样的
  • outOfRange - 默认到底或是到顶是 false 的,只有我们配合 physics 属性继续滚动时才会变成 true,大家注意临界值

4. 示例:

  Widget build(BuildContext context) {
    return NotificationListener(
      onNotification: (ScrollNotification notification) {
        print("pixels:${notification.metrics.pixels}");
        print("atEdge:${notification.metrics.atEdge}");
        print("axis:${notification.metrics.axis}");
        print("axisDirection:${notification.metrics.axisDirection}");
        print("extentAfter:${notification.metrics.extentAfter}");
        print("extentBefore:${notification.metrics.extentBefore}");
        print("extentInside:${notification.metrics.extentInside}");
        print("maxScrollExtent:${notification.metrics.maxScrollExtent}");
        print("minScrollExtent:${notification.metrics.minScrollExtent}");
        print("viewportDimension:${notification.metrics.viewportDimension}");
        print("outOfRange:${notification.metrics.outOfRange}");
        print("____________________________________________");
        return true;
      },
      child: Container(
        child: Stack(
          children: <Widget>[
            ListView.builder(
              padding: EdgeInsets.all(20),
              physics: BouncingScrollPhysics(),
              itemCount: 50,
              itemExtent: 35,
              controller: scrollController,
              itemBuilder: (context, index) {
                return Container(
                  alignment: Alignment.center,
                  child: Text("item:${index}"),
                );
              },
            ),
            Positioned(
              left: 20,
              top: 20,
              child: RaisedButton(
                child: Text("点击滚动"),
                onPressed: () {
                  scrollController.animateTo(500,
                      duration: Duration(milliseconds: 300),
                      curve: Curves.ease);
                  print("AAA");
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

physics

physics 这就是 Flutter 上的 Behavior,依托与上面讲的嵌套滚动,他可以让我们在列表划到顶或是底时对整个列表进行额外的操作,最典型的就是下拉到顶然后回弹了

代码:

physics: BouncingScrollPhysics(),

系统提供几个默认实现,目前不清楚是或否可以自定义:

  • NeverScrollablePhysics - 列表不可以滚动
  • BouncingScrollPhysics - 回弹效果,就是上面的那个 gif 的效果
  • ClampingScrollPhysics - 系统默认的,到头了显示水波纹
  • PageScrollPhysics - 是给 PageView 用的,如果 listview 设置的话在滑动到末尾时会有个比较大的弹起和回弹
  • AlwaysScrollableScrollPhysics - 列表总是可滚动的。iOS 上会有回弹效果,但在 android 上没有效果。如果 primary 设置为 false,但是设置 AlwaysScrollableScrollPhysics 的话,列表此时是可以滑动的
  • FixedExtentScrollPhysics - 这个必须配合响应的 widget 才行,listview 是不能用,具体的以后再写

:一般


滚动数据缓存

listview 继承自:Scrollable,本身是一个 StatefulWidget,自然也能保存数据,滚动偏移量自然也能保存,所以主要 listview 不从 widget 树上移出,那么滚动状态一直都在

但是有的时候因 widget 树的变化 listview 会被移除,比如:TabBarView 在切换 Tab 时可滚动组件的 State 会被销毁,此时我们要是想要在 tab 来回切换时能显示上次滚动到的位置,那么必须能缓存滚动偏移量才行

这里我们借助 PageStorage,他是一个用于保存页面(路由)相关数据的组件,PageStorage 的声明周期是整个 app,不管有多少页面都可以保存数据不丢失,核心就是通过设置 PageStorageKey 到 wigdet 构造函数的 可以就行

我们给每个 tab 设置自己字符串的 PageStorageKey 就成了

new TabBarView(
   children: myTabs.map((Tab tab) {
    new MyScrollableTabView(
      key: new PageStorageKey<String>(tab.text), // like 'Tab 1'
       tab: tab,
     ),
   }),
 )

目前这块就这么多


固定式头部 widget

列表中我们总是有固定头部这个需求,头部 view 不顺着列表滚动。这里说一个最简便的实现方法:column + expanded。Column 继承自 Flex,可以自动实现 widget 长度适应,Expanded 可以自动拉伸组件的大小,所以用它俩来做很适合

  Widget build(BuildContext context) {
    // TODO: implement build
    return Column(
      children: <Widget>[
        Text("我是头部"),
        Expanded(
          child: ListView.builder(
            padding: EdgeInsets.all(20),
            physics: BouncingScrollPhysics(),
            cacheExtent: 10,
            itemCount: 50,
            itemExtent: 35,
            controller: scrollController,
            itemBuilder: (context, index) {
              return Container(
                alignment: Alignment.center,
                child: Text("item:${index}"),
              );
            },
          ),
        ),
      ],
    );
  }
}


常用设计思路

  • ListView.separated 插入广告