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

简介: 0 基础学习 Flutter,第七十步:自定义 ACEStepper 尝试一下!

      小菜前几天尝试了 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 }
    
  2. 绘制圆点虚线,小菜准备支持自定义连线宽度(直线/虚线),因此圆点半径根据宽度获得,圆点之间的距离小菜尝试的是一个圆点大小,在一段长度中绘制 _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);
    }
    }
    
  3. 场景绘制直线或圆角虚线;

    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 }
    
  2. 绘制圆环;

    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);
    }
    }
    
  3. 绘制 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;
    }
    }
    
  4. 将绘制 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 的自定义还不够成熟,还有很多需要优化的地方,有建议的地方请多多指导!

来源: 阿策小和尚

目录
相关文章
|
1月前
|
UED 开发者 容器
Flutter&鸿蒙next 的 Sliver 实现自定义滚动效果
Flutter 提供了强大的滚动组件,如 ListView 和 GridView,但当需要更复杂的滚动效果时,Sliver 组件是一个强大的工具。本文介绍了如何使用 Sliver 实现自定义滚动效果,包括 SliverAppBar、SliverList 等常用组件的使用方法,以及通过 CustomScrollView 组合多个 Sliver 组件实现复杂布局的示例。通过具体代码示例,展示了如何实现带有可伸缩 AppBar 和可滚动列表的页面。
110 1
|
1月前
Flutter 自定义组件继承与调用的高级使用方式
本文深入探讨了 Flutter 中自定义组件的高级使用方式,包括创建基本自定义组件、继承现有组件、使用 Mixins 和组合模式等。通过这些方法,您可以构建灵活、可重用且易于维护的 UI 组件,从而提升开发效率和代码质量。
131 1
|
1月前
|
前端开发 开发者
深入探索 Flutter 鸿蒙版的画笔使用与高级自定义动画
本文深入探讨了 Flutter 中的绘图功能,重点介绍了 CustomPainter 和 Canvas 的使用方法。通过示例代码,详细讲解了如何绘制自定义图形、设置 Paint 对象的属性以及实现高级自定义动画。内容涵盖基本绘图、动画基础、渐变动画和路径动画,帮助读者掌握 Flutter 绘图与动画的核心技巧。
80 1
|
1月前
|
Dart UED 开发者
Flutter&鸿蒙next中的按钮封装:自定义样式与交互
在Flutter应用开发中,按钮是用户界面的重要组成部分。Flutter提供了多种内置按钮组件,但有时这些样式无法满足特定设计需求。因此,封装一个自定义按钮组件变得尤为重要。自定义按钮组件可以确保应用中所有按钮的一致性、可维护性和可扩展性,同时提供更高的灵活性,支持自定义颜色、形状和点击事件。本文介绍了如何创建一个名为CustomButton的自定义按钮组件,并详细说明了其样式、形状、颜色和点击事件的处理方法。
86 1
|
1月前
|
Dart 搜索推荐 API
Flutter & 鸿蒙next版本:自定义对话框与表单验证的动态反馈与错误处理
在现代移动应用开发中,用户体验至关重要。本文探讨了如何在 Flutter 与鸿蒙操作系统(HarmonyOS)中创建自定义对话框,并结合表单验证实现动态反馈与错误处理,提升用户体验。通过自定义对话框和表单验证,开发者可以提供更加丰富和友好的交互体验,同时利用鸿蒙next版本拓展应用的受众范围。
84 1
|
3月前
|
前端开发 搜索推荐
Flutter中自定义气泡框效果的实现
Flutter中自定义气泡框效果的实现
114 3
|
4月前
|
前端开发
Flutter快速实现自定义折线图,支持数据改变过渡动画
Flutter快速实现自定义折线图,支持数据改变过渡动画
109 4
Flutter快速实现自定义折线图,支持数据改变过渡动画
|
4月前
|
开发者 监控 开发工具
如何将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应用。
64 0
|
4月前
|
开发者 容器 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应用提供全面支持,助力开发者拓展技术视野与实践机会。
21 0
|
4月前
|
开发框架 API 开发者
Flutter表单控件深度解析:从基本构建到高级自定义,全方位打造既美观又实用的移动端数据输入体验,让应用交互更上一层楼
【8月更文挑战第31天】在构建美观且功能强大的移动应用时,表单是不可或缺的部分。Flutter 作为热门的跨平台开发框架,提供了丰富的表单控件和 API,使开发者能轻松创建高质量表单。本文通过问题解答形式,深入解读 Flutter 表单控件,并通过具体示例代码展示如何构建优秀的移动应用表单。涵盖创建基本表单、处理表单提交、自定义控件样式、焦点管理和异步验证等内容,适合各水平开发者学习和参考。
111 0