Flutter 上拉加载和下拉刷新

4,692 阅读4分钟

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

refresh_loadmore

一、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 放到 ListViewcontroller 属性中即可。

接下来实现加载更多的布局,主要在 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();
  }

}

首先明确提供给外部的属性,onRefreshonLoadMore 没什么疑问,真正的请求操作都必须由使用者实现,并且 onLoadMore 需要拿到 LoadingMoreState 返回值,这样才能判断上拉加载时布局的变化。itemCount 是使用者列表数据的数量,这是为了给 ListView 增加最后一行。itemBuilder 直接是使用 ListViewitem 函数,让使用者去实现 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]),
          );
        },
      ),
    );
  }

}