一种Flutter加载更多的实现方法

转载注明出处:https://blog.csdn.net/skysukai

1、why flutter?

我们在进行Android开发的时候,比如布局文件,会创建一个xml来存放布局。写熟悉了觉得没什么,可是,用xml来存放布局文件是十年前的技术了。在十年过后,再用xml来写布局文件运行时在由系统负责渲染看起来有些过时。
关于为什么是flutter,网上有很多讨论,在我看来最重要的应该是大前端的思想。作为一个Android开发者,我们也应该去学习这种先进的思想。

2、背景

项目中有很多ListView、GridView的场景,通常来说,从服务器获取数据都会分页获取。而flutter官方并没有提供一个loadmore控件。这就需要开发者自己实现。先贴出一张效果图:

网上有一些关于ListView加载更多的实现,无外乎都是判断是否滚动到底,滚动到底之后再加载更多。但是在我们的项目中,不仅有ListView还有GridView、StaggeredGrid(瀑布流)。作为一个懒程序员,我更愿意用一套代码解决这三个控件的加载更多 ,而不愿意分别为他们都写一套代码。

3、实现

站在巨人的肩膀上才能看得更远,这篇blog给了我莫大的启示,感谢作者。我的加载更多控件姑且叫做LoadMoreIndicator吧。

3.1 总体思路

3.1.1 状态定义

总体来说,我们还是需要判断是否滚动到底。如果滚动到底且还有数据,则加载更多;否则,无更多数据。所以,我们的LoadMoreIndicator至少应该包含IDLE、NOMORE这两个状态,除此之外,应该还有FAIL、LOADING两个状态,分别对应加载失败、正在加载。

3.1.2 监听滚动事件

作为加载更多的实现,如何判断滚动到底?flutter提供了ScrollController来监听滚动类控件,而我最开始也是使用ScrollController来做的,不过后面还是换成了Notification,其中遇到过一个坑,后边再详细说明。有关flutter的notification机制网上有很多介绍,总的来说就是一个flutter的事件分发机制。

3.1.3 如何统一封装

上面提到过,项目里面用到的滚动控件包括ListView、GridView、StaggeredGrid,那这三个不同的控件该如何封装到一起呢?单论这个问题似乎有很多解。再仔细分析项目需求,除了那三个滚动控件之外,可能还需要Appbar用于定义title,也还需要pinnedHeader。能把三个滚动控件统一起来,且还支持Appbar、pinnedHeader的控件只有CustomScrollView了。CustomScrollView包含多个滚动模型,能够处理许多个滚动控件带来的滑动冲突。
那么,LoadMoreIndicator的主体也很清晰了——通过CustomScrollView封装不同的滚动控件,并且处理各种业务场景。

3.2 主体框架

给出一小段代码,说明LoadMoreIndicator的主体:

class LoadMoreIndicator<T extends Widget, K extends Widget>
    extends StatefulWidget {

  /// the Sliver header
  final K header;

  /// the Sliver body
  final T child;

  /// callback to loading more
  final LoadMoreFunction onLoadMore;

  /// footer delegate
  final LoadMoreDelegate delegate;

  /// whether to load when empty
  final bool whenEmptyLoad;

  ///define emptyview or use default emptyview
  final Widget emptyView;
  
  const LoadMoreIndicator({
    Key key,
    @required this.child,
    @required this.onLoadMore,
    this.header,
    this.delegate,
    this.whenEmptyLoad = true,
    this.controller,
    this.emptyView
  }) : super(key: key);

  @override
  _LoadMoreIndicatorState createState() => _LoadMoreIndicatorState();
  
  ……
}
class _LoadMoreIndicatorState extends State<LoadMoreIndicator> {
	……

	/// original widget need to be wrapped by CustomScrollView
	final List<Widget> _components = [];
	
	  @override
	  Widget build(BuildContext context) {
	    /// build header
	    if (childHeader != null) {
	      _components.add(SliverToBoxAdapter(
	        child: childHeader,
	      ));
	    }
	
	    /// add body
	    _components.add(childBody);
	
	    /// build footer
	    _components.add(SliverToBoxAdapter(
	      child: _buildFooter(),
	    ));
	
	    return _rebuildConcrete();
	  }
	  /// build actual Sliver Body
	  Widget _rebuildConcrete() {
	    return NotificationListener<ScrollNotification>(
	      onNotification: _onScrollToBottom,
	      child: CustomScrollView(
	        slivers: _components,
	      ),
	    );
	  }
  bool _onScrollToBottom(ScrollNotification scrollInfo) {
    /// if is loading return
    if (_status == LoadMoreStatus.LOADING) {
      return true;
    }
    /// scroll to bottom
    if (scrollInfo.metrics.extentAfter == 0.0 &&
        scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent * 0.8) {
      if (loadMoreDelegate is DefaultLoadMoreDelegate) {
        /// if scroll to bottom and there has more data then load
        if (_status != LoadMoreStatus.NOMORE && _status != LoadMoreStatus.FAIL) {
          loadData();
        }
      }
    }

    return false;
  }
	……
 }

以上这一小段代码就是LoadMoreIndicator最核心代码了,非常简单。只需要把需要封装的控件传递过来,添加header、footer即可。有一个问题是,这样封装的话,滚动控件必须是sliver的实现,如:SliverGrid、SliverList、SliverStaggeredGrid,目前没有想到其他更好的解决办法。loadData()就是加载更多的实现,一般是连接到服务器获取数据。

3.3 构造footer

LoadMoreIndicator中,封装完滚动控件之后,最重要的工作就是构造footer了。选中了LoadMoreIndicator代码的主体是Customscrollview之后,其实构造footer也很简单了。SliverToBoxAdapter就是flutter提供的用于封装的其他Widget的控件,只需要把构造的footer用SliverToBoxAdapter再包装一层即可大功告成。给出代码片段:

  Widget _buildFooter() {
    return NotificationListener<_RetryNotify>(
      child: NotificationListener<_AutoLoadNotify>(
        child: DefaultLoadMoreView(
          status: _status,
          delegate: loadMoreDelegate,
        ),
        onNotification: _onAutoLoad,
      ),
      onNotification: _onRetry,
    );
  }

DefaultLoadMoreView用于设置默认的加载更多动画,如果用户没有设置,则使用这个加载效果;否则使用定义过的加载效果。

/// if don't define loadmoreview use default
class DefaultLoadMoreView extends StatefulWidget {
  final LoadMoreStatus status;
  final LoadMoreDelegate delegate;

  const DefaultLoadMoreView({
    Key key,
    this.status = LoadMoreStatus.IDLE,
    @required this.delegate,
  }) : super(key: key);

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

class DefaultLoadMoreViewState extends State<DefaultLoadMoreView> {
  LoadMoreDelegate get delegate => widget.delegate;

  @override
  Widget build(BuildContext context) {
    notify();
    return GestureDetector(
      behavior: HitTestBehavior.translucent,
      onTap: () {
        if (widget.status == LoadMoreStatus.FAIL ||
            widget.status == LoadMoreStatus.IDLE) {
          /// tap to load
          _RetryNotify().dispatch(context);
        }
      },
      child: Container(
        alignment: Alignment.center,
        child: delegate.buildChild(
          context,
          widget.status,
        ),
      ),
    );
  }
 ……
}

加载动画的实现在DefaultLoadMoreDelegate中,通过代理的模式来设置默认的加载动画:

///default LoadMoreView delegate
class DefaultLoadMoreDelegate extends LoadMoreDelegate {
  @override
  Widget buildChild(BuildContext context, LoadMoreStatus status) {
    switch (status) {
      case LoadMoreStatus.IDLE:
      case LoadMoreStatus.LOADING:
        return LoadingAnimation(blockBackKey: false);
        break;
      case LoadMoreStatus.NOMORE:
        return Center(
          child: Padding(
            padding: EdgeInsets.all(10.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                Text(
                  S.of(context).loadMore_Nomore,
                  style: TextStyle(color: Colors.white),
                ),
              ],
            ),
          ),
        );

        break;
      case LoadMoreStatus.FAIL:
        return Text(
          S.of(context).loadMore_Fail,
          style: TextStyle(color: Colors.white),
        );
        break;
    }

    return null;
  }
}

3.4 其他问题

到这里,基本讲清楚了LoadMoreIndicator的实现思路,还有很多细节问题需要花功夫完善,如:怎么判断是否加载完,没有更多数据?是否可以提供默认的EmptyView?

3.4.1 loadData()的实现

前面已经提到过当判断滚动到底的时候需要触发加载更多,loadData()这个函数怎么实现呢?

  /// notify UI to load more data and receive result
  void loadData() {
    if (_status == LoadMoreStatus.LOADING) {
      return;
    }

    if(mounted) {
      setState(() {
        _updateStatus(LoadMoreStatus.LOADING);
      });
    }

    widget.onLoadMore((int count, int pageNum, int pageSize) {

      if (pageNum * pageSize >= count) {
        _updateStatus(LoadMoreStatus.NOMORE);
      } else {
        _updateStatus(LoadMoreStatus.IDLE);
      }

      if(mounted) {
        setState(() {
          _isEmpty = count == 0;
        });
      }
    }, (int errorCode) {
      _updateStatus(LoadMoreStatus.FAIL);
      if (mounted) {setState(() {});}
    });
  }

LoadMoreIndicator中滚动到底之后,需要触发真实的页面去请求数据,而不可能在控件里边去完成业务逻辑。在java中可以使用回调接口来完成,再把请求结果传回LoadMoreIndicator,用于更新footer状态。在dart中可以使用typedef来完成相同的功能,即用方法来代替回调接口,这部分不是本文的重点,在此略过。
来看一下LoadMoreIndicator中回调方法的定义:

typedef void LoadMoreOnSuccess(int totalCount, int pageNum, int pageSize);
typedef void LoadMoreOnFailure(int errorCode);
typedef void LoadMoreFunction(LoadMoreOnSuccess success, LoadMoreOnFailure failure);

LoadMoreFunction作为LoadMoreIndicator的一个成员变量,它的实现在具体业务逻辑中。LoadMoreOnSuccessLoadMoreOnFailure是业务逻辑加载失败或成功的回调,用于通知LoadMoreIndicator更新footer状态。

3.3.2 为什么不能用ScrollController

LoadMoreIndicator完成之后,能够满足项目中大部门场景,但是在一个场景中,页面不能滚动了。先来看下设计图:

在这个界面中,有三个页签,每一个页签都要求能够加载更多。flutter提供了NestedScrollView来实现一个滑动头部折叠的动画效果。在NestedScrollView的body中设置TabBarView,即可达到效果。
之前提到,为了监听滚动,一般来说得给控件设置ScrollController来监听,但是NestedScrollView本身自带一个监听,用于处理滚动冲突,并且在NestedScrollView有一段注释:

 // The "controller" and "primary" members should be left
 // unset, so that the NestedScrollView can control this
 // inner scroll view.
 // If the "controller" property is set, then this scroll
 // view will not be associated with the NestedScrollView.
 // The PageStorageKey should be unique to this ScrollView;
 // it allows the list to remember its scroll position when
 // the tab view is not on the screen.

所以,在LoadMoreIndicator只能使用ScrollNotification来监听滚动到底,但是在这样修改之后,理论上能够监听tabbarview的滚动了,实际上,tabbarview还是不能滚动到底,头像依然不能被收起。来看下那个包裹头像的appbar是怎么写的吧:

SliverAppBar(
  pinned: true,
  expandedHeight: ScreenUtils.px2dp(1206),
  forceElevated: innerBoxIsScrolled,
  bottom: PreferredSize(
    child: Container(
    child: TabBar(
      indicatorColor: Colors.red,
      indicatorWeight: ScreenUtils.px2dp(12),
      indicatorPadding: EdgeInsets.only(top: 10.0),
      indicatorSize: TabBarIndicatorSize.label,
      labelColor: Colors.red,
      labelStyle: _tabBarTextStyle(),
      unselectedLabelColor: Colors.white,
      unselectedLabelStyle: _tabBarTextStyle(),
      tabs: _tabTagMap.keys
       .map(
        (String tag) => Tab(
          child: Tab(text: tag),
        ),
       ).toList(),
      ),
      color: Colors.black,
    ),
  preferredSize:
    Size(double.infinity, ScreenUtils.px2dp(192))),
  flexibleSpace: Container(
    child: Column(
      children: <Widget>[
        AppBar(
          backgroundColor: Colors.black,
        ),
        Expanded(
          child: _userInfoHeadWidget(
            context, _userInfo, UserInfoType.my),
          ),
       ],
     ),
   ),
  ),
),

看上去没有什么问题,但是tabbar无论如何不能被收起,后来无意在github上发现,改为以下可实现:

SliverAppBar(
  expandedHeight: ScreenUtils.px2dp(1206),
  flexibleSpace: SingleChildScrollView(
    physics: NeverScrollableScrollPhysics(),
    child: Container(
      child: Column(
        children: <Widget>[
          AppBar(
            backgroundColor: Colors.black,
          ),
          _userInfoHeadWidget(
            context, _userInfo, UserInfoType.my
          ),
        ],
      ),
    ),
  ),

其实思想就是把用户头像appbar的flexiblespace里,同时设置flexiblespace可滚动。这样,tarbar就可以收起了。

4、结语

经过一些踩坑,一个在flutter下的加载更多就完成了。总体来说,flutter的开发是比Android开发效率高。不过,目前还是很不成熟,在Android中一句话可以搞定的事情,在flutter中确不一定。能够做出这个加载更多,也是站在巨人的肩膀上,感谢以下作者给予的启发。
相关参考:https://blog.csdn.net/qq_28478281/article/details/83827699
相关参考:https://juejin.im/post/5bfb9cb7e51d45592b766769
相关参考:https://stackoverflow.com/questions/48035594/flutter-notificationlistener-with-scrollnotification-vs-scrollcontroller
相关参考:https://github.com/xuelongqy/flutter_easyrefresh/blob/master/README.md

你可能感兴趣的:(心得)