Flutter ListView 实战快速上手

1,118 阅读7分钟

本文微信公众号「AndroidTraveler」首发。

背景

本篇主要讲述如何快速在 Flutter 中实现 ListView。

效果图

先上效果图感受一下:

基本实现

1. 确定 Item 项布局

首先我们要先确定我们列表项的布局,我们按照我们效果图上面所显示的,可以写出如下代码:

import 'package:flutter/material.dart';

class ItemWidget extends StatefulWidget {
  @override
  _ItemWidgetState createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text('title'),
        SizedBox(height: 6,),
        Text('description')
      ],
    );
  }
}

显示效果如下:

当然这里的 titledescription 目前是 hard code,我们第二步确定 Bean 之后会做相应的处理。

2. 确定数据源

我们根据列表项的显示情况可以得到如下 Bean:

class ItemBean {
  final String title;
  final String description;

  ItemBean(this.title, this.description);
}

可以看到就是标题和描述而已。

同时我们第一步的列表项可以更新如下:

import 'package:flutter/material.dart';
import 'package:my_flutter/item_bean.dart';

class ItemWidget extends StatefulWidget {

  final ItemBean itemBean;

  ItemWidget(this.itemBean);

  @override
  _ItemWidgetState createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(widget?.itemBean?.title ?? ''),
        SizedBox(height: 6,),
        Text(widget?.itemBean?.description ?? '')
      ],
    );
  }
}

不再 hard code 了。

另外如果你对于 ?. 和 ?? 不熟悉,可以看下我之前的文章 Dart 如何优雅的避空

3. 显示

有了数据源和显示的 Widget,那么显示也就水到渠成了。

如下:

import 'package:flutter/material.dart';
import 'package:my_flutter/item_bean.dart';
import 'package:my_flutter/item_widget.dart';

class ListViewWidget extends StatefulWidget {
  @override
  _ListViewWidgetState createState() => _ListViewWidgetState();
}

class _ListViewWidgetState extends State<ListViewWidget> {
  final List<ItemBean> itemBeans = [];

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

    _initData();
  }

  /// 实际场景可能是从网络拉取,这里演示就直接填充数据源了
  void _initData() {
    itemBeans.add(ItemBean('第一句', '关注微信公众号「AndroidTraveler」'));
    itemBeans.add(ItemBean('第二句', '星河滚烫,你是人间理想'));
    itemBeans.add(ItemBean('第三句', '我明白你会来,所以我等。'));
    itemBeans.add(ItemBean('第四句', '家人闲坐,灯火可亲。'));
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: ListView.builder(
        itemCount: itemBeans.length,
        itemBuilder: (context, index) {
          return ItemWidget(itemBeans[index]);
        },
      ),
    );
  }
}

列表的关键代码在于:

ListView.builder(
    itemCount: itemBeans.length,
    itemBuilder: (context, index) {
        return ItemWidget(itemBeans[index]);
    },
)

还是比较固定的。

最后我们把这个 ListViewWidget 加载到主页面,主页面代码如下:

import 'package:flutter/material.dart';
import 'package:my_flutter/listview_widget.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: _buildWidget(),
        ),
      ),
    );
  }

  Widget _buildWidget() {
    return ListViewWidget();
  }
}

运行效果如下:

添加分隔线

看起来还是怪怪的,我们增加下分隔线看看效果。

Flutter 官方 sdk 里面自带了分隔线 Widget,为 Divider

具体每个属性可以在代码里面看到详细注释,这里就不展开了。

我们的 Divider 代码如下:

Divider(color: Colors.grey,),

很简单,就是指定分隔线的颜色。

因为我们的 Item 本身就是一个 Column,我们直接追加就可以了。

ItemWidget 修改后如下:

···

class _ItemWidgetState extends State<ItemWidget> {
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.only(left: 16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(widget?.itemBean?.title ?? ''),
          SizedBox(
            height: 6,
          ),
          Text(widget?.itemBean?.description ?? ''),
          Divider(color: Colors.grey),
        ],
      ),
    );
  }
}

效果如下:

可能有小伙伴会说,你这个是刚好 item 布局是 Column,如果不是 Column 的话呢?

方法多种多样,这里就说其中的一种方法吧,比如你可以利用 Stack 来实现。

代码位置:github.com/nesger/Flut…

添加点击回调

我们知道,列表成功显示只是第一步而已,点击能够实现我们期望的效果才是常规操作。

因此,点击回调是必不可少的。

那么如何实现呢?

其实也很简单,就是跟普通 Widget 一样包裹一层 GestureDetector 就可以了。

修改后的 ItemWidget 如下:

···

class _ItemWidgetState extends State<ItemWidget> {
  @override
  Widget build(BuildContext context) {
    Widget container = Container(
      padding: EdgeInsets.only(left: 16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(widget?.itemBean?.title ?? ''),
          SizedBox(
            height: 6,
          ),
          Text(widget?.itemBean?.description ?? ''),
          Divider(color: Colors.grey),
        ],
      ),
    );
    return GestureDetector(
      child: container,
      onTap: (){
        print('onTap');
      },
    );
  }
}

点击 Item 时控制台确实输出了打印日志:

flutter: onTap
flutter: onTap

但是存在两个问题。

第一个就是不知道点击的是哪一个 item,第二个就是一般回调应该是在外层而不应该直接写在里面。

因此我们需要对 ItemWidget 做修改,传入 index 和监听回调。

我们定义的回调接口如下:

/// 定义一个回调接口
typedef OnItemClickListener = void Function(int position, ItemBean itemBean);

ItemWidget 修改后代码如下:

import 'package:flutter/material.dart';
import 'package:my_flutter/item_bean.dart';

/// 定义一个回调接口
typedef OnItemClickListener = void Function(int position, ItemBean itemBean);

class ItemWidget extends StatefulWidget {
  final int position;
  final ItemBean itemBean;
  final OnItemClickListener listener;

  ItemWidget(this.position, this.itemBean, this.listener);

  @override
  _ItemWidgetState createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  @override
  Widget build(BuildContext context) {
    Widget container = Container(
      padding: EdgeInsets.only(left: 16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(widget?.itemBean?.title ?? ''),
          SizedBox(
            height: 6,
          ),
          Text(widget?.itemBean?.description ?? ''),
          Divider(color: Colors.grey),
        ],
      ),
    );
    return GestureDetector(
      child: container,
      onTap: () => widget.listener(widget.position, widget.itemBean),
    );
  }
}

可以看到我们增加了 position 和 listener。

因此我们的 ListViewWidget 也需要做相应修改:

class ListViewWidget extends StatefulWidget {

  final OnItemClickListener listener;

  ListViewWidget(this.listener);

  @override
  _ListViewWidgetState createState() => _ListViewWidgetState();
}

class _ListViewWidgetState extends State<ListViewWidget> {

    ···
    
    @override
    Widget build(BuildContext context) {
        return Container(
          color: Colors.white,
          child: ListView.builder(
            itemCount: itemBeans.length,
            itemBuilder: (context, index) {
              return ItemWidget(index, itemBeans[index], widget.listener);
            },
          ),
        );
    }
}

可以看到改动项就是传入了 listener 并且在 itemBuilder 返回的时候对应传入参数给 ItemWidget。

然后我们在 main.dart 修改如下:

···

class MyApp extends StatelessWidget {

    ···
    
    Widget _buildWidget() {
        return ListViewWidget((position, itemBean){
          print('pos=$position, title='+itemBean.title+",description="+itemBean.description);
        });
    }
}

点击列表,控制台输出期望效果如下:

flutter: pos=0, title=第一句,description=关注微信公众号「AndroidTraveler」
flutter: pos=1, title=第二句,description=星河滚烫,你是人间理想

代码位置:github.com/nesger/Flut…

添加点击视觉反馈

点击是实现了,但是点击之后没有一点点反馈,用户怎么知道自己是不是点击了呢?

因此点击后的视觉反馈也是必不可少的。

那么这个点击后的反馈怎么处理呢?

其实还是离不开 GestureDetector 的回调监听。

当按下时,我们更新颜色值,当抬起或取消时我们恢复颜色值。

因此我们可以修改 ItemWidget 如下:

···

class _ItemWidgetState extends State<ItemWidget> {

  Color _color;

  @override
  void initState() {
    super.initState();
    _color = Colors.white;
  }

  @override
  Widget build(BuildContext context) {
    Widget container = Container(
      color: _color,
      padding: EdgeInsets.only(left: 16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(widget?.itemBean?.title ?? ''),
          SizedBox(
            height: 6,
          ),
          Text(widget?.itemBean?.description ?? ''),
          Divider(color: Colors.grey),
        ],
      ),
    );
    return GestureDetector(
      child: container,
      onTap: () => widget.listener(widget.position, widget.itemBean),
      onTapDown: (_) => _updatePressedColor(),
      onTapUp: (_) => _updateNormalColor(),
      onTapCancel: () => _updateNormalColor(),
    );
  }

  void _updateNormalColor() {
    setState(() {
      _color = Colors.white;
    });
  }

  void _updatePressedColor() {
    setState(() {
      _color = Color(0xFFF0F1F2);
    });
  }
}

效果如下:

可以看到分隔线有点问题,主要原因是 Divider 默认高度是 16.0,所以我们调整下,同时改下 item 的上下间隔。

修改如下:

···

class _ItemWidgetState extends State<ItemWidget> {

  ···

  @override
  Widget build(BuildContext context) {
    Widget container = Container(
      color: _color,
      padding: EdgeInsets.only(left: 16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          SizedBox(
            height: 8,
          ),
          Text(widget?.itemBean?.title ?? ''),
          SizedBox(
            height: 6,
          ),
          Text(widget?.itemBean?.description ?? ''),
          SizedBox(
            height: 8,
          ),
          Divider(color: Colors.grey, height: 0.5,),
        ],
      ),
    );
    return GestureDetector(
      child: container,
      onTap: () => widget.listener(widget.position, widget.itemBean),
      onTapDown: (_) => _updatePressedColor(),
      onTapUp: (_) => _updateNormalColor(),
      onTapCancel: () => _updateNormalColor(),
    );
  }

  void _updateNormalColor() {
    setState(() {
      _color = Colors.white;
    });
  }

  void _updatePressedColor() {
    setState(() {
      _color = Colors.grey;
    });
  }
}

效果如下:

但是如果你不是长按,而是快速点击,会发现没有效果。

所以我们需要给抬起恢复来个延时,修改如下:

···

void _updateNormalColor() {
    Future.delayed(Duration(milliseconds: 100), () {
      setState(() {
        _color = Colors.white;
      });
    });
}

···

效果如下:

代码位置:github.com/nesger/Flut…

多种布局处理

这个其实也不难。

我们知道 ListView 的核心代码是:

ListView.builder(
    itemCount: itemBeans.length,
    itemBuilder: (context, index) {
        return ItemWidget(itemBeans[index]);
    },
)

因此只需要在 itemBuilder 这里做文章。

举个例子,假设我要求要显示一个纯色块在顶部。

那么我们可以如下修改

···

class _ListViewWidgetState extends State<ListViewWidget> {

  ···

  /// 实际场景可能是从网络拉取,这里演示就直接填充数据源了
  void _initData() {
    itemBeans.add(ItemBean('', ''));

    itemBeans.add(ItemBean('第一句', '关注微信公众号「AndroidTraveler」'));
    itemBeans.add(ItemBean('第二句', '星河滚烫,你是人间理想'));
    itemBeans.add(ItemBean('第三句', '我明白你会来,所以我等。'));
    itemBeans.add(ItemBean('第四句', '家人闲坐,灯火可亲。'));
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: ListView.builder(
        itemCount: itemBeans.length,
        itemBuilder: (context, index) {
          if (index == 0) {
            return Container(
              color: Colors.blue,
              height: 66,
            );
          } else {
            return ItemWidget(index, itemBeans[index], widget.listener);
          }
        },
      ),
    );
  }
}

这里通过在一开始添加一个空 Bean,然后在 itemBuilder 做判断返回对应布局来实现。

当然你也可以不在集合添加,但是 index 需要更改,并且列表长度也要修改,等价代码如下:

···

class _ListViewWidgetState extends State<ListViewWidget> {

  ···

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: ListView.builder(
        itemCount: itemBeans.length + 1,
        itemBuilder: (context, index) {
          if (index == 0) {
            return Container(
              color: Colors.blue,
              height: 66,
            );
          } else {
            return ItemWidget(index, itemBeans[index - 1], widget.listener);
          }
        },
      ),
    );
  }
}

可以看到 itemCount 和 itemBuilder 都变化了。

效果图如下:

从这个小演示,我们也可以看到关键在于 itemCountitemBuilder 的处理。

只要处理得当,可以实现各种各样的布局。

一般的方式都是通过在 Bean 添加一个 viewType 来区分加载不同的布局。

也可以考虑继承和多态等方式,这里就不展开讲了。

相信小伙伴们都能够自行处理的。

代码位置:github.com/nesger/Flut…

我们一开始的效果图就是这个代码,不过分隔线和视觉反馈的颜色值不一样而已。

说明

由于只是演示,因此有一些地方并没有做额外处理,实际使用需要注意。

  1. 代码结构,注意按业务或者功能等划分。
  2. 有些公用的地方可以进行封装,减少后续写多个 ListView 页面时重复代码。
  3. 代码里面的数据源是直接填充的,实际情况可能是从网络获取。因此需要增加 Bean 相关的 json 解析逻辑。