Flutter 自定义实现时间轴、侧边进度条

简介: Flutter 自定义实现时间轴、侧边进度条

因为 flutter 提供的 Stepper 无法满足业务需求,于是只好自己实现一个。

flutter Stepper 的样式

我实现的 Stepper

这个或许根本不叫 Stepper 吧,也没有什么步骤,只是当前的配送进度,不需要数字步骤,希望所有内容都能显示出来,原生的则是有数字表示第几步,把当前步骤外的其他的内容都隐藏了。

那么开始进行分析,整个需求中,有点难度的也就是这个左边的进度线了。我们把进度看做一个 ListView ,每条进度都是一个 Item

先来看怎么布局这个Item,一开始我是想在最外层做成一个 Row 布局,像这样

左边是圆和线,右边是内容,然而我太天真了,左边的 线 高度没法跟随右边的高度,即右边有多高,左边就有多高。也就是我必须给左边的View设置一个高度,否则就没法显示出来。。。绝望ing,如果我左边写死了高度,右边的内容因为用户字体过大而高度超过左边的线,那么两个 Item 之间的线就没法连在一起了。

然后我看到了 Flutter 的 Stepper ,虽然不符合需求,但是人家左边的线是 Item 和 Item 相连的,我就看了下他的源码,豁然开朗,人家的布局是个 Colum 。整体看起来是这样的。

这样的话,就好理解了,Colum 的第一个 child 我们称为 Head , 第二个 child 我们称为 Body 。

Head 的布局如图是个 Row,左边是圆和线,右边是个 Text。

Body 的布局是个 Container , 包含了一个 Column ,Column 里面就是两个Text。相信小伙伴们已经想到了,Body左边的那条线就是 Container 的 border

圆和线我选择自己绘制,下面是线和圆的自定义View代码

 
class LeftLineWidget extends StatelessWidget {
  final bool showTop;
  final bool showBottom;
  final bool isLight;
 
  const LeftLineWidget(this.showTop, this.showBottom, this.isLight);
 
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(horizontal: 16),//圆和线的左右外边距
      width: 16,
      child: CustomPaint(
        painter: LeftLinePainter(showTop, showBottom, isLight),
      ),
    );
  }
}
 
class LeftLinePainter extends CustomPainter {
  static const double _topHeight = 16; //圆上的线高度
  static const Color _lightColor = XColors.mainColor;//圆点亮的颜色
  static const Color _normalColor = Colors.grey;//圆没点亮的颜色
 
  final bool showTop; //是否显示圆上面的线
  final bool showBottom;//是否显示圆下面的线
  final bool isLight;//圆形是否点亮
 
  const LeftLinePainter(this.showTop, this.showBottom, this.isLight);
 
  @override
  void paint(Canvas canvas, Size size) {
    double lineWidth = 2; // 竖线的宽度
    double centerX = size.width / 2; //容器X轴的中心点
    Paint linePain = Paint();// 创建一个画线的画笔
    linePain.color = showTop ? Colors.grey : Colors.transparent;
    linePain.strokeWidth = lineWidth;
    linePain.strokeCap = StrokeCap.square;//画线的头是方形的
    //画圆上面的线
    canvas.drawLine(Offset(centerX, 0), Offset(centerX, _topHeight), linePain);
    //依据下面的线是否显示来设置是否透明
    linePain.color = showBottom ? Colors.grey : Colors.transparent;
    // 画圆下面的线
    canvas.drawLine(
        Offset(centerX, _topHeight), Offset(centerX, size.height), linePain);
    // 创建画圆的画笔
    Paint circlePaint = Paint();
    circlePaint.color = isLight ? _lightColor : _normalColor;
    circlePaint.style = PaintingStyle.fill;
    // 画中间的圆
    canvas.drawCircle(Offset(centerX, _topHeight), centerX, circlePaint);
  }
 
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    if(oldDelegate is LeftLinePainter){
      LeftLinePainter old = oldDelegate;
      if(old.showBottom!=showBottom){
        return true;
      }
      if(old.showTop!=showTop){
        return true;
      }
      if(old.isLight!=isLight){
        return true;
      }
      return false;
    }
    return true;
  }
}
 

左侧的圆和线是3个部分,分别是圆的上面那条线,和圆,以及圆下面的那条线,

通过 showTopshowBottom 来控制上面那条线和下面那条线是否显示。

圆和线解决了,我就把Head组装起来

Row(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: <Widget>[
    // 圆和线
    Container( 
      height: 32,
      child: LeftLineWidget(false, true, true),
    ),
    Expanded(child: Container(
      padding: EdgeInsets.only(top: 4),
      child: Text(
        '天天乐超市(限时降价)已取货',
        style: TextStyle(fontSize: 18),
        overflow: TextOverflow.ellipsis,
      ),
    ))
  ],
)

编译运行后截图

(这里截图跟之前不一样是因为我又单独建立了一个demo)

接下来写下面的 Body

Container(
  //这里写左边的那条线
  decoration: BoxDecoration(
    border:Border(left: BorderSide(
      width: 2,// 宽度跟 Head 部分的线宽度一致,下面颜色也是
      color: Colors.grey
    ))
  ),
  margin: EdgeInsets.only(left: 23), //这里的 left 的计算在代码块下面解释怎么来的
  padding: EdgeInsets.fromLTRB(22,0,16,16),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      Text('配送员:吴立亮 18888888888'),
      Text('时间:2018-12-17 09:55:22')
    ],
  ),
)

这里说一下 margin 的 left 参数值是怎么计算的。

设置这个是为了 Body 的左边框跟上面 Head 的线能对齐连上,不能错开。

首先我们的 LeftLineWidget 是有个 margin 的,他的左右外边距是16,自身的宽度是16。因为线在中间,所以宽度要除以2。那就是:左外边距+宽度除以2 left = 16 + 16/2 算出来是24。

可是我们这里写的23,是因为边框的线的宽度是从容器的边界往里面走的。我们算出来的边距会让 Body 的容器边界在上面的线中间。看起来像这样。

所以还要减去线宽的一半,线宽是2,除以2等于1, 最后left = 16+(16/2)-(2/2)=23,翻译成中文 left = LeftLineWidget左边距+(LeftLineWidget宽度➗2)-(LeftLineWidget线宽➗2)

最后看起来像这样:

多复制几个

最后一item要隐藏边框,把边框线颜色设置为透明即可。

渲染树是这样的

最后奉上完整代码:

import 'package:flutter/material.dart';
 
void main() => runApp(MyApp());
 
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Stepper',
      home: Scaffold(
        appBar: AppBar(
          elevation: 0,
          title: Text('自定义View'),
        ),
        body: ListView(
          shrinkWrap: true,
          children: <Widget>[
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(// 圆和线
                      height: 32,
                      child: LeftLineWidget(false, true, true),
                    ),
                    Expanded(child: Container(
                      padding: EdgeInsets.only(top: 4),
                      child: Text(
                        '天天乐超市(限时降价)已取货',
                        style: TextStyle(fontSize: 18),
                        overflow: TextOverflow.ellipsis,
                      ),
                    ))
                  ],
                ),
                Container(
                  decoration: BoxDecoration(
                    border:Border(left: BorderSide(
                      width: 2,
                      color: Colors.grey
                    ))
                  ),
                  margin: EdgeInsets.only(left: 23),
                  padding: EdgeInsets.fromLTRB(22,0,16,16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('配送员:吴立亮 18888888888'),
                      Text('时间:2018-12-17 09:55:22')
                    ],
                  ),
                )
              ],
            ),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(// 圆和线
                      height: 32,
                      child: LeftLineWidget(true, true, false),
                    ),
                    Expanded(child: Container(
                      padding: EdgeInsets.only(top: 4),
                      child: Text(
                        '天天乐超市(限时降价)已取货',
                        style: TextStyle(fontSize: 18),
                        overflow: TextOverflow.ellipsis,
                      ),
                    ))
                  ],
                ),
                Container(
                  decoration: BoxDecoration(
                      border:Border(left: BorderSide(
                          width: 2,
                          color: Colors.grey
                      ))
                  ),
                  margin: EdgeInsets.only(left: 23),
                  padding: EdgeInsets.fromLTRB(22,0,16,16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('配送员:吴立亮 18888888888'),
                      Text('时间:2018-12-17 09:55:22')
                    ],
                  ),
                )
              ],
            ),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(// 圆和线
                      height: 32,
                      child: LeftLineWidget(true, false, false),
                    ),
                    Expanded(child: Container(
                      padding: EdgeInsets.only(top: 4),
                      child: Text(
                        '天天乐超市(限时降价)已取货',
                        style: TextStyle(fontSize: 18),
                        overflow: TextOverflow.ellipsis,
                      ),
                    ))
                  ],
                ),
                Container(
                  decoration: BoxDecoration(
                      border:Border(left: BorderSide(
                          width: 2,
                          color: Colors.transparent
                      ))
                  ),
                  margin: EdgeInsets.only(left: 23),
                  padding: EdgeInsets.fromLTRB(22,0,16,16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('配送员:吴立亮 18888888888'),
                      Text('时间:2018-12-17 09:55:22')
                    ],
                  ),
                )
              ],
            ),
          ],
        ),
      ),
    );
  }
}
 
class LeftLineWidget extends StatelessWidget {
  final bool showTop;
  final bool showBottom;
  final bool isLight;
 
  const LeftLineWidget(this.showTop, this.showBottom, this.isLight);
 
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(horizontal: 16),
      width: 16,
      child: CustomPaint(
        painter: LeftLinePainter(showTop, showBottom, isLight),
      ),
    );
  }
}
 
class LeftLinePainter extends CustomPainter {
  static const double _topHeight = 16;
  static const Color _lightColor = Colors.deepPurpleAccent;
  static const Color _normalColor = Colors.grey;
 
  final bool showTop;
  final bool showBottom;
  final bool isLight;
 
  const LeftLinePainter(this.showTop, this.showBottom, this.isLight);
 
  @override
  void paint(Canvas canvas, Size size) {
    double lineWidth = 2;
    double centerX = size.width / 2;
    Paint linePain = Paint();
    linePain.color = showTop ? Colors.grey : Colors.transparent;
    linePain.strokeWidth = lineWidth;
    linePain.strokeCap = StrokeCap.square;
    canvas.drawLine(Offset(centerX, 0), Offset(centerX, _topHeight), linePain);
    Paint circlePaint = Paint();
    circlePaint.color = isLight ? _lightColor : _normalColor;
    circlePaint.style = PaintingStyle.fill;
    linePain.color = showBottom ? Colors.grey : Colors.transparent;
    canvas.drawLine(
        Offset(centerX, _topHeight), Offset(centerX, size.height), linePain);
    canvas.drawCircle(Offset(centerX, _topHeight), centerX, circlePaint);
  }
 
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}
 

原文链接:https://www.jianshu.com/p/2c90bdf9a2ce


Flutter时间轴(timeline)

组件

在业务开发中经常会使用到timeline时间轴,来记录数据操作记录等,在这本人封装了一个相对较通用的时间轴组件。

示例

参数

timelineList

调用示例

import '@/timeline/timeline.dart';
final list = [
    {
      'day': '07-08',
      'time': '13:20',
      'remark': "备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑",
      'description': '',
      'subtitle': '齐本安(主播)',
      'title': "新建工单"
    },
    {
      'id': "2",
      'day': '07-08',
      'time': '13:20',
      'description': "备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑",
      'subtitle': '吴雄飞(销售专员)',
      'title': "联系客户"
    },
    {
      'id': "3",
      'day': '07-08',
      'time': '13:20',
      'description': "备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑,辛苦再撮合",
      'title': "新建工单"
    },
    {
      'id': "4",
      'day': '07-08',
      'time': '13:20',
      'description': "备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑,辛苦再撮合备注:降价1000客户可考虑,辛苦再撮合",
      'subtitle': '齐本安(主播)',
      'title': "新建工单"
    },
    {
      'id': "5",
      'day': '07-08',
      'time': '13:20',
      'description': "备注:降价1000客户可考虑",
      'subtitle': '吴雄飞(主播)',
      'title': "新建工单"
    }
  ];
TimelineComponent(
  timelineList: list,
  lineColor: WBColors.color_cccccc,
  leftContent: false,
  height: 80.0,
)

源码地址: https://github.com/gongchenghuigch/flutterComponent/tree/main/timeline



前言

本文部分代码参考了Flutter 类似物流的 时间轴 ListView 时间轴 - 简书 ,前排感谢。

使用的接口是阿里云的:易源数据-快递物流查询API接口,具体使用和一些细节打算专门再写一个博客。

效果图

 

具体代码

import 'dart:convert';
 
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
 
class DeliverInfoPage extends StatefulWidget{
 
  //从上一个页面传过来的快递单号
  String trackingNum;
 
  DeliverInfoPage(this.trackingNum);
 
  @override
  State<StatefulWidget> createState() {
    return DeliverInfoPageState(trackingNum);
  }
}
 
class DeliverInfoPageState extends State<DeliverInfoPage>{
 
  String trackingNum;
  //get请求获取的数据
  Map jsonMap;
 
  DeliverInfoPageState(this.trackingNum);
 
  @override
  void initState() {
    //NetInterface是自己封装的网络接口类,把项目中用到的接口都放在一起,便于管理
    //对于阿里云接口的具体使用看另一个帖子吧。毕竟不是所有人都用的这个,就不在这里展开了
    NetInterface.getDeliverInfo(trackingNum).then((response) {
//      print("getDeliverInfo=>"+response.toString());
      //jsonMap的具体格式请看阿里云API购买页面,本博最后也会贴出来
      jsonMap = json.decode(response.toString());
      setState(() { });
    }).catchError((response) {
      //ToastUtil也是封装的一个类,具体代码是:
      /*class ToastUtil{
        static void print(String msg){
          Fluttertoast.showToast(
          msg: msg,
          toastLength: Toast.LENGTH_SHORT,
          gravity: ToastGravity.CENTER,
          timeInSecForIosWeb: 1,
          );
        }
      }*/
      ToastUtil.print("出现错误,请重试");
      print("getDeliverInfo Error=>"+response.toString());
    });
  }
 
  @override
  Widget build(BuildContext context) {
    //因为这个项目是安卓和flutter混合开发,所以用了WillPopScope拦截退出事件
    return WillPopScope(
      child: Scaffold(
        appBar: AppBar(
          title: Text("物流追踪"),
          leading: IconButton(
              icon: Icon(Icons.arrow_back),
              onPressed: () {
                Navigator.pop(context);
              }
          ),
        ),
        //未获取到数据就居中显示加载图标
        body: jsonMap != null ?  buildBody(context) : showLoading(),
      ),
      onWillPop: (){
        Navigator.pop(context);
      },
    );
  }
 
  Widget buildBody(BuildContext context){
    return Column(
      children: <Widget>[
        Container(
          padding: EdgeInsets.fromLTRB(10, 0, 0, 0),
          width: double.infinity,
          color: Colors.white,
          height: 70,
          child: Container(
            margin: EdgeInsets.all(5),
            child: Row(
              children: <Widget>[
                Container(
                  height: 60,
                  width: 60,
                  margin: EdgeInsets.fromLTRB(5, 5, 10, 5),
                  child: ClipRRect(
                    borderRadius: BorderRadius.circular(50),
                    child: FadeInImage.assetNetwork(
                      //用了一个加载中的GIF作为默认占位图
                      //注意图片要在pubspec.yaml声明一下,我刚写的时候忘了,就无法加载
                      placeholder: "assets/loading.gif",
                      image: jsonMap["showapi_res_body"]["logo"],
                      fit: BoxFit.fitWidth,
                    ),
                  ),
                ),
                Expanded(
                  child: Column(
                    children: <Widget>[
                      Expanded(
                        flex: 1,
                        child: Container(
                          margin: EdgeInsets.only(left: 10),
                          alignment: Alignment.centerLeft,
                          child: Row(
                            children: <Widget>[
                              Text("物流状态:",style: TextStyle(fontSize: 16)),
                              Text(
                                  "${statusConvert(jsonMap["showapi_res_body"]["status"])}", 
                                  style: TextStyle(fontSize: 16, color: Colors.green)
                              ),
                            ],
                          ),
                        ),
                      ),
                      Expanded(
                        flex: 1,
                        child: Container(
                          margin: EdgeInsets.only(left: 10),
                          alignment: Alignment.centerLeft,
                          child: Text(
                              "运单编号:$trackingNum", 
                              style: TextStyle(
                                  fontSize: 15, 
                                  //颜色稍淡一点
                                  color: Color.fromARGB(95, 0, 0, 0)
                              )
                          ),
                        ),
                      ),
                    ],
                  ),
                )
              ],
            ),
          ),
        ),
        buildListView(context, jsonMap["showapi_res_body"]["data"]),
      ],
    );
  }
 
  Widget buildListView(BuildContext context, List list){
    return Expanded(
      child: Container(
        margin: EdgeInsets.fromLTRB(0, 10, 0, 0),
        color: Colors.white,
        child: ListView.builder(
            //想设置item为固定高度可以设置这个,不过文本过长就显示不全了
//            itemExtent: 100,
            itemCount: list == null ? 0 : list.length,
            itemBuilder: (BuildContext context, int position){
              return buildListViewItem(context, list, position);
            }
        ),
      ),
    );
  }
 
  Widget buildListViewItem(BuildContext context, List list, int position){
    if(list.length != 0){
      return Container(
        color: Colors.white,
        padding: EdgeInsets.only(left: 20, right: 10),
        child: Row(
          children: [
            //这个Container描绘左侧的线和圆点
            Container(
              margin: EdgeInsets.only(left: 10),
              width: 20,
              //需要根据文本内容调整高度,不然文本太长会撑开Container,导致线会断开
              height: getHeight(list[position]["context"]),
              child: Column(
                //中心对齐,
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Expanded(
                      flex: 1,
                      child: Container(
                        //第一个item圆点上方的线就不显示了
                        width: position == 0 ? 0 : 1,
                        color: Colors.grey,
                      )
                  ),
                  //第一个item显示稍大一点的绿色圆点
                  position == 0 ? Stack(
                    //圆心对齐(也就是中心对齐)
                    alignment: Alignment.center,
                    children: <Widget>[
                      //为了实现类似阴影的圆点
                      Container(
                        height: 20,
                        width: 20,
                        decoration: BoxDecoration(
                          color: Colors.green.shade200,
                          borderRadius: BorderRadius.all(Radius.circular(10)),
                        ),
                      ),
                      Container(
                        height: 14,
                        width: 14,
                        decoration: BoxDecoration(
                          color: Colors.green,
                          borderRadius: BorderRadius.all(Radius.circular(7)),
                        ),
                      ),
                    ],
                  ) : Container(
                    height: 10,
                    width: 10,
                    decoration: BoxDecoration(
                      color: Colors.grey.shade300,
                      borderRadius: BorderRadius.all(Radius.circular(5)),
                    ),
                  ),
                  Expanded(
                      flex: 2,
                      child: Container(
                        width: 1,
                        color: Colors.grey,
                      )
                  ),
                ],
              ),
            ),
            Expanded(
              child: Padding(
                padding: EdgeInsets.fromLTRB(20, 0, 20, 0),
                child: Text(
                  list[position]["context"] + "\n" + list[position]["time"],
                  style: TextStyle(
                    fontSize: 15,
                    //第一个item字体颜色为绿色+稍微加粗
                    color: position == 0 ? Colors.green : Colors.black,
                    fontWeight: position == 0 ? FontWeight.w600 : null,
                  ),
                ),
              ),
            ),
          ],
        ),
      );
    }else{
      return Container();
    }
  }
 
  Widget showLoading(){
    return Center(
      child: CupertinoActivityIndicator(
        radius: 20,
      ),
    );
  }
 
  double getHeight(String content){
    //具体多长的文字需要增加高度,看手机分辨率和margin、padding的设置了
    if(content.length >= 95){
      return 150;
    } else if(content.length >= 80 && content.length < 95){
      return 130;
    } else if(content.length >= 40 && content.length < 80){
      return 110;
    } else if(content.length >= 20 && content.length < 40){
      return 90;
    } else {
      return 70;
    }
  }
 
  //把int类型的状态转成字符串,具体对应请看阿里云API购买页面,本博最后的图也会有
  String statusConvert(int status){
    String returnStatus;
    switch(status) {
      case -1: { returnStatus = "待查询"; }
      break;
      case 0: { returnStatus = "查询异常"; }
      break;
      case 1: { returnStatus = "暂无记录"; }
      break;
      case 2: { returnStatus = "在途中"; }
      break;
      case 3: { returnStatus = "派送中"; }
      break;
      case 4: { returnStatus = "已签收"; }
      break;
      case 5: { returnStatus = "用户拒签"; }
      break;
      case 6: { returnStatus = "疑难件"; }
      break;
      case 7: { returnStatus = "无效单"; }
      break;
      case 8: { returnStatus = "超时单"; }
      break;
      case 9: { returnStatus = "签收失败"; }
      break;
      case 10: { returnStatus = "退回"; }
      break;
      default: { returnStatus = "未知状态"; }
      break;
    }
    return returnStatus;
  }
}
 

返回数据的结构

这个实际就是易源数据-快递物流查询API接口的,不是泄露别人隐私哈

注意API接口是这个,别看错了

{
  "showapi_res_error": "",//showapi平台返回的错误信息
  "showapi_res_code": 0,//showapi平台返回码,0为成功,其他为失败
  "showapi_res_id": "5ea941d48d57baae12a0bcd5",
  "showapi_res_body": {
    "update": 1588141785719,//数据最后查询的时间
    "upgrade_info": "", //提示信息,用于提醒用户可能出现的情况
    "updateStr": "2020-04-29 14:29:45",//数据最后更新的时间
    "logo": "http://app2.showapi.com/img/expImg/zto.jpg", //快递公司logo
    "dataSize": 11,  //数据节点的长度
    "status": 4, //-1 待查询 0 查询异常 1 暂无记录 2 在途中 3 派送中 4 已签收 5 用户拒签 6 疑难件 7 无效单 8 超时单 9 签收失败 10 退回
    "fee_num": 1,
    "tel": "95311",//快递公司电话
    "data": [
      {
        "time": "2019-11-16 21:33:56",
        "context": "快件已在 【九江城西港】 签收, 签收人: 速递易, 如有疑问请电联:(15779254414), 投诉电话:(13687028760), 您的快递已经妥投。风里来雨里去, 只为客官您满意。上有老下有小, 赏个好评好不好?【请在评价快递员处帮忙点亮五颗星星哦~】"
      },
      {
        "time": "2019-11-16 07:31:24",
        "context": "【九江城西港】 的程继业(15779254414) 正在第1次派件, 请保持电话畅通,并耐心等待(95720为中通快递员外呼专属号码,请放心接听)"
      },
      {
        "time": "2019-11-16 07:31:23",
        "context": "快件已经到达 【九江城西港】"
      },
      {
        "time": "2019-11-15 19:06:30",
        "context": "快件离开 【九江】 已发往 【九江城西港】"
      },
      {
        "time": "2019-11-15 19:06:18",
        "context": "快件已经到达 【九江】"
      },
      {
        "time": "2019-11-15 10:45:21",
        "context": "快件离开 【南昌中转部】 已发往 【九江】"
      },
      {
        "time": "2019-11-15 08:02:44",
        "context": "快件已经到达 【南昌中转部】"
      },
      {
        "time": "2019-11-13 15:19:48",
        "context": "快件离开 【石家庄】 已发往 【南昌中转部】"
      },
      {
        "time": "2019-11-13 14:22:09",
        "context": "快件已经到达 【石家庄】"
      },
      {
        "time": "2019-11-13 14:08:31",
        "context": "快件离开 【石家庄市场部】 已发往 【石家庄】"
      },
      {
        "time": "2019-11-13 10:27:33",
        "context": "【石家庄市场部】(0311-68026565、0311-68026566) 的 付保文四组(031186891089) 已揽收"
      }
    ],
    "expSpellName": "zhongtong",//快递字母简称
    "msg": "查询成功", //返回提示信息
    "mailNo": "75312165465979",//快递单号
    "queryTimes": 1, //无走件记录时被查询次数     注意:超过8次将会计费,即第9次开始计费
    "ret_code": 0,//接口调用是否成功,0为成功,其他为失败
    "flag": true,//物流信息是否获取成功
    "expTextName": "中通快递", //快递简称
    "possibleExpList": [] //自动识别结果
  }
}

flutter 时间轴、物流页面效果实现

使用flutter实现一个关于物流进度效果

demo下载地址 GitHub - qqcc1388/line_step_demo: 一个基于flutter的 时间线进度 物流进度等类似视图的demo

实现思路也很简单 将每个item拆开分成 leftWidget和rightWiget

leftWidget用来显示竖线和⭕️,可以控制上竖线和下竖线都可以单独隐藏和显示,方便处理第一行和最后一行的竖线显示隐藏问题,进度区域高度跟随rightWidget高度,⭕️位置固定

rightWidget用来控制显示内容区 高度部分 rightWidget内容区 利用column的 mainAxisSize: MainAxisSize.min,来根据内容自适应高度

思路是这个思路,但是在操作的时候发现 左边进度区域和高度无法确定,因为内容区域使用column来自适应高度,如果整个item的高度是不确定的,那么进入区域的高度就不确定,这样就没法实现左边铺满,右边自适应了,那么解决问题的方法只有计算右边内容区的高度,但是计算高度会有一定的误差,这样容易出现左边无法铺满的情况,所有计算文本高度的方案是不可行的

最后发现flutter中提供了IntrinsicHeight这个控件,可以完美解决我的问题

根据内部子控件高度来调整高度,它将其子widget的高度调整其本身实际的高度:

将其子控件调整为该子控件的固有高度,举个例子来说,Row中有3个子控件,其中只有一个有高度,默认情况下剩余2个控件将会充满父组件,而使用IntrinsicHeight控件,则3个子控件的高度一致。

此类非常有用,例如,当可以使用无限制的高度并且您希望孩子尝试以其他方式无限扩展以将其自身调整为更合理的高度时,该类非常有用。

但是此类相对昂贵,因为它在最终布局阶段之前添加了一个推测性布局遍历。 避免在可能的地方使用它。 在最坏的情况下,此小部件可能会导致树的深度的布局为O(N²)。所以不推荐使用。

使用IntrinsicHeight将左边和右边包裹起来,这样一旦右边内容区自适应有,那么左边容器的高度和整个item的高度一致了,这样一旦确定了高度,进入部分就可以铺满整个item了

具体代码 如下:

line_step.dart
 
import 'package:flutter/material.dart';
 
class LineStep extends StatefulWidget {
  final Widget child;
  final isTop;
  final isBottom;
  LineStep({
    key,
    @required this.child,
    this.isTop: false,
    this.isBottom: false,
  }) : super(key: key);
  @override
  _LineStepState createState() => _LineStepState();
}
 
class _LineStepState extends State<LineStep> {
  @override
  Widget build(BuildContext context) {
    return Material(
      child: IntrinsicHeight(
        child: Row(
          children: <Widget>[
            leftWidget(),
            Expanded(
              child: widget.child,
            ),
          ],
        ),
      ),
    );
  }
 
  Widget leftWidget() {
    return Container(
      width: 20,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Container(
            width: 5,
            color: widget.isTop ? Colors.transparent : Colors.blue,
            height: 20,
          ),
          Container(
            width: 16,
            height: 16,
            decoration: BoxDecoration(
                color: Colors.red, borderRadius: BorderRadius.circular(8)),
          ),
          Expanded(
              child: Container(
            width: 5,
            color: widget.isBottom ? Colors.transparent : Colors.blue,
          )),
        ],
      ),
    );
  }
}
 
hom.dart
import 'package:flutter/material.dart';
import 'dart:ui' as ui show window;
 
import 'package:flutter/services.dart';
import 'package:flutter_alpha_appbar/line_step.dart';
 
class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}
 
class _HomePageState extends State<HomePage> {
  ScrollController scrollController = ScrollController();
  double navAlpha = 0;
  @override
  void initState() {
    super.initState();
 
    scrollController.addListener(() {
      var offset = scrollController.offset;
      if (offset < 0) {
        if (navAlpha != 0) {
          setState(() {
            navAlpha = 0;
          });
        }
      } else if (offset < 50) {
        setState(() {
          navAlpha = 1 - (50 - offset) / 50;
        });
      } else if (navAlpha != 1) {
        setState(() {
          navAlpha = 1;
        });
      }
    });
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnnotatedRegion(
        value: navAlpha > 0.5
            ? SystemUiOverlayStyle.dark
            : SystemUiOverlayStyle.light,
        child: Stack(
          children: <Widget>[
            Column(
              children: <Widget>[
                Expanded(
                  child: ListView(
                    physics: const AlwaysScrollableScrollPhysics(),
                    controller: scrollController,
                    padding: EdgeInsets.only(top: 0),
                    children: <Widget>[
                      _headerView(),
                      _contentList(),
                    ],
                  ),
                ),
              ],
            ),
            _buildNavWidget(),
          ],
        ),
      ),
    );
  }
 
  void back() {
    Navigator.pop(context);
  }
 
  Widget _buildNavWidget() {
    return Stack(
      children: <Widget>[
        Opacity(
            opacity: 1 - navAlpha,
            child: Container(
              width: 44,
              height: kToolbarHeight +
                  MediaQueryData.fromWindow(ui.window).padding.top,
              padding: EdgeInsets.fromLTRB(
                  5, MediaQueryData.fromWindow(ui.window).padding.top, 0, 0),
              child: GestureDetector(
                onTap: back,
                child: Container(
                  color: Colors.orange,
                  width: 20,
                  height: 30,
                ),
              ),
            )),
        Opacity(
          opacity: navAlpha,
          child: Container(
            padding: EdgeInsets.fromLTRB(
                5, MediaQueryData.fromWindow(ui.window).padding.top, 0, 0),
            height: kToolbarHeight +
                MediaQueryData.fromWindow(ui.window).padding.top,
            color: Colors.white,
            child: Row(
              children: <Widget>[
                Container(
                  width: 44,
                ),
                Expanded(
                  child: Text(
                    'novel.name',
                    style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
                    textAlign: TextAlign.center,
                  ),
                ),
                Container(
                  width: 44,
                ),
              ],
            ),
          ),
        )
      ],
    );
  }
 
  Widget _headerView() {
    return Container(
      height: 200,
      color: Colors.cyan,
    );
  }
 
  List list = [
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': '可以讲子控件的高度调整至实'
    },
    {
      'title': 'IntrinsicHeight',
      'content': '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,'
    },
    {
      'title': 'IntrinsicHeight',
      'content':
          '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,第一个Container将会撑满整个body的高度,但使用了IntrinsicHeight高度会约束在50。这里Row的高度时需要有子内容的最大高度来决定的,但是第一个Container本身没有高度,有没有子控件,那么他就会去撑满父控件,然后发现父控件Row也是不具有自己的高度的,就撑满了body的高度。IntrinsicHeight就起到了约束Row实际高度的作用'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    },
      {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    }
  ];
 
  Widget _contentList() {
    return Container(
      child: ListView(
        physics: NeverScrollableScrollPhysics(),
        shrinkWrap: true,
        children: list.map((e) {
          final index = list.indexOf(e);
          return _lineItems(e, index);
        }).toList(),
      ),
    );
  }
 
  Widget _lineItems(res, index) {
    return Container(
      decoration: BoxDecoration(
          // color: Colors.cyan,
          border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
      padding: EdgeInsets.only(left: 15),
      margin: EdgeInsets.fromLTRB(0, 0, 0, 0),
      child: LineStep(
        key: Key('step$index'),
        isTop: index == 0,
        isBottom: index == list.length - 1,
        child: rightWidget(res),
      ),
    );
  }
 
  Widget leftWidget() {
    return Container(
      width: 20,
      // height: 200,
      // color: Colors.orange,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Container(
            width: 5,
            color: Colors.blue,
            height: 20,
          ),
          Container(
            width: 16,
            height: 16,
            decoration: BoxDecoration(
                color: Colors.red, borderRadius: BorderRadius.circular(8)),
          ),
          Expanded(
              child: Container(
            width: 5,
            color: Colors.blue,
          )),
        ],
      ),
    );
  }
 
  Widget rightWidget(res) {
    return Container(
      // color: Colors.blue,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          SizedBox(height: 15),
          Text(
            res['title'],
            style: TextStyle(
                color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            res['content'],
            style: TextStyle(color: Colors.orange, fontSize: 15),
          ),
          SizedBox(height: 15),
        ],
      ),
    );
  }
}
 

 

相关文章
|
15天前
|
UED 开发者 容器
Flutter&鸿蒙next 的 Sliver 实现自定义滚动效果
Flutter 提供了强大的滚动组件,如 ListView 和 GridView,但当需要更复杂的滚动效果时,Sliver 组件是一个强大的工具。本文介绍了如何使用 Sliver 实现自定义滚动效果,包括 SliverAppBar、SliverList 等常用组件的使用方法,以及通过 CustomScrollView 组合多个 Sliver 组件实现复杂布局的示例。通过具体代码示例,展示了如何实现带有可伸缩 AppBar 和可滚动列表的页面。
79 1
|
17天前
Flutter 自定义组件继承与调用的高级使用方式
本文深入探讨了 Flutter 中自定义组件的高级使用方式,包括创建基本自定义组件、继承现有组件、使用 Mixins 和组合模式等。通过这些方法,您可以构建灵活、可重用且易于维护的 UI 组件,从而提升开发效率和代码质量。
114 1
|
17天前
|
前端开发 开发者
深入探索 Flutter 鸿蒙版的画笔使用与高级自定义动画
本文深入探讨了 Flutter 中的绘图功能,重点介绍了 CustomPainter 和 Canvas 的使用方法。通过示例代码,详细讲解了如何绘制自定义图形、设置 Paint 对象的属性以及实现高级自定义动画。内容涵盖基本绘图、动画基础、渐变动画和路径动画,帮助读者掌握 Flutter 绘图与动画的核心技巧。
65 1
|
17天前
|
Dart UED 开发者
Flutter&鸿蒙next中的按钮封装:自定义样式与交互
在Flutter应用开发中,按钮是用户界面的重要组成部分。Flutter提供了多种内置按钮组件,但有时这些样式无法满足特定设计需求。因此,封装一个自定义按钮组件变得尤为重要。自定义按钮组件可以确保应用中所有按钮的一致性、可维护性和可扩展性,同时提供更高的灵活性,支持自定义颜色、形状和点击事件。本文介绍了如何创建一个名为CustomButton的自定义按钮组件,并详细说明了其样式、形状、颜色和点击事件的处理方法。
67 1
|
17天前
|
Dart 搜索推荐 API
Flutter & 鸿蒙next版本:自定义对话框与表单验证的动态反馈与错误处理
在现代移动应用开发中,用户体验至关重要。本文探讨了如何在 Flutter 与鸿蒙操作系统(HarmonyOS)中创建自定义对话框,并结合表单验证实现动态反馈与错误处理,提升用户体验。通过自定义对话框和表单验证,开发者可以提供更加丰富和友好的交互体验,同时利用鸿蒙next版本拓展应用的受众范围。
68 1
|
2月前
|
前端开发 搜索推荐
Flutter中自定义气泡框效果的实现
Flutter中自定义气泡框效果的实现
83 3
|
3月前
|
前端开发
Flutter快速实现自定义折线图,支持数据改变过渡动画
Flutter快速实现自定义折线图,支持数据改变过渡动画
93 4
Flutter快速实现自定义折线图,支持数据改变过渡动画
|
3月前
|
开发者 监控 开发工具
如何将JSF应用送上云端?揭秘在Google Cloud Platform上部署JSF应用的神秘步骤
【8月更文挑战第31天】本文详细介绍如何在Google Cloud Platform (GCP) 上部署JavaServer Faces (JSF) 应用。首先,确保已准备好JSF应用并通过Maven构建WAR包。接着,使用Google Cloud SDK登录并配置GCP环境。然后,创建`app.yaml`文件以配置Google App Engine,并使用`gcloud app deploy`命令完成部署。最后,通过`gcloud app browse`访问应用,并利用GCP的监控和日志服务进行管理和故障排查。整个过程简单高效,帮助开发者轻松部署和管理JSF应用。
60 0
|
3月前
|
开发者 容器 Java
Azure云之旅:JSF应用的神秘部署指南,揭开云原生的新篇章!
【8月更文挑战第31天】本文探讨了如何在Azure上部署JavaServer Faces (JSF) 应用,充分发挥其界面构建能力和云平台优势,实现高效安全的Web应用。Azure提供的多种服务如App Service、Kubernetes Service (AKS) 和DevOps简化了部署流程,并支持应用全生命周期管理。文章详细介绍了使用Azure Spring Cloud和App Service部署JSF应用的具体步骤,帮助开发者更好地利用Azure的强大功能。无论是在微服务架构下还是传统环境中,Azure都能为JSF应用提供全面支持,助力开发者拓展技术视野与实践机会。
18 0
|
3月前
|
开发框架 API 开发者
Flutter表单控件深度解析:从基本构建到高级自定义,全方位打造既美观又实用的移动端数据输入体验,让应用交互更上一层楼
【8月更文挑战第31天】在构建美观且功能强大的移动应用时,表单是不可或缺的部分。Flutter 作为热门的跨平台开发框架,提供了丰富的表单控件和 API,使开发者能轻松创建高质量表单。本文通过问题解答形式,深入解读 Flutter 表单控件,并通过具体示例代码展示如何构建优秀的移动应用表单。涵盖创建基本表单、处理表单提交、自定义控件样式、焦点管理和异步验证等内容,适合各水平开发者学习和参考。
81 0