Flutter学习之项目开发。

681 阅读7分钟

仿微信开发

项目中使用的假数据,从网络中获取。整体开发思路参考git上的一些项目。根据多个项目整理出适合自己的开发思路。

首页

需要实现的功能点

  • 本地JSON数据的获取。✅
  • JSON转model。✅
  • 列表中每个选项的左滑。✅
  • 下拉小程序。(最难的点)
  • 搜索
  • 右上角加好按钮弹框 需要的知识点:在开发中总结。

语言国际化/手机暗黑模式适配已经配置完毕,本地JSON文件数据获取已经搞定。项目基本搭建起来了。可以开始正常的功能/UI开发

暗黑模式适配我整理了一下:链接

列表基本样式开发。

Simulator Screen Shot - iPhone 13 Pro - 2022-04-21 at 18.26.05.png

IMG_E009D06E0EFA-1.jpeg

考虑到需要用到图片缓存,所以使用了cached_network_image插件:

cached_network_image: ^3.2.0

还有屏幕适配插件:

flutter_screenutil

然后再阿里巴巴矢量图库找了几个icon,静音icon和默认头像icon。

左边头像通过Wrap组件实现网格布局。 右边通过Column套用两个Row的方式实现。

child: ListTile(
  leading: leftWidget,
  title: rightWidget,
),

然后用ListTile把他俩组装起来。

编译起来看下效果。

WechatIMG193.png

看起来效果不错。

但是少了分割线。接下来添加分割先

添加分割线

因为组件比较多,所以我准备吧分割线头像和右侧label封装一下,名字就叫做:

WXWeChatListTitle

封装完毕,运行起来也挺完美,那么接下来的问题是:

截屏2022-04-25 14.50.45.png

怎么在打开第二个菜单的时候关闭第一个。

网上一顿搜索,都说是要设置:

 Slidable(
  controller:
 )

但是发现根本没有controller属性。

后来去查看官方文档,发现Slidable0.6版本之后就没有controller属性了。

关闭另外一个菜单的方法替换为以下方式。

image.png

也就是使用SlidableAutoCloseBehavior包裹ListView,探后在Slidable,设置groupTag属性,设置成一样的值,这样打开第二个第一个就会自动关闭啦。

以下是WXWeChatListTitle封装的全部代码:

class _WXWeChatListTitleState extends State<WXWeChatListTitle> {
  @override

  List<Widget> actionList = [];

  Widget build(BuildContext context) {
    actionList = _endActionChildren();
    return Slidable(
      groupTag:"1",
      key: Key(widget.chatModel.idstr),
      child: _buildChildWidget(),
      // key: ValueKey(0),
      endActionPane: ActionPane(
        motion: ScrollMotion(),
        dismissible: DismissiblePane(onDismissed: () {}),
        dragDismissible: false,
        extentRatio: actionList.length * 0.23,
        children: actionList,
      ),

    );
  }

  List<Widget> _endActionChildren() {
    List<Widget> tempList = [];

    /// 删除按钮
    Widget deleteBtn = SlidableAction(
      onPressed: (context) {
        Slidable.of(context)?.close();
      },
      flex: 3,
      backgroundColor: Color.fromRGBO(245, 56, 64, 1),
      foregroundColor: Colors.white,
      // icon: Icons.delete,
      label: '删除',
    );

    /// 标记未读
    Widget markUnreadBtn = SlidableAction(
      // An action can be bigger than the others.
      onPressed: (context) {},
      flex: 4,
      backgroundColor: Color.fromRGBO(20, 109, 231, 1),
      foregroundColor: Colors.white,

      // icon: Icons.archive,
      label: '标记未读',
    );

    /// 不显示
    Widget hideBtn = SlidableAction(
      // An action can be bigger than the others.
      onPressed: (context) {},
      flex: 3,
      backgroundColor: Color.fromRGBO(249, 135, 46, 1),
      foregroundColor: Colors.white,
      // icon: Icons.archive,
      label: '不显示',
    );


    Widget focusBtn = SlidableAction(
      // An action can be bigger than the others.
      onPressed: (context) {},
      flex: 4,
      backgroundColor: Color(0xFFC7C7CB),
      foregroundColor: Colors.white,
      // icon: Icons.archive,
      label: '不再关注',
    );



    if (widget.chatModel.type == '0') {
      // 订��号消息、微信运动、微信支付
      tempList.addAll([hideBtn,deleteBtn]);
    } else if (widget.chatModel.type == '1') {
      // 单聊、群聊、QQ邮箱提醒
      tempList.addAll([markUnreadBtn,hideBtn,deleteBtn]);
    } else {
      // 公众号
      tempList.addAll([focusBtn,hideBtn, deleteBtn]);
    }
    return tempList;
  }

  /// 构建分割线
  Widget _buildDividerWidget() {
    return Divider(
      height: widget.dividerHeight,
      color: widget.dividerColor,
      indent: widget.dividerIndent,
      endIndent: widget.dividerEndIndent,
    );
  }

  /// 构建左侧头像
  Widget _setLeftWidget() {
    return WeChatListPhoto(widget.chatModel);
  }
  /// 构建右侧布局
  Widget _setRightWidget() {
    return Column(
      children: [
        Row(
          children: [
            Expanded(
              child: Text(
                widget.chatModel.screenName,
                overflow: TextOverflow.ellipsis,
                maxLines: 1,
                style: TextStyle(
                  color: Style.pTextColor,
                ),
              ),
            ),
            Text(
              '2022/04/24',
              style: TextStyle(
                color: Color(0xFFB2B2B2),
                fontSize: ScreenUtil().setSp(36.0),
              ),
            ),
          ],
        ),
        SizedBox(height: ScreenUtil().setHeight(9.0)),
        Row(
          children: [
            Expanded(
              child: Text(
                widget.chatModel.text,
                overflow: TextOverflow.ellipsis,
                maxLines: 1,
                style: TextStyle(
                  color: Color(0xFF9B9B9B),
                  fontSize: ScreenUtil().setSp(48.0),
                ),
              ),
            ),
            Offstage(
              offstage: !widget.chatModel.messageFree,
              child: Image.asset(
                AssetsPathManage.assetsImagesMainframe +
                    'AlbumMessageDisableNotifyIcon_15x15.png',
                width: ScreenUtil().setWidth(45),
                height: ScreenUtil().setHeight(45.0),
              ),
            )
          ],
        )
      ],
    );
  }
  /// 构建主控件(组装)
  Widget _buildChildWidget() {
    return Column(
      children: <Widget>[
        ListTile(
          leading: _setLeftWidget(),
          title: _setRightWidget(),
          onTap: (){
            Slidable.of(context)?.close();
          },
        ),
        _buildDividerWidget(),
      ],
    );
  }
}

class WXWeChatListTitle extends StatefulWidget {
  /// 分割线高度 default is 0.5
  double dividerHeight = 1.0;

  /// 分割线颜色 default is 0xFFD8D8D8
  Color dividerColor = Color(0xFFD8D8D8);

  /// 分割线相对头部偏移量 default is 0.0
  double dividerIndent = ScreenUtil().setWidth(133);

  /// 分割线相对尾部偏移量 default is 0.0
  double dividerEndIndent = 0;

  /// 聊天的Entity
  MsglistEntity chatModel;

  /// 初始化器
  WXWeChatListTitle(this.chatModel, this.dividerHeight, this.dividerColor,
      this.dividerIndent, this.dividerEndIndent);

  @override
  State<WXWeChatListTitle> createState() => _WXWeChatListTitleState();
}

下拉刷新,上拉加载控件添加。

我这里使用的是pull_to_refresh

这次不去百度了,直接去看官方文档,防止走歪路: 果然根据官方文档很快就写出刷新和加载控件。记得要把语言国际化功能加上,(提示文字)。

下面是完整代码:这里官方反复强调 ListView一定要作为SmartRefresher的child

Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      // backgroundColor: Color.fromRGBO(19, 19, 19, 1),
      title: Text(S.of(context).tabbar_item_wechat),
    ),
    body: Container(
      child:SlidableAutoCloseBehavior(
        child:SmartRefresher(
          controller: _refreshController,
          onRefresh: _onRefresh,
          onLoading: _onLoading,
          enablePullDown: true,
          enablePullUp: true,
          header: WaterDropHeader(),
          footer: CustomFooter(
            builder: (context,mode){
              Widget body ;
              if(mode==LoadStatus.idle){
                ///上拉加载
                body =  Text(S.of(context).listView_loadMore);
              }else if(mode==LoadStatus.loading){
                body =  CupertinoActivityIndicator();
              }else if(mode == LoadStatus.failed){
                ///加载失败!点击重试!
                body = Text(S.of(context).listView_LoadFailed);
              }else if(mode == LoadStatus.canLoading){
                ///松手加载更多
                body = Text(S.of(context).listView_loadMore);
              }else{
                ///没有更多数据了.
                body = Text(S.of(context).listView_NoMoreData);
              }
              return Container(
                height: 55.0,
                child: Center(child:body),
              );
            },
          ),
          child: ListView(
            children: _getData(),
          ),
        ) ,
      ),
    ),
  );

官方文档还提供有设置全局上拉刷新下拉加载控件的方法。

全局配置RefreshConfiguration,配置子树下的所有SmartRefresher表现,一般存放于MaterialApp的根部,用法和ScrollConfiguration是类似的。 另外,假如你某一个SmartRefresher表现和全局不一样的情况,你可以使用RefreshConfiguration.copyAncestor从祖先RefreshConfiguration复制属性过来并替换不为空的属性。

    // 全局配置子树下的SmartRefresher,下面列举几个特别重要的属性
     RefreshConfiguration(
         headerBuilder: () => WaterDropHeader(),        // 配置默认头部指示器,假如你每个页面的头部指示器都一样的话,你需要设置这个
         footerBuilder:  () => ClassicFooter(),        // 配置默认底部指示器
         headerTriggerDistance: 80.0,        // 头部触发刷新的越界距离
         springDescription:SpringDescription(stiffness: 170, damping: 16, mass: 1.9),         // 自定义回弹动画,三个属性值意义请查询flutter api
         maxOverScrollExtent :100, //头部最大可以拖动的范围,如果发生冲出视图范围区域,请设置这个属性
         maxUnderScrollExtent:0, // 底部最大可以拖动的范围
         enableScrollWhenRefreshCompleted: true, //这个属性不兼容PageView和TabBarView,如果你特别需要TabBarView左右滑动,你需要把它设置为true
         enableLoadingWhenFailed : true, //在加载失败的状态下,用户仍然可以通过手势上拉来触发加载更多
         hideFooterWhenNotFull: false, // Viewport不满一屏时,禁用上拉加载更多功能
         enableBallisticLoad: true, // 可以通过惯性滑动触发加载更多
        child: MaterialApp(
            ........
        )
    );

然后在设置语言国际化delete的地方加入RefreshLocalizations.delegate,

    MaterialApp(
            localizationsDelegates: [
              // 这行是关键
              RefreshLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              GlobalMaterialLocalizations.delegate
            ],
            supportedLocales: [
              const Locale('en'),
              const Locale('zh'),
            ],
            localeResolutionCallback:
                (Locale locale, Iterable<Locale> supportedLocales) {
              //print("change language");
              return locale;
            },
    )

等等,好像忘了一样东西,适配系统主题色!!! 在dark模式下确实有问题:如下

WeChat5fb7feb528e8e2f0bdbf8be020332b8a.png

20220426

主要是字体颜色的问题。 接下来创建一个管理类,名字就叫做:WXDarkAndLightConfig

import 'package:flutter/material.dart';
///系统主题色适配
import 'package:wechat_flutter_practice/constant/style.dart';
import 'package:get/get.dart';

class WXDarkAndLightConfig{
  /// 颜色key
  static const String wechatTitleKey = 'wechat_title_key';
  static const String wechatSubtitleKey = 'wechat_subtitle_key';

  static Color getColor(String key){
    final colors = _SystemThemeColor._colors[key];
    if (colors == null) {
      return Style.pTextColor;
    }
    if (Get.isDarkMode == true) {
      return colors[1];
    }
    return colors[0];
  }

}

class _SystemThemeColor{

  static const _colors = {
    "wechat_title_key":[_titleColorDark, _titleColorLight],
    "wechat_subtitle_key":[_subTitleColorDark, _subTitleColorLight],
  };



  static const _titleColorDark = Colors.white;
  static const _titleColorLight = Style.pTextColor;
  static const _subTitleColorDark = Color.fromRGBO(269,267, 268, 1);
  static const _subTitleColorLight = Color.fromRGBO(269,267, 268, 1);

}

主要是作用页面中的文字/背景颜色通过WXDarkAndLightConfig获取。通过GetX插件获取当前系统主题Get.isDarkMode

但是代码中还有不足的地方,比如

/// 颜色key
static const String wechatTitleKey = 'wechat_title_key';
static const String wechatSubtitleKey = 'wechat_subtitle_key';
static const _colors = {
  "wechat_title_key":[_titleColorDark, _titleColorLight],
  "wechat_subtitle_key":[_subTitleColorDark, _subTitleColorLight],
};

每加入一个颜色,这两个地方都要加上代码。感觉很繁琐,而且容易漏掉某一个。但是一时想不出怎么解决,后续在进行优化。

使用方法:感觉也很繁琐。

WXDarkAndLightConfig.getColor(WXDarkAndLightConfig.wechatTitleKey)

我想要的效果是。

WXDarkAndLightConfig.wechatTitleKey

我想到Swift中有变量的get和set方法。 刚好Dart也有。

经过修改,代码改变为下面这种。

class WXDarkAndLightConfig{
  /// 颜色key
  static Color get wechatTitleColor{
    return  _SystemThemeColor.getColor('wechat_title_key');
  }

  static Color get wechatSubtitleColor {
    return _SystemThemeColor.getColor('wechat_subtitle_key');
  }

}

class _SystemThemeColor{
  
  static const _colors = {
    "wechat_title_key":[_titleColorDark, _titleColorLight],
    "wechat_subtitle_key":[_subTitleColorDark, _subTitleColorLight],
  };

  static Color getColor(String key){
    final colors = _SystemThemeColor._colors[key];
    if (colors == null) {
      return Style.pTextColor;
    }
    if (Get.isDarkMode == true) {
      return colors[1];
    }
    return colors[0];
  }

  static const _titleColorDark = Colors.white;
  static const _titleColorLight = Style.pTextColor;
  static const _subTitleColorDark = Color.fromRGBO(269,267, 268, 1);
  static const _subTitleColorLight = Color.fromRGBO(269,267, 268, 1);

}

这样就可以直接:

WXDarkAndLightConfig.wechatTitleKey

完美!

WeChat5a4e09956b9dec1c0103142209b78f2b.png

接下来做输入框: 输入框可能困难的是动画怎么过度到搜索页,想到了 Hero 动画。试试看。 网上一堆查找,发现没有适合的第三方组建。那么自己动手做吧。

20220427

今天遇到了两个问题以及解决方法: juejin.cn/post/709118… 搜索框的搭建,导航条的搭建. 这两个目前已经完成。下一步

  1. 着手搭建点击右上角加号按钮后的菜单。
  2. 搭建下拉展示最近使用的小程序功能。
  3. ThemeData对象的学习,该属性影响着整个项目中的系统主题色适配。需要把该对象的每个属性都尝试修改一下,看看影响的点。 下图是当前的开发成果:

image.png Simulator Screen Shot - iPhone 13 Pro - 2022-04-27 at 19.30.38.png