Flutter入门-滚动类组件-Listview、GridView以及PageView

513 阅读4分钟

Scrolling分类中比较常用的ListView、GridView、PageView

ListView

ListView 是一个线性布局的widgets 列表. ListView -extends->BoxScrollView -extends->ScrollView -extends->StatelessWidget

在构建ListView时有4种选择:

  • 利用ListView构造函数。此构造函数适合于具有少量子元素的列表视图,因为上种方法创建的ListView 会将子Item一次性绘制出来,如果子Item 多的话,会造成页面卡顿。
  • ListView.builder利用IndexedWidgetBuilder来按需构造。这个构造函数适合于具有大量(或无限)子视图的列表视图,因为构建器只绘制可见的Item。
  • 使用ListView.separated构造函数,采用两个IndexedWidgetBuilder:itemBuilder根据需要构建子项separatorBuilder类似地构建出现在子项之间的分隔符子项。此构造函数适用于具有固定数量的子控件的列表视图。
  • 使用ListView.custom的SliverChildDelegate构造,它提供了定制子模型的其他方面的能力。 例如,SliverChildDelegate可以控制用于估计实际上不可见的孩子的大小的算法。

加载少量数据时可以使用

本方法创建的ListView ,会将子Item一次性绘制出来

      ListView(
            //item 高度会适配 item填充的内容的高度
            shrinkWrap: true,
            padding: EdgeInsets.all(20.0),
            children: <Widget>[
              new Text(
                "test",
                style: new TextStyle(fontSize: 18.0, color: Colors.red),
              ),
              new Text(
                "${list[0].age}",
                style: new TextStyle(fontSize: 18.0, color: Colors.green),
              ),
              new Text(
                "${list[0].content}",
                style: new TextStyle(fontSize: 18.0, color: Colors.blue),
              ),
            ],
          );

ListView.builder() 常用

假如有 1000 个列表,初始渲染时并不会所有都渲染,而只会特定数量的 item

在这里插入图片描述

    ListView.builder(
                // item 的个数
                itemCount: 20,
                //设置滑动方向 Axis.horizontal 水平  默认 Axis.vertical 垂直
                scrollDirection: Axis.vertical,
                //内间距
                padding: EdgeInsets.all(10.0),
                //是否倒序显示 默认正序 false  倒序true
                reverse: false,
                //false,如果内容不足,则用户无法滚动 而如果[primary]为true,它们总是可以尝试滚动。
                primary: true,
                //确定每一个item的高度 会让item加载更加高效
                itemExtent: 50.0,
                //item 高度会适配 item填充的内容的高度 多用于嵌套listView中 内容大小不确定 比如 垂直布局中 先后放入文字 listView (需要Expend包裹否则无法显示无穷大高度 但是需要确定listview高度 shrinkWrap使用内容适配不会) 文字
                shrinkWrap: true,
                //滑动类型设置
                //new AlwaysScrollableScrollPhysics() 总是可以滑动 NeverScrollableScrollPhysics禁止滚动 BouncingScrollPhysics 内容超过一屏 上拉有回弹效果 ClampingScrollPhysics 包裹内容 不会有回弹
                //        cacheExtent: 30.0,  //cacheExtent  设置预加载的区域   cacheExtent 强制设置为了 0.0,从而关闭了“预加载”
                physics: new ClampingScrollPhysics(),
                //滑动监听
                //        controller ,
                itemBuilder: (BuildContext context, int index) {
                  //设置子条目
                  return ListTile(
                    title: Text("title $index"),
                    // item 标题
                    leading: Icon(Icons.keyboard),
                    // item 前置图标
                    subtitle: Text("subtitle $index"),
                    // item 副标题
                    trailing: Icon(Icons.keyboard_arrow_right),
                    // item 后置图标
                    isThreeLine: false,
                    // item 是否三行显示
                    dense: true,
                    // item 直观感受是整体大小
                    contentPadding: EdgeInsets.all(10.0),
                    // item 内容内边距
                    enabled: true,
                    onTap: () {
                      print('点击:$index');
                    },
                    // item onTap 点击事件
                    onLongPress: () {
                      print('长按:$index');
                    },
                    // item onLongPress 长按事件
                    selected: false, // item 是否选中状态
                  );
                },
              ),

ListTile 是Flutter 提供好的常用widget ,包括文字,icon,点击事件

ListView.separated()

带分割线的item,separated 相比较于 builder,又多了一个参数 separatorBuilder ,用于控制列表各个元素的间隔如何渲染。

在这里插入图片描述

  ListView.separated(
              scrollDirection: Axis.vertical,
              itemCount: 100, // item 的个数
              separatorBuilder: (BuildContext context, int index) => Divider(height:1.0,color: Colors.blue),  // 添加分割线
              itemBuilder: (BuildContext context, int index) {
                return ListTile(
                  title:  Text("title $index"), // item 标题
                  leading: Icon(Icons.keyboard), // item 前置图标
                  subtitle: Text("subtitle $index"), // item 副标题
                  trailing: Icon(Icons.keyboard_arrow_right),// item 后置图标
                  isThreeLine:false,  // item 是否三行显示
                  dense:true,                // item 直观感受是整体大小
                  contentPadding: EdgeInsets.all(10.0),// item 内容内边距
                  enabled:true,
                  onTap:(){print('点击:$index');},// item onTap 点击事件
                  onLongPress:(){print('长按:$index');},// item onLongPress 长按事件
                  selected:false,     // item 是否选中状态
                );
              },
            ),
            

ListView.custom

 ListView.custom(
            scrollDirection: Axis.vertical,
            childrenDelegate:
                SliverChildBuilderDelegate((BuildContext context, int index) {
              return Container(
                height: 50.0,
                alignment: Alignment.center,
                color: Colors.lightBlue[100 * (index % 9)],
                child: Text('list item $index'),
              );
            }, childCount: 50),
          ),

常用属性

  • 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 插入广告

GridView

GridView的使用方法和ListView类似,它有五种构造函数:

  1. 默认构造函数GridView。
  2. GridView.count:在横轴方向上具有固定数量的GridView。
  3. GridView.extent:在横轴方向上具有最大范围的GridView。
  4. GridView.builder:适用于具有大量(或无限)列表项。
  5. GridView.custom:提供了自定义子Widget的能力。

这里以第2种构造函数为例。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter',
      home: Scaffold(
        appBar: AppBar(
          title: new Text('GridView示例'),
        ),
        body: GridView.count(
          crossAxisCount: 3, //1
          children: <Widget>[
            ListTile(
              title: Text('item1'),
            ),
            ListTile(
              title: Text('item2'),
            ),
            ListTile(
              title: Text('item3'),
            ),
            ListTile(
              title: Text('item4'),
            ),
            ListTile(
              title: Text('item5'),
            ),
            ListTile(
              title: Text('item6'),
            ),
            ListTile(
              title: Text('item7'),
            ),
            ListTile(
              title: Text('item8'),
            ),
            ListTile(
              title: Text('item9'),
            ),
          ],
        ),
      ),
    );
  }
}

注释1处的crossAxisCount用于设置横轴item的数量。效果如下图所示:

VBCOyT.png

3 PageView

PageView是一个可逐页滚动的列表,和Android中ViewPage类似。 PageView有三种构造函数:

  1. 默认构造函数PageView
  2. PageView.builder:适用于具有大量(或无限)列表项。
  3. PageView.custom:提供了自定义子Widget的能力。

以默认构造函数为例,代码如下所示。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter',
      home: Scaffold(
        appBar: AppBar(
          title: Text('PageView示例'),
        ),
        body: PageView(
          onPageChanged: (index) {//1
            print('当前为第 $index 页');
          },
          children: <Widget>[
            ListTile(
              title: Text('第0页'),
            ),
            ListTile(
              title: Text('第1页'),
            ),
            ListTile(
              title: Text('第2页'),
            ),
          ],
        ),
      ),
    );
  }
}

通过onPageChanged属性可以得知当前滑动到了第几页。

VBk9Yj.png