Flutter ListView 實戰快速上手

安卓小煜發表於2020-03-23

本文微信公眾號「AndroidTraveler」首發。

背景

本篇主要講述如何快速在 Flutter 中實現 ListView。

效果圖

先上效果圖感受一下:

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')
      ],
    );
  }
}
複製程式碼

顯示效果如下:

Flutter ListView 實戰快速上手

當然這裡的 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 ListView 實戰快速上手

新增分隔線

看起來還是怪怪的,我們增加下分隔線看看效果。

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),
        ],
      ),
    );
  }
}
複製程式碼

效果如下:

Flutter ListView 實戰快速上手

可能有小夥伴會說,你這個是剛好 item 佈局是 Column,如果不是 Column 的話呢?

Flutter ListView 實戰快速上手

方法多種多樣,這裡就說其中的一種方法吧,比如你可以利用 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);
    });
  }
}
複製程式碼

效果如下:

Flutter ListView 實戰快速上手

可以看到分隔線有點問題,主要原因是 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;
    });
  }
}
複製程式碼

效果如下:

Flutter ListView 實戰快速上手

但是如果你不是長按,而是快速點選,會發現沒有效果。

所以我們需要給抬起恢復來個延時,修改如下:

···

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

···
複製程式碼

效果如下:

Flutter ListView 實戰快速上手

程式碼位置: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 都變化了。

效果圖如下:

Flutter ListView 實戰快速上手

從這個小演示,我們也可以看到關鍵在於 itemCountitemBuilder 的處理。

只要處理得當,可以實現各種各樣的佈局。

一般的方式都是通過在 Bean 新增一個 viewType 來區分載入不同的佈局。

也可以考慮繼承和多型等方式,這裡就不展開講了。

相信小夥伴們都能夠自行處理的。

程式碼位置:github.com/nesger/Flut…

我們一開始的效果圖就是這個程式碼,不過分隔線和視覺反饋的顏色值不一樣而已。

說明

由於只是演示,因此有一些地方並沒有做額外處理,實際使用需要注意。

  1. 程式碼結構,注意按業務或者功能等劃分。
  2. 有些公用的地方可以進行封裝,減少後續寫多個 ListView 頁面時重複程式碼。
  3. 程式碼裡面的資料來源是直接填充的,實際情況可能是從網路獲取。因此需要增加 Bean 相關的 json 解析邏輯。

Flutter ListView 實戰快速上手

相關文章