一種Flutter加載更多的實現方法

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章