【Flutter】应用Loading页面状态布局封装
LoadState布局的封装
前言
很常见的一个应用场景。在我们开发应用都会有加载的动画,有些页面可能是弹窗的 Loading 而有些页面则是 Loading 的布局。
在 Fluter 的应用中我们也是同样的道理,并且得益于 Flutter 加载布局的方式,使用 LoadStateWidget 来占位加载一些复杂页面还会有更快速的效果。它并不像 Android 那样加载了所有的布局然后只是做了显隐,而是真正把加载的布局改变了。
在一些列表页面或表单页面,我们可以使用自定义的 LoadStateWidget 来占位进入到页面再加载数据填充布局从而达到部分优化的效果,如 Android 开发我们封装的加载状态布局类型,我们可以定义几种状态,并且根据状态驱动刷新页面的布局。
下面我们就简单的定义一下 LoadStateWidget 便于大家取用,效果图如下:
接下来就看看如何实现的吧:
一、默认的布局
import 'package:flutter/material.dart';
import 'package:ftrecruiter/comm/constants/color_constants.dart';
import 'package:ftrecruiter/comm/widget/my_load_image.dart';
import 'package:ftrecruiter/comm/widget/my_text_view.dart';
import 'package:get/get.dart';
///四种视图状态
enum LoadState { State_Success, State_Error, State_Loading, State_Empty }
///根据不同状态来展示不同的视图
class LoadStateLayout extends StatefulWidget {
final LoadState state; //页面状态
final Widget? successWidget; //成功视图
final VoidCallback? errorRetry; //错误事件处理
String? errorMessage;
LoadStateLayout(
{Key? key,
this.state = LoadState.State_Loading, //默认为加载状态
this.successWidget,
this.errorMessage,
this.errorRetry})
: super(key: key);
@override
_LoadStateLayoutState createState() => _LoadStateLayoutState();
}
class _LoadStateLayoutState extends State<LoadStateLayout> {
@override
Widget build(BuildContext context) {
return SizeBox(
//宽高都充满屏幕剩余空间
width: double.infinity,
height: double.infinity,
child: _buildWidget,
);
}
///根据不同状态来显示不同的视图
Widget get _buildWidget {
switch (widget.state) {
case LoadState.State_Success:
return widget.successWidget ?? const SizedBox();
case LoadState.State_Error:
return _errorView;
case LoadState.State_Loading:
return _loadingView;
case LoadState.State_Empty:
return _emptyView;
default:
return _loadingView;
}
}
///加载中视图
Widget get _loadingView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(ColorConstants.appBlue),
),
MyTextView('loading'.tr, marginTop: 15, fontSize: 15.5)
],
),
);
}
///错误视图
Widget get _errorView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 80),
child: GestureDetector(
onTap: widget.errorRetry,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const MyAssetImage('other/page_no_data.webp', width: 180, height: 180, fit: BoxFit.contain),
MyTextView(widget.errorMessage??'Load Error Try Again'.tr, marginTop: 10, fontSize: 15.5),
],
)));
}
///数据为空的视图
Widget get _emptyView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 80),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const MyAssetImage('other/page_no_data.webp', width: 180, height: 180, fit: BoxFit.contain),
MyTextView('load_no_data'.tr, marginTop: 10, fontSize: 15.5),
],
),
);
}
}
我们先用枚举定义了几种常用的加载状态,加载中,加载成功,加载失败与无数据。
我们使用的场景就是整体页面的布局或者特殊的容器中,所以我们宽高都是占满父布局的,接着就是根据状态切换对应的 Widget 即可。
使用的时候我们传入 state 状态就能根据状态填充到指定的布局。
LoadStateLayout(
state: controller.loadingState,
errorMessage: controller.errorMessage,
errorRetry: () {
controller.retryRequest();
},
successWidget:Scrollbar(
child: ListView.builder(
itemCount: controller.datas.length,
itemBuilder: (BuildContext context, int index) {
return _buildNotificationItem(controller, index, () {
controller.gotoMessageChatPage(index);
});
},
),
),
)
这样当成功之后就会加载 ListView 的布局。
设置的时候只需要设置状态字段与错误字符串字段即可:
LoadState loadingState = LoadState.State_Success;
String? errorMessage;
Future fetchNotifyList() async {
if (_needShowPlaceholder) {
changeLoadingState(LoadState.State_Loading);
}
//获取到数据
var result = await mainRepository.fetchNotificationList(_curPage);
//处理数据
if (result.isSuccess) {
handleList(result.data?.data);
} else {
errorMessage = result.errorMsg;
changeLoadingState(LoadState.State_Error);
}
//最后赋值
_needShowPlaceholder = false;
}
// 处理数据与展示的逻辑
void handleList(List<NotificationEntity>? list) {
if (list != null && list.isNotEmpty) {
//有数据,判断是刷新还是加载更多的数据
if (_curPage == 1) {
//刷新的方式
datas.clear();
datas.addAll(list);
refreshController.finishRefresh();
//更新展示的状态
if (_needShowPlaceholder) {
//这种情况没有筛选,所以不会再出现LoadState状态了,直接判断,如果是有筛选条件导致其他LoadState状态,需要另外处理
changeLoadingState(LoadState.State_Success);
}
} else {
//加载更多
datas.addAll(list);
refreshController.finishLoad();
update();
}
} else {
if (_curPage == 1) {
//展示无数据的布局
datas.clear();
changeLoadingState(LoadState.State_Empty);
} else {
//展示加载完成,没有更多数据了
refreshController.finishLoad(IndicatorResult.noMore);
}
}
}
一个默认的带 状态加载布局的列表页面就实现了,也就是文章开头的效果图,但是如果是页面上带有筛选功能的页面那么就不适用了。
因为切换筛选条件会导致空布局与成功布局频繁切换,导致刷新功能无法生效。因为加载状态布局无法滚动,所以无法触发刷新去请求接口导致布局切换。
那么如何让状态加载布局滚动起来呢?
二、滚动的布局
直接在 LoadStateLayout 中加上 SingleScrollView 行不行?
理论上是可行的,但是在实际开发中,我们用 double.infinity 的方式并不能准确的显示真正的高度,那我们固定写屏幕高度?也不合适,还需要计算去除状态栏导航栏标题栏等其他布局之后的真正内容高度。
太不方便,咦,记得 CustomScrollView 的 Sliver
中不是有一个 SliverFillViewport
吗?它不是可以直接占满父布局吗?
我们可以用 successSliverWidget 与 successWidget 布局区分开,如果是需要滚动的布局,我们使用 successSliverWidget 的方式加载,并且可以在成功的布局中加入各种布局一起滚动。
我们修改 LoadStateLayout 如下:
///四种视图状态
enum LoadState { State_Success, State_Error, State_Loading, State_Empty }
///根据不同状态来展示不同的视图
class LoadStateLayout extends StatefulWidget {
final LoadState state; //页面状态
final Widget? successWidget; //成功视图
final List<SliverMultiBoxAdaptorWidget>? successSliverWidget; //成功的滚动视图
final VoidCallback? errorRetry; //错误事件处理
String? errorMessage;
LoadStateLayout({
Key? key,
this.state = LoadState.State_Loading, //默认为加载状态
this.successWidget, //成功的布局 (二选一)
this.successSliverWidget, //成功的滚动布局(二选一)
this.errorMessage, //错误的信息展示
this.errorRetry, //错误重试的事件
}) : super(key: key);
@override
_LoadStateLayoutState createState() => _LoadStateLayoutState();
}
class _LoadStateLayoutState extends State<LoadStateLayout> {
@override
Widget build(BuildContext context) {
if (widget.successSliverWidget != null) {
//如果有 successSliverWidget 就使用 Slivers 的方式布局
return CustomScrollView(
slivers: _buildSlivers(),
);
} else {
//如果没有有 successSliverWidget 就使用默认布局的方式布局
return SizedBox (
width: double.infinity,
height: double.infinity,
child: _buildWidget,
);
}
}
//Slivers的布局
List<Widget> _buildSlivers() {
return _buildListWidget;
}
///根据不同状态来显示不同的视图 (默认布局)
Widget get _buildWidget {
switch (widget.state) {
case LoadState.State_Success:
return widget.successWidget ?? const SizedBox();
case LoadState.State_Error:
return _errorView;
case LoadState.State_Loading:
return _loadingView;
case LoadState.State_Empty:
return _emptyView;
default:
return _loadingView;
}
}
///根据不同状态来显示不同的视图 (CustomScrollView)
List<Widget> get _buildListWidget {
switch (widget.state) {
case LoadState.State_Success:
return widget.successSliverWidget != null ? widget.successSliverWidget!
: widget.successWidget != null ? [widget.successWidget!] : [const SizedBox()];
case LoadState.State_Error:
return [widget.successSliverWidget != null ? _warpStateLayout(_errorView) : _errorView];
case LoadState.State_Loading:
return [widget.successSliverWidget != null ? _warpStateLayout(_loadingView) : _loadingView];
case LoadState.State_Empty:
return [widget.successSliverWidget != null ? _warpStateLayout(_emptyView) : _emptyView];
default:
return [widget.successSliverWidget != null ? _warpStateLayout(_loadingView) : _loadingView];
}
}
//如果父布局是 CustomScrollView 则使用 SliverFillViewport 包裹状态布局
Widget _warpStateLayout(Widget widget) {
return SliverFillViewport(
delegate: SliverChildBuilderDelegate(
(context, index) {
return widget;
},
childCount: 1,
),
);
}
// ===================================== 真正的状态布局 ↓ =====================================
///加载中视图
Widget get _loadingView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(ColorConstants.appBlue),
),
MyTextView('加载中...'.tr, marginTop: 15, fontSize: 15.5)
],
),
);
}
///错误视图
Widget get _errorView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: widget.errorRetry,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const MyAssetImage('page_load_error.png', width: 180, height: 180, fit: BoxFit.contain),
MyTextView(widget.errorMessage ?? '加载数据错误,请重试'.tr, marginTop: 10, fontSize: 15.5),
],
)));
}
///数据为空的视图
Widget get _emptyView {
return Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 10),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const MyAssetImage('page_no_data.png', width: 180, height: 180, fit: BoxFit.contain),
MyTextView('暂无数据'.tr, marginTop: 10, fontSize: 15.5),
],
),
);
}
}
比如我们结合 EasyRefresh 的刷新控件结合 LoadStateLayout 的布局就可以如下布局:
效果:
就可以在刷新布局中使用状态加载布局了。
后记
文本记录的状态加载布局的两种用法,固定高度不带刷新的和封装在Sliver中带刷新的使用,常用的两种方式都做了介绍并给出了代码。
这种布局并不局限框架,原生的用法直接 setState 切换状态,GetX 的 update 也能使用。
当然网上还有其他的一些用法,例如利用 Provide 的 Selector 去实现,用 GetX 的 Obx 实现,或者结合异步的 FutureBuilder 来构建布局等,万变不离其宗。
Ok,本文的代码文章中已经全部贴出,那么大家都是如何实现的呢,抛砖引玉如果有更多的更好的其他方式,也希望大家能评论区交流一起学习进步。
如果代码、注释、解释有不到位或错漏的地方,希望同学们可以指出。
如果感觉本文对你有一点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力。
Ok,这一期就此完结。
转载自:https://juejin.cn/post/7293410163751125033