本文微信公眾號「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')
],
);
}
}
複製程式碼
顯示效果如下:
當然這裡的 title 和 description 目前是 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 都變化了。
效果圖如下:
從這個小演示,我們也可以看到關鍵在於 itemCount 和 itemBuilder 的處理。
只要處理得當,可以實現各種各樣的佈局。
一般的方式都是通過在 Bean 新增一個 viewType 來區分載入不同的佈局。
也可以考慮繼承和多型等方式,這裡就不展開講了。
相信小夥伴們都能夠自行處理的。
程式碼位置:github.com/nesger/Flut…
我們一開始的效果圖就是這個程式碼,不過分隔線和視覺反饋的顏色值不一樣而已。
說明
由於只是演示,因此有一些地方並沒有做額外處理,實際使用需要注意。
- 程式碼結構,注意按業務或者功能等劃分。
- 有些公用的地方可以進行封裝,減少後續寫多個 ListView 頁面時重複程式碼。
- 程式碼裡面的資料來源是直接填充的,實際情況可能是從網路獲取。因此需要增加 Bean 相關的 json 解析邏輯。