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
的一個成員變量,它的實現在具體業務邏輯中。LoadMoreOnSuccess
和LoadMoreOnFailure
是業務邏輯加載失敗或成功的回調,用於通知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