Flutter 中的 ListView 的使用、嵌套及监听

前言

在 Android、和 iOS 开发中,列表分别使用的是 Android 的 ListView 或 RecyclerView,iOS 的 UITableView 实现的。而在 Flutter 中实现这种需求使用的则是 ListView。


(一)ListView 的基本使用

在 Flutter 中,ListView 可以沿一个方向(垂直或水平)来排列其所有子 Widget,比如通讯录、优化卷、商品列表等。

ListView 提供了一个默认构造函数 ListView,我们可以通过设置它的 children 参数,很方便地将所有的子 Widget 包含到 ListView 中。

不过,这种创建方式要求提前将所有子 Widget 一次性创建好,而不是等到它们真正在屏幕上需要显示时才创建。所有性能会很差。因此,这种方式仅适用于列表中含有少量元素的场景。

代码如下所示:

body: ListView(
          children: <Widget>[
            ListTile(
              leading: Icon(Icons.map),
              title: Text('Map'),
              subtitle: Text('Map'),
            ),
            ListTile(
              leading: Icon(Icons.mail),
              title: Text('Mail'),
              subtitle: Text('Mail'),
            ),
            ListTile(
              leading: Icon(Icons.message),
              title: Text('Message'),
              subtitle: Text('Message'),
            ),
            ...
          ],
        )

其中,ListTile 是 Flutter 提供的用于快速构建列表项元素的一个组件元素,参数包含 leading(主导)、title(文本)、subtitle(副文本)等。

具体 ListTile 的使用细节,可以参考官方文档

效果图如下所示:


ListView 默认构造函数

除了默认的垂直方向布局外,ListView 还可以通过设置 scrollDirection 参数设置水平布局。

代码如下所示:

body: ListView(
  scrollDirection: Axis.horizontal,
  itemExtent: 140, // 宽度 140
  children: <Widget>[
    Container(
      color: Colors.pink,
    ),
    Container(
      color: Colors.black,
    ),
    Container(
      color: Colors.blue,
    ),
    Container(
      color: Colors.red,
    ),
    Container(
      color: Colors.green,
    ),
    Container(
      color: Colors.orange,
    ),
  ],
)

效果图如下所示:


水平滚动的 ListView

(二)ListView.builder 的使用

考虑到创建子 Widget 产生的性能问题,更好的方式是抽象出创建子 Widget 的方法,交由 ListView 统一管理,在真正需要展示该子 Widget 时再去创建。

ListView 的另一个构造函数 ListView.builder,则适用于子 Widget 比较多的场景。这个构造函数有两个关键参数:

  • itemBuilder,是列表项的创建方法。当列表滚动到相应位置时,ListView 会调用该方法创建对应的子 Widget。
  • itemCount,表示列表项的数量,如果为空,则表示 ListView 为无限列表。

代码如下所示:

body: ListView.builder(
    itemCount: 20,
    itemExtent: 50.0,
    itemBuilder: (BuildContext context, int index) => ListTile(
          title: Text("这是第$index个条目"),
        ))

效果图如下所示:


ListView.builder 构造函数

(三)ListView 分割线

在 ListView 中,有两种方式支持分割线:

  • 一种是,在 itemBuilder 中,根据 index 的值动态创建分割线,也就是将分割线视为列表项的一部分;
  • 另一种是,使用 ListView 的另一个构造方法 ListView.separated,单独设置分割线的样式。

与 ListView.builder 抽离出了子 Widget 的构造方法类似,ListView.separated 抽离出了分割线的创建方法 separatorBuilder,以便根据 index 设置不同样式的分割线。

代码如下所示:

body: ListView.separated(
    separatorBuilder: (BuildContext context, int index) =>
        index % 2 == 0
            ? Divider(
                height: 1,
                color: Colors.orange,
              )
            : Divider(
                height: 1,
                color: Colors.blue,
              ),
    itemCount: 20,
    itemBuilder: (BuildContext context, int index) => ListTile(
          title: Text("这是第$index个条目"),
        ))

效果图如下所示:


ListView.separated 构造函数

(四)CustomScrollView

在使用 ListView 时,对于某些特殊交互场景,比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等,还需要嵌套多个 ListView 来实现。这时,各自视图的滚动和布局模型就是相互独立、分离的,就很难保证整个页面统一一致的滑动效果。

在 Flutter 中有一个专门的控件 CustomScrollView,用来处理多个需要自定义滚动效果的 Widget。在 CustomScrollView 中,这些彼此独立的、可滚动的 Widget 被统称为 Sliver

比如,ListView 的 Sliver 实现为 SliverList,AppBar 的 Sliver 实现为 SliverAppBar。这些 Sliver 不再维护各自的滚动状态,而是交由 CustomScrollView 统一管理,最终实现滑动效果的一致性。

下面以滚动视差为例,演示 CustomScrollView 的使用方法。

视差滚动是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。

以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。

经分析得出,要实现这样的需求,我们需要两个 Sliver:作为头图的 SliverAppBar,与作为列表的 SliverList。具体的实现思路是:

  • 在创建 SliverAppBar 时,把 flexibleSpace 参数设置为悬浮头图背景。flexibleSpace 可以让背景图显示在 AppBar 下方,高度和 SliverAppBar 一样;
  • 而在创建 SliverList 时,通过 SliverChildBuilderDelegate 参数实现列表项元素的创建;
  • 最后,将它们一并交由 CustomScrollView 的 slivers 参数统一管理。

代码如下所示:

body: CustomScrollView(
  slivers: <Widget>[
    SliverAppBar(//SliverAppBar 作为头图控件
      title: Text('CustomScrollView Demo'),// 标题
      floating: true,// 设置悬浮样式
      flexibleSpace: Image.network('https://upload.jianshu.io/users/upload_avatars/7534136/e21b56cd-6ac5-4ec9-ab00-4de058f63ae2.jpg?imageMogr2/auto-orient/strip|imageView2/1/w/300/h/300/format/webp',fit:BoxFit.cover),// 设置悬浮头图背景
      expandedHeight: 300,// 头图控件高度
    ),
    SliverList(//SliverList 作为列表控件
      delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(title: Text('Item #$index')),// 列表项创建方法
        childCount: 100,// 列表元素个数
      ),
    ),
  ])

效果图如下所示:


CustomScrollView 视差滚动示例

(五)ScrollController 与 ScrollNotfication

在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制,比如,列表是否已经滑到底(顶)部?如果快速回到列表顶部?列表滚动是否已经开始,是否已经停止?

对于前两个问题,可以使用ScrollController 进行滚动信息的监听,以及相应的滚动控制;最后一个问题,需要接受 ScrollNotfication 通知进行滚动事件的获取。

(1)ScrollController

在 Flutter 中,因为 Widget 并不是渲染到屏幕的最终视觉元素(RenderObject 才是),所以我们无法像原生的 Android 或 iOS 系统那样,向持有的 Widget 对象获取或设置最终渲染相关的视觉信息,而必须通过对应的组件控制器才能实现。

ListView 的组件控制器则是 ScrollControler,我们可以通过它来获取视图的滚动信息,更新视图的滚动位置。

一般而言,获取视图的滚动信息往往是为了进行界面的状态控制,因此 ScrollController 的初始化、监听及销毁需要与 StatefulWidget 的状态保持同步。

代码如下所示:

class _MyHomePageState extends State<MyHomePage> {
  ScrollController _controller; // listView 控制器
  bool isToTop = false; // 标示目前是否需要启动 Top 按钮

  @override
  void initState() {
    print('调用了 + initState');
    _controller = ScrollController();
    _controller.addListener(() {   // 为控制器注册滚动监听方法
      if (_controller.offset > 1000) {
        // 如果 ListView 已经向下滚动了 1000,则开启 Top 按钮
        setState(() {
          isToTop = true;
        });
      } else if (_controller.offset < 300) {
        // 如果 ListView 向下滚动距离不足 300,则禁用
        setState(() {
          isToTop = false;
        });
      }
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text(widget.title),
        ),
        // 顶部 Top 按钮,根据 isToTop 变量判断是否需要注册滚动到顶部的方法
        body: Column(
          children: <Widget>[
            Container(
              child: RaisedButton(
                onPressed: (isToTop
                    ? () {
                        if (isToTop) {
                          _controller.animateTo(.0,
                              duration: Duration(milliseconds: 500),
                              curve: Curves.ease); // 做一个滚动到顶部的动画
                        }
                      }
                    : null),
                child: Text('Top'),
              ),
            ),
            Expanded(
                child: ListView.builder(
                    controller: _controller, // 初始化传入控制器
                    itemCount: 100, // 列表总数
                    itemBuilder: (context, index) => ListTile(
                          title: Text('Index:$index'),
                        )))
          ],
        ));
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
    print('调用了 + dispose');
  }
}

效果图如下所示:


ScrollController 示例
  • 首先,我们在 State 的初始化方法里,创建了 ScrollController,并通过 _controller.addListener 注册了滚动监听方法回调,根据当前视图的滚动位置,判断当前是否需要展示“Top”按钮。
  • 随后,在视图构建方法 build 中,我们将 ScrollController 对象与 ListView 进行了关联,并且在 RaisedButton 中注册了对应的回调方法,可以在点击按钮时通过 _controller.animateTo 方法返回列表顶部。
  • 最后,在 State 的销毁方法中,我们对 ScrollController 进行了资源释放。
(2)ScrollNotfication

Flutter 中为了感知 ListView 的各类滚动事件,需要获取 ScrollNotification 通知。

ScrollNotification 通知的获取是通过 NotificationListener 来实现的。与 ScrollController 不同的是,NotificationListener 是一个 Widget,为了监听滚动类型的事件,需要将 NotificationListener 添加为 ListView 的父容器,从而捕获 ListView 中的通知。而这些通知,需要通过 onNotification 回调函数实现监听逻辑:

代码如下所示:

  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'ScrollController Demo',
        home: Scaffold(
            appBar: AppBar(title: Text('ScrollController Demo')),
            body: NotificationListener<ScrollNotification>(
              // 添加 NotificationListener 作为父容器
              onNotification: (scrollNotification) {
                // 注册通知回调
                if (scrollNotification is ScrollStartNotification) {
                  // 滚动开始
                  print('Scroll Start');
                } else if (scrollNotification is ScrollUpdateNotification) {
                  // 滚动位置更新
                  print('Scroll Update');
                } else if (scrollNotification is ScrollEndNotification) {
                  // 滚动结束
                  print('Scroll End');
                }
              },
              child: ListView.builder(
                itemCount: 30, // 列表元素个数
                itemBuilder: (context, index) =>
                    ListTile(title: Text("Index : $index")), // 列表项创建方法
              ),
            )));
  }

打印结果如下所示:


NotificationListener 监听结果

相比于 ScrollController 只能和具体的 ListView 关联后才可以监听到滚动信息;通过 NotificationListener 则可以监听其子 Widget 中的任意 ListView,不仅可以得到这些 ListView 的当前滚动位置信息,还可以获取当前的滚动事件信息 。

总结

在处理用于展示一组连续、可滚动的视图元素的场景,Flutter 提供了比原生系统更加强大的列表组件 ListView 和 CustomScrollView,不仅可以支持单一视图下可滚动 Widget 的交互模型及 UI 控制模型,对于某些特殊交互,需要嵌套多重可滚动 Widget 的场景,也提供了统一管理机制,最终实现体验一致的滑动效果。这些强大的组件,不仅可以开发出样式丰富的界面,更可以实现复杂的交互。

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

推荐阅读更多精彩内容