视图层
列表视图,包括上拉加载,下拉刷新,骨架装载器、自定义AppBar
第三方库
包括上拉加载、下拉刷新 easyrefresh,skeleton 骨架加载器和支持空安全的 swiper 轮播图。
flutter_easyrefresh: ^2.2.1
skeleton_text: ^3.0.0
flutter_swiper_null_safety: ^1.0.2
基本视图
lib/page/Dynamic/Dynamic.dart
import 'package:flutter/material.dart';
import 'package:flutter_easyrefresh/easy_refresh.dart';
import 'package:flutter_locyin/page/Dynamic/app_bar.dart';
import 'package:flutter_locyin/page/menu/menu.dart';
import 'package:flutter_locyin/widgets/skeleton.dart';
class DynamicPage extends StatefulWidget {
const DynamicPage({Key? key}) : super(key: key);
@override
_DynamicPageState createState() => _DynamicPageState();
}
class _DynamicPageState extends State<DynamicPage> {
//EasyRefresh控制器
late EasyRefreshController _controller;
//主要用于打开抽屉
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
@override
void initState() {
super.initState();
//初始化控制器
_controller = EasyRefreshController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
//抽屉
drawer: MenuDrawer(),
body:SafeArea(
child: Flex(
direction: Axis.vertical,
children: [
//自定义AppBar
DynamicAppBarWidget( scaffoldKey: _scaffoldKey,),
Flexible(
//上拉加载、下拉刷新
child: EasyRefresh(
enableControlFinishRefresh: false,
enableControlFinishLoad: true,
controller: _controller,
header: ClassicalHeader(),
footer: ClassicalFooter(),
//下拉刷新
onRefresh: () async {
await Future.delayed(Duration(seconds: 2), () {
print("正在刷新数据...");
});
},
//上拉加载
onLoad: () async {
await Future.delayed(Duration(seconds: 2), () {
print('onLoad');
setState(() {
_count += 10;
});
print("count: $_count");
//如果计数器大于 30 则显示没有更多了
_controller.finishLoad(noMore: _count >= 30);
});
},
child:CustomScrollView(
slivers: <Widget>[
//=====列表=====//
Container(
child: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
//骨架加载器
return SkeletonWidget();
},
childCount: _count,
),
)
),
],
),
),
),
],
),
),
);
}
}
AppBar
Flutter 自带的太丑了,我自定义了动态页的 AppBar,包括左侧 menu 按钮用于打开抽屉,中间显示标题 title ,右侧
search 按钮跳转到检索页。传递 scaffoldKey 用于打开抽屉。
lib/page/Dynamic/app_bar.dart
import 'package:flutter/material.dart';
import 'package:flutter_locyin/utils/toast.dart';
class DynamicAppBarWidget extends StatelessWidget {
final GlobalKey<ScaffoldState> scaffoldKey;
const DynamicAppBarWidget({Key? key, required this.scaffoldKey}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
child: Container(
height: 48,
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () {
scaffoldKey.currentState!.openDrawer();
},
child: Icon(Icons.menu_outlined),
),
Text(
"首页",
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
),
InkWell(
onTap: () {
ToastUtils.toast("跳转到检索页");
//_scaffoldKey.currentState.openDrawer();
},
child: Icon(Icons.search),
),
],
),
),
);
}
}
骨架加载器
SkeletonWidget() 是骨架加载器插件,封装成无状态小部件,可以直接使用,包含两种样式。
lib/widgets/skeleton.dart
import 'package:flutter/material.dart';
import 'package:skeleton_text/skeleton_text.dart';
class SkeletonWidget extends StatelessWidget {
const SkeletonWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
//用于骨架加载器的一个阴影类型的列表
List<BoxShadow> shadowList = [
BoxShadow(color: Colors.grey[300]!, blurRadius: 30, offset: Offset(0, 10))
];
return Container(
height: 200,
margin: EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
//样式一
/*Expanded(
child: SkeletonAnimation(
shimmerColor: Colors.grey,
borderRadius: BorderRadius.circular(20),
shimmerDuration: 1000,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(20),
boxShadow: shadowList,
),
margin: EdgeInsets.only(top: 40),
),
),
),*/
//样式二
Expanded(
child: Container(
margin: EdgeInsets.only(top: 20, bottom: 20),
decoration: BoxDecoration(
color: Colors.grey,
boxShadow: shadowList,
borderRadius: BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 15.0, bottom: 5.0),
child: SkeletonAnimation(
borderRadius: BorderRadius.circular(10.0),
//shimmerColor: index % 2 != 0 ? Colors.grey : Colors.white54,
child: Container(
height: 30,
width: MediaQuery.of(context).size.width * 0.35,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
color: Colors.grey[300]),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 15.0),
child: Padding(
padding: const EdgeInsets.only(right: 5.0),
child: SkeletonAnimation(
borderRadius: BorderRadius.circular(10.0),
//shimmerColor: index % 2 != 0 ? Colors.grey : Colors.white54,
child: Container(
width: 60,
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0),
color: Colors.grey[300]),
),
),
),
),
],
),
),
),
],
),
);
}
}
修改导航
lib/page/index.dart
import 'package:flutter_locyin/page/Dynamic/Dynamic.dart';
·
·
·
List<BottomNavigationBarItem> getTabs(BuildContext context) => [
BottomNavigationBarItem(label: 'navigationHome'.tr, icon: Icon(Icons.home)),
BottomNavigationBarItem(label: 'navigationFind'.tr, icon: Icon(Icons.find_in_page)),
BottomNavigationBarItem(label: 'navigationMap'.tr, icon: Icon(Icons.map)),
BottomNavigationBarItem(label: 'navigationMessage'.tr, icon: Icon(Icons.notifications)),
BottomNavigationBarItem(label: 'navigationMine'.tr, icon: Icon(Icons.person)),
];
List<Widget> getTabWidget(BuildContext context) =>
[DynamicPage(), FindPage(), MapPage(), MessagePage(), MinePage()];
·
·
·
演示
数据层
实体类,接口,服务类,状态管理
API
Laravel 服务器返回的游记列表的 JSON 格式如下
实体类
根据 JSON 自动生成的游记列表实体类
lib/data/model/dynamic_list_entity.dart
import 'package:flutter_locyin/generated/json/base/json_convert_content.dart';
import 'package:flutter_locyin/generated/json/base/json_field.dart';
class DynamicListEntity with JsonConvert<DynamicListEntity> {
late List<DynamicListData> data;
late DynamicListLinks links;
late DynamicListMeta meta;
}
class DynamicListData with JsonConvert<DynamicListData> {
late int id;
@JSONField(name: "user_id")
late int userId;
@JSONField(name: "thumb_count")
late int thumbCount;
late int thumbed;
late int collected;
@JSONField(name: "collect_count")
late int collectCount;
@JSONField(name: "comment_count")
late int commentCount;
@JSONField(name: "created_at")
late String createdAt;
@JSONField(name: "updated_at")
late String updatedAt;
late String content;
late String location;
late DynamicListDataUser user;
late List<DynamicListDataImages> images;
}
class DynamicListDataUser with JsonConvert<DynamicListDataUser> {
late int id;
late String username;
late String nickname;
late String avatar;
late String email;
late String introduction;
@JSONField(name: "notification_count")
late int notificationCount;
@JSONField(name: "created_at")
late String createdAt;
@JSONField(name: "updated_at")
late String updatedAt;
}
class DynamicListDataImages with JsonConvert<DynamicListDataImages> {
late int id;
@JSONField(name: "user_id")
late int userId;
@JSONField(name: "dynamic_id")
late int dynamicId;
late String type;
late String path;
}
class DynamicListLinks with JsonConvert<DynamicListLinks> {
late String first;
late String last;
late dynamic prev;
late String next;
}
class DynamicListMeta with JsonConvert<DynamicListMeta> {
@JSONField(name: "current_page")
late int currentPage;
late int from;
@JSONField(name: "last_page")
late int lastPage;
late List<DynamicListMetaLinks> links;
late String path;
@JSONField(name: "per_page")
late String perPage;
late int to;
late int total;
}
class DynamicListMetaLinks with JsonConvert<DynamicListMetaLinks> {
late String url;
late String label;
late bool active;
}
接口
lib/data/api/apis.dart
·
·
·
/// 上传头像
static const String UPLOAD_AVATAR = "avatars";
/// 游记列表
static const String DYNAMIC ="dynamics";
服务类
lib/data/api/apis_service.dart
发起 Dio 请求获取 JSON 数据,随之转为实体类,将其作为参数回调。
·
·
·
/// 获取用户个人信息
Future<void> getUserInfo(Function callback, Function errorCallback) async {
await BaseNetWork.instance.dio.get(Apis.USER_INFO).then((response) {
print(response);
callback(UserEntity().fromJson(response.data));
}).catchError((e) {
errorCallback(e);
});
}
/// 获取广场列表数据
Future<void> getDynamicList(Function callback, Function errorCallback , int page)async {
BaseNetWork.instance.dio.get(Apis.DYNAMIC+ "?page="+page.toString()).then((response) {
callback(
DynamicListEntity().fromJson(response.data)
);
}).catchError((e) {
errorCallback(e);
});
}
状态管理
lib/utils/getx.dart
_dynamicList 数据记录游记列表,调用 Api 服务类,传递页数参数,如果页数为1则将 model 直接赋值,不为1则将 model 追加到已存在的 _dynamicList 数组中,并更新 meta 值,meta 数据结构如下:
聪明的读者发现了,我们可以通过比较 current_page
和 last_page
来判断还有没有更多数据。
// 游记列表状态控制器
class DynamicController extends GetxController{
//游记列表
DynamicListEntity? _dynamicList;
DynamicListEntity? get dynamicList => _dynamicList;
//用于判断是否正在异步请求数据,避免多次请求
bool _dynamic_running = false;
bool get dynamic_running => _dynamic_running;
Future getDynamicList (int page) async{
_dynamic_running = true;
apiService.getDynamicList((DynamicListEntity model) {
if(_dynamicList == null || page==1){
_dynamicList = model;
}
else{
if(_dynamicList!.meta.currentPage == model.meta.currentPage){
return;
}
_dynamicList!.data.addAll(model.data);
_dynamicList!.meta = model.meta;
_dynamicList!.links = model.links;
}
print("更新视图");
_dynamic_running = false;
update(['list']);
}, (DioError error) {
_dynamic_running = false;
print(error.response);
},page);
}
}
逻辑层
插件库
安装组件,cached_network_image 用于图片缓存,image_gallery_saver 用于保存图片,permission_handler 权限处理,share 用于分享。
cached_network_image: ^3.1.0
image_gallery_saver: ^1.6.9
share: ^2.0.4
permission_handler: ^8.1.4+2
item 视图
游记列表的游记项布局都是一样的,只是数据不同,我们可以将 item 子项封装起来,并设置必要参数。
lib/widgets/lists/dynamic_item.dart
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_locyin/utils/toast.dart';
import 'package:flutter_locyin/widgets/like_button.dart';
import 'package:share/share.dart';
import 'package:get/get.dart' as getx;
/// 游记列表详情
class DynamicListItem extends StatefulWidget {
//id
final int id;
//头像
final String avatar;
//昵称
final String nickname;
//动态图片内容
final String? imageUrl;
//动态文字内容
final String content;
//动态喜欢数
final int like;
//动态评论数
final int comment;
//动态时间
final String time;
//已赞
final bool thumbed;
const DynamicListItem({Key? key, required this.id, required this.avatar, required this.nickname, required this.imageUrl, required this.content, required this.like, required this.comment, required this.time, required this.thumbed}) : super(key: key);
@override
_DynamicListItemState createState() => _DynamicListItemState();
}
class _DynamicListItemState extends State<DynamicListItem> {
@override
void initState() {
// TODO: implement initState
super.initState();
}
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
elevation: 0,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
child: Column(
children: [
Container(
//padding: const EdgeInsets.symmetric(horizontal: 16),
height: 45,
color: getx.Get.theme.cardColor,
child: Row(
children: <Widget>[
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: getx.Get.theme.cardColor,
shape: BoxShape.circle,
image: DecorationImage(image:NetworkImage(widget.avatar),fit: BoxFit.fitWidth)
),
),
SizedBox(
width: 8,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.nickname,
style: TextStyle(fontSize: 15,),
),
Text(
widget.time,
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
)
],
),
),
Icon(Icons.more_vert,)
],
),
),
Padding(
padding: const EdgeInsets.only(left: 40),
child: Column(
children: [
widget.imageUrl!=null?InkWell(
onTap: () {
getx.Get.toNamed(
"/index/dynamic/detail?id=${Uri.encodeComponent(widget.id.toString())}");
},
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadiusDirectional.circular(10)),
clipBehavior: Clip.antiAlias,
child: CachedNetworkImage(
imageUrl:widget.imageUrl.toString(),
width: double.maxFinite,
placeholder:(context,url)=> Image.asset('assets/images/loading.gif',fit: BoxFit.cover),
fit: BoxFit.cover,
errorWidget: (context, url, error) => new Icon(Icons.error),
),
),
):Container(),
SizedBox(
height: 8,
),
InkWell(
onTap: () {
getx.Get.toNamed(
"/index/dynamic/detail?id=${Uri.encodeComponent(widget.id.toString())}");
},
child: Text(
widget.content,
maxLines: 4,
style:
TextStyle(fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.share),
onPressed: () {
Share.share("https://lotvin.com");
}),
Row(
children: [
//Icon(Icons.favorite,color: thumbed? Colors.cyan : Colors.grey,),
LikeButtonWidget(id: widget.id, like: widget.like, thumbed: widget.thumbed),
SizedBox(
width: 10,
),
IconButton(
icon: Icon(Icons.mode_comment_outlined),
onPressed: (){
//ToastUtils.toast("跳转到游记详情页");
getx.Get.toNamed(
"/index/dynamic/detail?id=${Uri.encodeComponent(widget.id.toString())}");
},
),
Text("${widget.comment}"),
],
)
],
),
SizedBox(
height: 16,
),
],
),
),
],
),
),
),
);
}
}
列表视图
之前动态列表 item 都是骨架加载器,现在对其改造,只有游记状态中的dynamicList
为空时显示5个骨架加载器,不为空则显示每一个游记。
lib/page/Dynamic/dynamic.dart
·
·
·
child:CustomScrollView(
slivers: <Widget>[
//=====列表=====//
Container(
child: GetBuilder<DynamicController>(
init:DynamicController(),
builder: (controller){
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return getDynamicListView(index);
//return getDynamicListView(index);
},
childCount: controller.dynamicList == null?5:controller.dynamicList!.data.length,
),
);
}),
),
],
),
·
·
·
Widget getDynamicListView(int index) {
DynamicListEntity? _dynamic_list =
Get.find<DynamicController>().dynamicList;
if (_dynamic_list == null) {
print(
"正在请求列表数据...................................................................");
if (!Get.find<DynamicController>().dynamic_running) {
Get.find<DynamicController>().getDynamicList(1);
}
return SkeletonWidget();
} else {
return DynamicListItem(
id: _dynamic_list.data[index].id,
avatar: _dynamic_list.data[index].user.avatar,
nickname: _dynamic_list.data[index].user.nickname,
imageUrl: _dynamic_list.data[index].images.length>0?_dynamic_list.data[index].images[0].path:null,
content: _dynamic_list.data[index].content,
like: _dynamic_list.data[index].thumbCount,
comment: _dynamic_list.data[index].commentCount,
time: _dynamic_list.data[index].updatedAt,
thumbed: _dynamic_list.data[index].thumbed == 1 ? true : false);
}
}
代码解读
SliverList 外部使用 GetBuilder 包裹就可以完成局部刷新。
if(_dynamic_list == null){
if(!Get.find<DynamicController>().running){
Get.find<DynamicController>().getDynamicList(1);
}
return SkeletonWidget();
}
当动态列表状态控制器内的游记列表 _dynamicList
为空时,且没有正在执行的异步请求,则调用getDynamicList(1)获取游记列表第一页数据。
当动态列表状态控制器内的游记列表 _dynamicList
不为空时,就返回 item 视图,并传递必要参数,其中 SliverList 的 index 索引与游记列表的 index 索引其实是一一对应的关系,换句话说,通过SliverList 的 index 索引对应的_dynamicList 数组元素的数据构建一个个 item 视图。
上拉加载,下拉刷新
接下来继续完善上拉加载,下拉刷新
·
·
·
//下拉刷新
onRefresh: () async {
await Future.delayed(Duration(seconds: 2), () {
print("正在刷新数据...");
if (!Get.find<DynamicController>().dynamic_running) {
Get.find<DynamicController>().getDynamicList(1);
}
_controller.resetLoadState();
});
},
//上拉加载
onLoad: () async {
await Future.delayed(Duration(seconds: 2), () {
print('onLoad');
/*setState(() {
_count += 10;
});*/
if (!Get.find<DynamicController>().dynamic_running) {
Get.find<DynamicController>().getDynamicList(
Get.find<DynamicController>()
.dynamicList!
.meta
.currentPage +
1);
}
print("count: $_count");
//如果计数器大于 30 则显示没有更多了
_controller.finishLoad(
noMore: Get.find<DynamicController>()
.dynamicList!
.meta
.currentPage >=
Get.find<DynamicController>()
.dynamicList!
.meta
.lastPage);
});
},
·
·
·
下拉刷新很好理解,如果没有正在执行的异步请求,则调用 getDynamicList(1) 获取游记列表第一页数据,
注意我们动态状态管理器中的方法,如果 page 为1 ,是将数据直接赋值给_dynamicList
的,这将覆盖之前的数据,完成了数据刷新,最后通知视图刷新即可。
if(_dynamicList == null || page==1){
_dynamicList = model;
}
上拉加载通过 dynamicList!.meta.currentPage获取当前页面,+1 就是下一页。
如果 dynamicList!.meta.currentPage 大于等于 dynamicList!.meta.lastPage 就意味着一定没有下一页了。
演示
版本提交
git add -A
git commit -m "游记列表"