美文网首页一起来学Flutter~Flutter圈子Flutter学习
Flutter 70: 图解自定义 ACEStepper 步进器

Flutter 70: 图解自定义 ACEStepper 步进器

作者: 阿策神奇 | 来源:发表于2019-12-08 13:05 被阅读0次

          小菜前几天尝试了 Flutter Stepper 简单实用,但样式等方面也有局限性,Stepper 的使用小菜在上一篇中有过尝试 图解基本 Stepper 步进器,现在小菜尝试在此基础上增加一些新特性;

    1. Step 之间的连线支持 直线和圆点虚线,且颜色尺寸均可自定义;
    2. Step Header Icon 中支持 自定义文字/icon/本地图片/网络图片,且尺寸颜色均可分别自定义;
    3. 横向 Stepper 支持滑动,不限制整体宽度;
    4. Step 中按钮支持单个显隐性处理;
    5. Stepper 中每个 Step 内容支持全部展示和单独展示;
    6. 其他自定义 ThemeData

          小菜准备在 Stepper 基础上进行扩展,首先要了解 Stepper 的构成,根据一切都是 Widget 的思想,小菜绘制了一个基本的构成图:

    新特性扩展

    1. 圆点虚线

          Step 之间的连线只有直线有些单调,针对不同实际场景,小菜尝试圆点虚线;

    1. 定义连线类型,nomal 为直线,circle 为圆点虚线;
    enum LineType { normal, circle }
    
    1. 绘制圆点虚线,小菜准备支持自定义连线宽度(直线/虚线),因此圆点半径根据宽度获得,圆点之间的距离小菜尝试的是一个圆点大小,在一段长度中绘制 _circleLength / radius / 4 - 1 个圆点即可,小菜之所以 -1 是因为在连线交接处,首尾之间的圆点过近(可自由设置);
    class _LinePainter extends CustomPainter {
      final Color color;
      final double radius;
      final ACEStepperType type;
    
      _LinePainter({this.color, this.radius, this.type});
    
      @override
      bool hitTest(Offset point) => true;
    
      @override
      bool shouldRepaint(_LinePainter oldPainter) => oldPainter.color != color;
    
      @override
      void paint(Canvas canvas, Size size) {
        double _circleLength = (type == ACEStepperType.horizontal) ? size.width.toDouble() : size.height.toDouble();
        double _circleSize = _circleLength / radius / 4 > 2 ? _circleLength / radius / 4 - 1 : _circleLength / radius / 4;
        Path _path = Path();
        for (int i = 0; i < _circleSize; i++) {
          _path.addArc(Rect.fromCircle(center: Offset(
                      type == ACEStepperType.horizontal ? radius + 4 * radius * i : radius,
                      type == ACEStepperType.horizontal ? radius : radius + 4 * radius * i),
                  radius: radius), 0.0, 2 * pi);
        }
        canvas.drawPath(_path, Paint()..color = color..strokeCap = StrokeCap.round..style = PaintingStyle.fill);
      }
    }
    
    1. 场景绘制直线或圆角虚线;
    class StepperLine extends StatelessWidget {
      final Color color;
      final LineType lineType;
      final ACEStepperType type;
    
      StepperLine({@required this.color, this.type = ACEStepperType.horizontal,  this.lineType = LineType.normal});
    
      @override
      Widget build(BuildContext context) {
        double _width = (type == ACEStepperType.horizontal) ? _kLineHeight : _kLineWidth;
        double _height = (type == ACEStepperType.horizontal) ? _kLineWidth : _kLineHeight;
        double _diameter = (type == ACEStepperType.horizontal) ? _height : _width;
        return lineType == LineType.normal
            ? Container(width: _width, height: _height, color: color)
            : Container(width: _width, height: _height, child: CustomPaint(painter: _LinePainter(color: color, radius: _diameter * 0.5, type: type)));
      }
    }
    

    2. Header Icon 内容自定义

          Step Header Icon 有四种属性,但展示内容除了数组下标递增其余 Icon 不可变,小菜增加了自定义文本/Icon/本地图片/网络图片的展示,并非单一的数组下标;

    1. 定义 Header 类型;text 为展示文本内容,iconIconDataass_url 为本地图片路径,net_url 为网络图片,均不设置默认为递增的数组下标;
    enum IconType { text, icon, ass_url, net_url }
    
    1. 绘制圆环;
    class _CirclePainter extends CustomPainter {
      final Color color;
      final double size;
    
      _CirclePainter({this.color, this.size});
    
      @override
      bool hitTest(Offset point) => true;
    
      @override
      bool shouldRepaint(_CirclePainter oldPainter) => oldPainter.color != color;
    
      @override
      void paint(Canvas canvas, Size size) {
        final double radius = this.size * 0.5;
        canvas.drawArc(Rect.fromCircle(center: Offset(radius, radius), radius: radius),
            0.0, 2 * pi, false, Paint()..color = color..strokeCap = StrokeCap.round..strokeWidth = 1.0..style = PaintingStyle.stroke);
      }
    }
    
    1. 绘制 Header 内容;
    Widget _buildIcon(IconType type, CircleData circleData, int index) {
      Color contentActiveColor = widget.themeData == null ? _kContentActiveColor : widget.themeData.contentActiveColor ?? _kContentActiveColor;
      Color contentColor = widget.themeData == null ? _kContentColor : widget.themeData.contentColor ?? _kContentColor;
      Color _color = widget.steps[index].isActive ? contentActiveColor : contentColor;
      switch (type) {
        case IconType.text:
          return Text(circleData.circleText ?? (index + 1).toString(), style: TextStyle(color: _color));
          break;
        case IconType.icon:
          return circleData.circleIcon != null ? Icon(circleData.circleIcon, size: _kCircleIconSize, color: _color) : Text(circleData.circleText ?? (index + 1).toString(), style: TextStyle(color: _color));
          break;
        case IconType.ass_url:
          return circleData.circleAssUrl != null ? Padding(padding: EdgeInsets.all(_kCirclePadding), child: Image.asset(circleData.circleAssUrl, color: _color))
              : Text(circleData.circleText ?? (index + 1).toString(), style: TextStyle(color: _color));
          break;
        case IconType.net_url:
          return circleData.circleNetUrl != null ? Padding(padding: EdgeInsets.all(_kCirclePadding), child: Image.network(circleData.circleNetUrl))
              : Text(circleData.circleText ?? (index + 1).toString(), style: TextStyle(color: _color));
          break;
        default:
          return Text((index + 1).toString(), style: TextStyle(color: _color));
          break;
      }
    }
    
    1. 将绘制 Icon 放置在圆环内;
    Widget _buildCircle(IconType type, double size, CircleData circleData, int index) {
      Color circleActiveColor = widget.themeData == null ? _kCircleActiveColor : widget.themeData.circleActiveColor ?? _kCircleActiveColor;
      Color circleColor = widget.themeData == null ? _kCircleColor : widget.themeData.circleColor ?? _kCircleColor;
      return Stack(children: <Widget>[
        Container(child: CustomPaint(painter: _CirclePainter(color: widget.steps[index].isActive ? circleActiveColor : circleColor, size: size))),
        Container(width: size, height: size, child: Center(child: _buildIcon(type, circleData, index)))
      ]);
    }
    

    3. 横向滑动

          分析源码,Stepper 横向方式是将 Step 放置在 Row 中,此时若 Step 数量过多会造成宽度溢出;小菜调整存储方式,将自定义的 ACEStepper 放置在横向 ListView 中,不会限制宽度,放置多个 ACEStep 可横向滑动;

    Widget _buildHorizontal() {
      return Column(children: <Widget>[
        Container(height: widget.headerHeight <= 0.0 ? _kHeaderHeight : widget.headerHeight,
            child: ListView(primary: false, shrinkWrap: true, scrollDirection: Axis.horizontal,
                children: <Widget>[
                  for (int i = 0; i < widget.steps.length; i += 1)
                    Column(key: _keys[i], children: <Widget>[
                      InkWell(child: _buildHorizontalHeader(i), onTap: () => (widget.onStepTapped != null) ? widget.onStepTapped(i) : null)
                    ])
                ])),
        Expanded(child: ListView(children: <Widget>[
          Container(child: widget.steps[widget.currentStep].content ?? SizedBox.shrink()),
          _buildVerticalControls()
        ]))
      ]);
    }
    

    4. 单个按钮显隐性

          纵向 StepperControls 按钮是默认展示的,小菜为了适应更多场景,允许按钮单独展示;

    Widget _buildVerticalControls() {
      return (widget.controlsBuilder != null) ? widget.controlsBuilder(context, onStepContinue: widget.onStepContinue, onStepCancel: widget.onStepCancel)
          : Container(child: Row(children: <Widget>[
              widget.isContinue ? FlatButton( onPressed: widget.onStepContinue, child: Text('继续')) : SizedBox.shrink(),
              widget.isCancel ? FlatButton(onPressed: widget.onStepCancel, child: Text('取消')) : SizedBox.shrink()
            ]));
    }
    

    5. Content 内容展示

          Stepper 中选中单个 Step 时会展示 Content 内容,但小菜尝试做一个物流信息时间轴,Content 内容都要展示,因此添加一个状态,允许用户是否全部展示 Content

    Widget _buildVerticalBody(int index) {
      double circleDiameter = widget.themeData == null ? _kCircleDiameter : widget.themeData.circleDiameter ?? _kCircleDiameter;
      return Stack(children: <Widget>[
        PositionedDirectional(
            start: _kTopTipsWidth + (circleDiameter - _kLineWidth) * 0.5, top: Size.zero.width, bottom: Size.zero.width - 2,
            child: _isLast(index) ? SizedBox.shrink() : AspectRatio(aspectRatio: 1, child: SizedBox.expand(child: _buildLine(index, false)))),
        widget.isAllContent ? Container(
                margin: EdgeInsets.only(left: _kTopTipsWidth + _kCircleMargin * 2 + circleDiameter),
                child: Column(crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[widget.steps[index].content ?? SizedBox.shrink(),  _buildVerticalControls()  ]))
            : AnimatedCrossFade(firstChild: SizedBox.shrink(),
                secondChild: Container(margin: EdgeInsetsDirectional.only(start: _kTopTipsWidth + _kCircleMargin * 2 + circleDiameter),
                    child: Column(children: <Widget>[widget.steps[index].content ?? SizedBox.shrink(), _buildVerticalControls() ])),
                crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
                duration: Duration(milliseconds: 1))
      ]);
    }
    

    6. 自定义 ThemeData

          为了扩展 Stepper 展示效果的灵活性,小菜添加了 ThemeData 主题灵活展示各位置颜色等;

    class ACEStepThemeData {
      final Color circleColor,      // 圆环默认颜色
          circleActiveColor,        // 圆环选中颜色
          contentColor,             // 圆环内容默认颜色
          contentActiveColor,       // 圆环内容选中颜色
          lineColor;                // 连线颜色
      final double circleDiameter;  // 圆环直径
    
      ACEStepThemeData(
          {this.circleColor = _kCircleColor,
          this.lineColor = _kLineColor,
          this.circleActiveColor = _kCircleActiveColor,
          this.contentColor = _kContentColor,
          this.contentActiveColor = _kContentActiveColor,
          this.circleDiameter = _kCircleDiameter});
    }
    

    源码介绍

    const ACEStepper(
      {Key key,
      @required this.steps,                 // ACEStep 数组
      this.physics,                         // 滑动动画
      this.type = ACEStepperType.vertical,  // 方向:横向/纵向
      this.currentStep = 0,                 // 当前 ACEStep
      this.onStepTapped,                    // ACEStep 点击回调
      this.onStepContinue,                  // ACEStep 继续按钮回调
      this.onStepCancel,                    // ACEStep 取消按钮回调
      this.isContinue = true,               // 继续按钮显隐性
      this.isCancel = true,                 // 取消按钮显隐性
      this.headerHeight,                    // 横向 Header 高度
      this.controlsBuilder,                 // 自定义控件
      this.themeData,                       // 主题样式
      this.isAllContent = false});          // 内容是否全部展示
    
    const ACEStep(
        {@required this.title,              // 标题 Widget
        @required this.circleData,          // 标题图标内容
        this.content,                       // 内容 Widget
        this.subtitle,                      // 副标题 Widget
        this.toptips,                       // 顶部提示 Widget
        this.lineType = LineType.normal,    // 连线方式
        this.iconType = IconType.text,      // 标题图标方式
        this.isActive = false});            // 是否高亮
    

          分析源码,小菜自定义的 ACEStepperStepper 用法类似,只是增加了扩展项,具体的使用请到 GitHub

    注意事项

    1. Header 连接方式

          Step Header Icon 的连接是由两条固定长度的连线与圆环的拼接,连线处在第一个和最后一个时隐藏展示;因此造成一个问题,当 Title / subTitle 内容设置过大时,会造成 HeaderContent 连线不衔接;小菜暂未找到合适的处理方式,希望有解决方案的朋友多多指导!

    2. Content 连接方式

          在纵向 StepperContent 的展示对应的连线是单独的连线,与上下两个 Header 进行衔接;但 Content 大小并不固定,而小菜绘制的圆点虚线需要获取其高度进行绘制;小菜分析源码通过 State / AspectRatio 进行处理,AspectRatio 的研究会在后续博客中学习研究;

    Widget _buildVerticalBody(int index) {
      return Stack(children: <Widget>[
        PositionedDirectional(
            start: _kTopTipsWidth + (circleDiameter - _kLineWidth) * 0.5, top: Size.zero.width, bottom: Size.zero.width - 2,
            child: _isLast(index) ? SizedBox.shrink() : AspectRatio(aspectRatio: 1, child: SizedBox.expand(child: _buildLine(index, false)))),
            Container(margin: EdgeInsets.only(left: _kTopTipsWidth + _kCircleMargin * 2 + circleDiameter),
                child: Column(crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[widget.steps[index].content ?? SizedBox.shrink(),  _buildVerticalControls()]))
      ]);
    }
    

    3. 横向 Header 高度

          小菜在处理横向 ACEStepper Header 时用 ListView 存放 ACEStepper,解决了横向溢出的问题;但将 HeaderContent 放在 Column 中是会涉及到 ListView 高度错误的问题,小菜采用 Expend 方式也未很好处理,目前设置了基本的高度;有更好方案的朋友请多指导!


          小菜对 ACEStepper 的自定义还不够成熟,还有很多需要优化的地方,有建议的地方请多多指导!

    来源: 阿策小和尚

    相关文章

      网友评论

        本文标题:Flutter 70: 图解自定义 ACEStepper 步进器

        本文链接:https://www.haomeiwen.com/subject/sikmgctx.html