《Flutter 实战开发旅行社交手机APP》第一章 广场模块 1.1 游记列表

视图层

列表视图,包括上拉加载,下拉刷新,骨架装载器、自定义AppBar

第三方库

包括上拉加载、下拉刷新 easyrefreshskeleton 骨架加载器和支持空安全的 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_pagelast_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 "游记列表"
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

geeksoarsky

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值