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实现类似物流的时间轴(详细) 

前言
本文部分代码参考了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),
        ],
      ),
    );
  }
}

 

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值