我们在进行Android开发的时候,比如布局文件,会创建一个xml来存放布局。写熟悉了觉得没什么,可是,用xml来存放布局文件是十年前的技术了。在十年过后,再用xml来写布局文件运行时在由系统负责渲染看起来有些过时。
关于为什么是flutter,网上有很多讨论,在我看来最重要的应该是大前端的思想。作为一个Android开发者,我们也应该去学习这种先进的思想。
项目中有很多ListView、GridView的场景,通常来说,从服务器获取数据都会分页获取。而flutter官方并没有提供一个loadmore控件。这就需要开发者自己实现。先贴出一张效果图:
网上有一些关于ListView加载更多的实现,无外乎都是判断是否滚动到底,滚动到底之后再加载更多。但是在我们的项目中,不仅有ListView还有GridView、StaggeredGrid(瀑布流)。作为一个懒程序员,我更愿意用一套代码解决这三个控件的加载更多 ,而不愿意分别为他们都写一套代码。
站在巨人的肩膀上才能看得更远,这篇blog给了我莫大的启示,感谢作者。我的加载更多控件姑且叫做LoadMoreIndicator
吧。
总体来说,我们还是需要判断是否滚动到底。如果滚动到底且还有数据,则加载更多;否则,无更多数据。所以,我们的LoadMoreIndicator
至少应该包含IDLE、NOMORE这两个状态,除此之外,应该还有FAIL、LOADING两个状态,分别对应加载失败、正在加载。
作为加载更多的实现,如何判断滚动到底?flutter提供了ScrollController来监听滚动类控件,而我最开始也是使用ScrollController来做的,不过后面还是换成了Notification,其中遇到过一个坑,后边再详细说明。有关flutter的notification机制网上有很多介绍,总的来说就是一个flutter的事件分发机制。
上面提到过,项目里面用到的滚动控件包括ListView、GridView、StaggeredGrid,那这三个不同的控件该如何封装到一起呢?单论这个问题似乎有很多解。再仔细分析项目需求,除了那三个滚动控件之外,可能还需要Appbar用于定义title,也还需要pinnedHeader。能把三个滚动控件统一起来,且还支持Appbar、pinnedHeader的控件只有CustomScrollView了。CustomScrollView包含多个滚动模型,能够处理许多个滚动控件带来的滑动冲突。
那么,LoadMoreIndicator的主体也很清晰了——通过CustomScrollView封装不同的滚动控件,并且处理各种业务场景。
给出一小段代码,说明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()就是加载更多的实现,一般是连接到服务器获取数据。
在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;
}
}
到这里,基本讲清楚了LoadMoreIndicator
的实现思路,还有很多细节问题需要花功夫完善,如:怎么判断是否加载完,没有更多数据?是否可以提供默认的EmptyView?
前面已经提到过当判断滚动到底的时候需要触发加载更多,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状态。
在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就可以收起了。
经过一些踩坑,一个在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