上拉加载和下拉刷新基本上每款 app 必有的一个需求,本文不只是讲解上拉加载和下拉刷新在页面中的实现,而是把这两个功能放在一个 widget 中,可以在以后的开发中复用。先来看下效果图:

一、RefreshIndicator
Flutter 默认给我们提供了一个下拉刷新的控件,现在先看看代码是如何实现的:
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async{
await Future.delayed(Duration(seconds: 3));
return ;
},
child: ListView.builder(),
);
}
只需实现 onRefresh 属性对应的函数,然后在内部模拟一个异步的耗时操作,在三秒后刷新按钮自然就消失了。
二、上拉加载
Flutter 并没有提供一个上拉加载的控件,所以需要我们自己去实现。关键的地方有两点:一是要监听到列表是否滑动到最底端了,二是给最底端加一个加载更多的布局。
ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = new ScrollController();
_scrollController.addListener((){
// 滑动到底部,去做加载更多的请求
if(_scrollController.position.pixels == _scrollController.position.maxScrollExtent){
_getMoreData();
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: Scrollbar(
child: ListView.builder(
controller: _scrollController,
),
}
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
监听滑动到最底端我们采用的是 ScrollController,只需要把添加好监听函数的 scrollController 放到 ListView 的 controller 属性中即可。
接下来实现加载更多的布局,主要在 ListView.builder 内操作:
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: Scrollbar(
child: ListView.builder(
itemCount: widget.itemCount + 1,
itemBuilder: (context, index){
if(index == widget.itemCount){
if(_loadingMoreState == LoadingMoreState.loading) {
return _buildFootView("正在加载");
}else if(_loadingMoreState == LoadingMoreState.complete){
return _buildFootView("加载完成");
}else if(_loadingMoreState == LoadingMoreState.fail){
return _buildFootView('加载失败');
}else if(_loadingMoreState == LoadingMoreState.noData){
return _buildFootView('已经到底啦');
}else{
return Container();
}
}
return ListTile(
leading: Icon(Icons.android),
title: Text("android"),
subtitle: Text(subtitles[index]),
);
},
controller: _scrollController,
),
}
}
}
itemCount 数量需要加一,为了让 ListView 最后一行是加载更多的布局。这里根据状态不同统一写在 _buildFootView 函数内。
看下 LoadingMoreState 枚举类的状态:
enum LoadingMoreState {
loading, // 正在加载时
complete, // 加载完成
fail, // 加载失败
noData, // 没有更多数据了
hide, // 隐藏布局
}
总的来说就是监听到滑动到底部的时机,此时去请求数据,期间根据调整 LoadingMoreState 状态来改变 ListView 最后一行的 footView 布局。
三、RefreshLoadMoreIndicator
实现了加载更多后,为了以后的复用性,我把下拉刷新和上拉加载的功能都放在了一个 widget 中。
typedef RefreshCallBack = Future<void> Function();
typedef LoadMoreCallBack<LoadingMoreState> = Future<LoadingMoreState> Function();
class RefreshLoadMoreIndicator extends StatefulWidget {
RefreshCallBack onRefresh;
LoadMoreCallBack onLoadMore;
int itemCount;
IndexedWidgetBuilder itemBuilder;
RefreshLoadMoreIndicator({
@required this.onRefresh,
@required this.onLoadMore,
@required this.itemCount,
@required this.itemBuilder,
});
@override
State<StatefulWidget> createState() {
return RefreshLoadMoreIndicatorState();
}
}
首先明确提供给外部的属性,onRefresh 和 onLoadMore 没什么疑问,真正的请求操作都必须由使用者实现,并且 onLoadMore 需要拿到 LoadingMoreState 返回值,这样才能判断上拉加载时布局的变化。itemCount 是使用者列表数据的数量,这是为了给 ListView 增加最后一行。itemBuilder 直接是使用 ListView 的 item 函数,让使用者去实现 item 布局。这是几个必须要实现的属性。
class RefreshLoadMoreIndicatorState extends State<RefreshLoadMoreIndicator>{
ScrollController _scrollController;
LoadingMoreState _loadingMoreState;
@override
void initState() {
super.initState();
_scrollController = new ScrollController();
_scrollController.addListener((){
if(_scrollController.position.pixels == _scrollController.position.maxScrollExtent){
// 如果处于非 LoadingMoreState.hide 状态,都不能再来第二次,否则会出现重复请求
if(_loadingMoreState == LoadingMoreState.loading ||
_loadingMoreState == LoadingMoreState.complete ||
_loadingMoreState == LoadingMoreState.noData ||
_loadingMoreState == LoadingMoreState.fail){
return ;
}
// 把状态调整为 LoadingMoreState.loading,此时就会显示正在加载的布局
setState(() {
_loadingMoreState = LoadingMoreState.loading;
});
// 拿到使用者返回的加载状态
Future<LoadingMoreState> future = widget.onLoadMore();
future.then((state){
setState(() {
_loadingMoreState = state;
});
// 展示500ms的布局后再隐藏 footView 布局
Timer(Duration(milliseconds: 500), (){
setState(() {
_loadingMoreState = LoadingMoreState.hide;
});
});
});
}
});
}
// footView 根据不同的状态,决定是否显示转圈以及显示不同的文案
Widget _buildFootView(String text){
return Container(
child: Center(
child: Padding(
padding: EdgeInsets.all(10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_loadingMoreState == LoadingMoreState.loading?Container(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2,),
):Container(),
Padding(
padding: EdgeInsets.only(left: 10),
child: Text(text),
)
],
),
)
),
);
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: Scrollbar(
child: ListView.builder(
itemCount: widget.itemCount + 1,
itemBuilder: (context, index){
if(index == widget.itemCount){
if(_loadingMoreState == LoadingMoreState.loading) {
return _buildFootView("正在加载");
}else if(_loadingMoreState == LoadingMoreState.complete){
return _buildFootView("加载完成");
}else if(_loadingMoreState == LoadingMoreState.fail){
return _buildFootView('加载失败');
}else if(_loadingMoreState == LoadingMoreState.noData){
return _buildFootView('已经到底啦');
}else{
return Container();
}
}
// 依然还是用使用者给的 item 布局,只是在此之前我们做了关于 footView 的处理。
return widget.itemBuilder(context, index);
},
controller: _scrollController,
)
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
关键代码都已注释,若需要其他的属性可根据自己的需求继续加,甚至可以支持 GridView 等布局。封装的关键思路就是只处理上拉加载状态变化后的布局变化,其余属性直接透传都沿用 ListView 的属性。
最后看下使用此控件的示例:
class RefreshDemoState extends State<RefreshDemo>{
static const List<String> models = [
'111111111',
'22222222222',
'333333333',
'44444444444',
'555555555555',
'66666666666666',
'7777777777',
'888888888888',
'99999999999999999',
'10110101010010101',
];
List<String> subtitles = [
...models,
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('refresh')),
body: RefreshLoadMoreIndicator(
onRefresh: () async{
// 模拟刷新请求
await Future.delayed(Duration(seconds: 2));
return ;
},
onLoadMore: () async{
await Future.delayed(Duration(seconds: 2));
// 模拟加载成功、加载失败、没有数据的情况。
int state = Random().nextInt(3);
if(state == 0){
setState(() {
subtitles.addAll(models);
});
return LoadingMoreState.complete;
}else if(state == 1){
return LoadingMoreState.fail;
}else{
return LoadingMoreState.noData;
}
},
itemCount: subtitles.length,
itemBuilder: (context, index){
return ListTile(
leading: Icon(Icons.android),
title: Text("android"),
subtitle: Text(subtitles[index]),
);
},
),
);
}
}