Flutter 上拉加载更多终极解决方案实现

页面使用的代码还没来得及封装, 现有代码已经经过测试人员严格测试.请放心使用. 如果有bug欢迎提出. 具体效果可以参考 [视界北京] App

项目代码地址
对于移动开发,上拉加载更多是列表,中必不可少的一个功能, 由于上拉加载更多的逻辑相对来说比较复杂, 且变化多端, 因此 Android, IOS 都没有相应的上拉加载更多的控件提供. Flutter 作为新兴的跨平台开发方式也没有提供相应的Widget.

下面是我参考Android端加载更多, 开发的Flutter加载更多帮助类,可以适应大多数上拉加载更多的需求. 先来看一下效果图.

loadMore.gif

下面就来分析一下如何实现.

  1. 首先分析一下页面状态.

    数据加载状态

    1. 当前什么也没做(网络请求前,网络请求成功)
    2. 数据加载中
    3. 数据加载失败(业务逻辑错误)
    4. 数据加载网络异常

    数据状态

    1. 没有数据
    2. 有数据
      这样两种状态组合可以得到页面的八种状态,因此我们的加载更多要在这八种状态中进行切换.
  2. 其次我们来分析一下如何加载更多

    上拉加载更多的示例网上一搜一大把: 比如:

    1. 滑动到底部加载更多
    2. 滑动到还有多少个不可见项加载更多
    3. 某个特定项被加载时加载更多.

    显然这样的示例都无法满足实际开发时的需求.

    假设我们有这样的需求:

    1. 只在服务端有更多数据才允许加载更多
    2. 向上滑动时才允许加载更多
    3. 上一次调用过程没有发生错误和异常才允许加载更多
    4. 发生错误或异常后点击最后一项才允许加载更多.
  3. 代码实现

    用枚举表示数据加载状态

/// 数据加载状态
enum PageState {
  None, // 现在什么也没做(网络请求前,网络请求完成)
  Loading, // 加载中
  LoadingError, // 加载失败(业务逻辑错误)
  LoadingException, // 网络异常
}

用bool表示是否有数据

  /// 是否有数据
  bool get hasData => this.length > 0;

因此根据上面的分析我们可以得到如下加载更多基类

代码解析

三个需要重载的方法

  1. bool hasMore();
    根据具体的业务和服务返回数据清空 判断数据是否已经加载完成
  2. <MODEL> getRequest(bool isRefresh, int currentPage, int pageSize);
    根据参数调用后台服务
  3. Future<bool> handlerData(MODEL model, bool isRefresh);
    处理数据,将数据放入到数据列表中,通常在这里需要计算出 bool hasMore() 方法的返回值

其他方法及属性

  1. _mData = <DATA>[];
    用于存储服务请求回来的列表数据
  2. PageState _pageState = PageState.None;
    存储页面当前状态
  3. bool get hasData => this.length > 0;
    页面是否已经加载了数据, 有些时候需要总是显示有数据时的页面, 可以重写这个方法返回 true
  4. Future<bool> obtainData([bool isRefresh = false]) async
    用于页面请求数据
/// [DATA] 列表中的数据的数据类型
/// [MODEL] 服务返回的数据结构对应的数据类
abstract class DataLoadMoreBase<DATA, MODEL> extends ListBase<DATA>  {
  final _mData = <DATA>[];

  @override
  DATA operator [](int index) {
    return _mData[index];
  }

  @override
  void operator []=(int index, DATA value) {
    _mData[index] = value;
  }

  @override
  int get length => _mData.length;

  @override
  set length(int newLength) => _mData.length = newLength;

  final _pageSize = 20;

  int _currentPage = 1;
  
  /// 使用 BehaviorSubject 会保留最后一次的值,所有监听是会受到回调
  final _streamController = new BehaviorSubject<DataLoadMoreBase<DATA, MODEL>>();

  /// 页面状态
  PageState _pageState = PageState.None;

  /// 是否有数据
  bool get hasData => this.length > 0;

  /// 是否有业务错误
  bool get hasError => _pageState == PageState.LoadingError;

  /// 是否有网络异常
  bool get hasException => _pageState == PageState.LoadingException;

  /// 是否加载中
  bool get isLoading => _pageState == PageState.Loading;

  /// 页面状态
  PageState get pageState => _pageState;
  
  /// 页面通过监听stream变化更新界面
  Stream<DataLoadMoreBase<DATA, MODEL>> get stream => _streamController.stream;

  /// 拉取数据
  /// [isRefresh] 是否清空原来的数据
  @mustCallSuper
  Future<bool> obtainData([bool isRefresh = false]) async {
    if (isLoading) return true;

    _pageState = PageState.Loading;
    onStateChanged(this);

    var success = false;
    try {
      success = await _loadData(isRefresh);
      if (success) {
        // 加载数据成功
        _pageState = PageState.None;
      } else {
        // 加载数据业务逻辑错误
        _pageState = PageState.LoadingError;
      }
    } catch (e) {
      // 网络异常
      _pageState = PageState.LoadingException;
    }

    onStateChanged(this);
    return success;
  }

  /// 加载数据
  /// [isRefresh] 是否清空原来的数据
  Future<bool> _loadData([bool isRefresh = false]) async {
    int currentPage = isRefresh ? 1 : _currentPage + 1;
    MODEL model = await getRequest(isRefresh, currentPage, _pageSize);
    bool success = await handlerData(model, isRefresh);
    if (success) _currentPage = currentPage;
    return success;
  }

  /// 是否还有更多数据
  @protected
  bool hasMore();

  /// 构造请求
  /// [isRefresh] 是否清空原来的数据
  /// [currentPage] 将要请求的页码
  /// [pageSize] 每页多少数据
  @protected
  Future<MODEL> getRequest(bool isRefresh, int currentPage, int pageSize);

  /// 重载这个方法,必须在这个方法将数据添加到列表中
  /// [model] 本次请求回来的数据
  /// [isRefresh] 是否清空原来的数据
  @protected
  Future<bool> handlerData(MODEL model, bool isRefresh);
  
  /// 发送状态变更消息
  void onStateChanged(DataLoadMoreBase<DATA, MODEL> source) {
    if (!_streamController.isClosed) _streamController.add(source);
  }

  /// 释放资源
  void dispose() {
    _streamController.close();
  }
}

4. 代码使用

使用的时候只需要简单继承上面的类, 并在页面中监听列表滚动即可实现上拉加载,下拉刷新

首先实现一下数据加载逻辑处理类

class _DataLoader extends DataLoadMoreBase<Article, Model> {
  bool _hasMore = true;

  int _id; // 请求时的参数

  _DataLoader(this._id);

  @override
  Future<Model> getRequest(bool isRefresh, int currentPage, int pageSize) async {
    // 这里模拟网络请求
    var list = List();
    for (var i = 0; i < 10; i++) {
      var article = Article(title: "Article$currentPage $_id $i");
      list.add(article);
    }
    await Future.delayed(Duration(seconds: 2));

    return Model(data: list, message: "加载成功", code: 0);
  }

  @override
  Future<bool> handlerData(Model model, bool isRefresh) async {
    // 1. 判断是否有业务错误,
    // 2. 将数据存入列表, 如果是刷新清空数据
    // 3. 判断是否有更多数据
    if (model == null || model.isError()) {
      return false;
    }

    if (isRefresh) clear();

    // todo 实际使用时这里需要修改
    addAll((model.data as List<dynamic>).map((d){
          return d as Article;
    }));

    _hasMore = length < 100;

    return true;
  }

  @override
  bool hasMore() => _hasMore;
}

5. 页面实现

class LoaderMoreDemo extends StatefulWidget {
  final int _id;

  const LoaderMoreDemo(this._id, {Key key}) : super(key: key);

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

class _LoaderMoreDemoState extends State<LoaderMoreDemo> with AutomaticKeepAliveClientMixin {
  /// 数据加载类
  _DataLoader _loader;

  @override
  bool get wantKeepAlive => true;

  @override
  void initState() {
    _loader = _DataLoader(widget._id);
    _loader.obtainData(false);
    super.initState();
  }

  @override
  void dispose() {
    _loader.dispose();
    super.dispose();
  }

  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[200],
      appBar: AppBar(
        title: Text('加载更多示例'),
      ),
      body: StreamBuilder<DataLoadMoreBase<Article, Model>>(
          stream: _loader.stream,
          builder: (context, snapshot) {
            /// 监听滑动结束广播
            return NotificationListener<ScrollEndNotification>(
                onNotification: (notification) {
                  if (notification.depth != 0) return false;
                  if (notification.metrics.axisDirection != AxisDirection.down) return false;
                  if (notification.metrics.pixels < notification.metrics.maxScrollExtent) return false;

                  /// 如果没有更多, 服务返回错误信息, 网络异常,那么不允许上拉加载更多
                  if (snapshot.data == null ||
                      !snapshot.data.hasMore() ||
                      snapshot.data.hasError ||
                      snapshot.data.hasException) return false;

                  // 加载更多
                  _loader.obtainData(false);
                  return false;
                },

                /// 下拉刷新
                child: RefreshIndicator(
                  child: _buildList(snapshot.data),
                  onRefresh: () => _loader.obtainData(true),
                ));
          }),
    );
  }

  Widget _buildList(DataLoadMoreBase<Article, Model> dataLoader) {
    /// 初始化时显示的View
    if (dataLoader == null) {
      return Container(
        child: Center(child: new Text('欢迎光临...')),
      );
    }

    /// 没有数据时候显示的View构建
    if (!dataLoader.hasData) {
      return LoadingEmptyIndicator(dataLoader: dataLoader);
    }

    /// 渲染数据 ,这里数据+1 1表示最后一项,用于显示加载状态
    return ListView.separated(
      itemCount: dataLoader.length + 1,
      physics: const AlwaysScrollableScrollPhysics(),
      separatorBuilder: (content, index) {
        return new Container(height: 0.5, color: Colors.grey);
      },
      itemBuilder: (context, index) {
        if (index == dataLoader.length) {
          return LoadingIndicator(dataLoader: dataLoader);
        } else {
          return Material(
            color: Colors.white,
            child: new InkWell(
              child: Padding(
                padding: EdgeInsets.all(32),
                child: Text(dataLoader[index].title),
              ),
              onTap: () {},
            ),
          );
        }
      },
    );
  }
}

项目代码地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容