简单实现一下Flutter的Stepper做一个侧边进度条

3,128 阅读6分钟

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

flutter Stepper 的样式

原生 Stepper

我实现的 Stepper

我实现的 Stepper

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

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

item

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

image.png

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

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

image.png

这样的话,就好理解了,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,
      ),
    ))
  ],
)

编译运行后截图

image.png

(这里截图跟之前不一样是因为我又单独建立了一个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 的容器边界在上面的线中间。看起来像这样。

image.png

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

最后看起来像这样:

多复制几个

image

最后一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;
  }
}