Flutter 70: 圖解自定義 ACEStepper 步進(jìn)器

??????小菜前幾天嘗試了 Flutter Stepper 簡單實(shí)用,但樣式等方面也有局限性,Stepper 的使用小菜在上一篇中有過嘗試 圖解基本 Stepper 步進(jìn)器,現(xiàn)在小菜嘗試在此基礎(chǔ)上增加一些新特性;

  1. Step 之間的連線支持 直線和圓點(diǎn)虛線,且顏色尺寸均可自定義;
  2. Step Header Icon 中支持 自定義文字/icon/本地圖片/網(wǎng)絡(luò)圖片,且尺寸顏色均可分別自定義;
  3. 橫向 Stepper 支持滑動(dòng),不限制整體寬度;
  4. Step 中按鈕支持單個(gè)顯隱性處理;
  5. Stepper 中每個(gè) Step 內(nèi)容支持全部展示和單獨(dú)展示;
  6. 其他自定義 ThemeData

??????小菜準(zhǔn)備在 Stepper 基礎(chǔ)上進(jìn)行擴(kuò)展,首先要了解 Stepper 的構(gòu)成,根據(jù)一切都是 Widget 的思想,小菜繪制了一個(gè)基本的構(gòu)成圖:

新特性擴(kuò)展

1. 圓點(diǎn)虛線

??????Step 之間的連線只有直線有些單調(diào),針對不同實(shí)際場景,小菜嘗試圓點(diǎn)虛線;

  1. 定義連線類型,nomal 為直線,circle 為圓點(diǎn)虛線;
enum LineType { normal, circle }
  1. 繪制圓點(diǎn)虛線,小菜準(zhǔn)備支持自定義連線寬度(直線/虛線),因此圓點(diǎn)半徑根據(jù)寬度獲得,圓點(diǎn)之間的距離小菜嘗試的是一個(gè)圓點(diǎn)大小,在一段長度中繪制 _circleLength / radius / 4 - 1 個(gè)圓點(diǎn)即可,小菜之所以 -1 是因?yàn)樵谶B線交接處,首尾之間的圓點(diǎn)過近(可自由設(shè)置);
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 內(nèi)容自定義

??????Step Header Icon 有四種屬性,但展示內(nèi)容除了數(shù)組下標(biāo)遞增其余 Icon 不可變,小菜增加了自定義文本/Icon/本地圖片/網(wǎng)絡(luò)圖片的展示,并非單一的數(shù)組下標(biāo);

  1. 定義 Header 類型;text 為展示文本內(nèi)容,iconIconDataass_url 為本地圖片路徑,net_url 為網(wǎng)絡(luò)圖片,均不設(shè)置默認(rèn)為遞增的數(shù)組下標(biāo);
enum IconType { text, icon, ass_url, net_url }
  1. 繪制圓環(huán);
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 內(nèi)容;
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 放置在圓環(huán)內(nèi);
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. 橫向滑動(dòng)

??????分析源碼,Stepper 橫向方式是將 Step 放置在 Row 中,此時(shí)若 Step 數(shù)量過多會造成寬度溢出;小菜調(diào)整存儲方式,將自定義的 ACEStepper 放置在橫向 ListView 中,不會限制寬度,放置多個(gè) ACEStep 可橫向滑動(dòng);

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. 單個(gè)按鈕顯隱性

??????縱向 StepperControls 按鈕是默認(rèn)展示的,小菜為了適應(yīng)更多場景,允許按鈕單獨(dú)展示;

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('繼續(xù)')) : SizedBox.shrink(),
          widget.isCancel ? FlatButton(onPressed: widget.onStepCancel, child: Text('取消')) : SizedBox.shrink()
        ]));
}

5. Content 內(nèi)容展示

??????Stepper 中選中單個(gè) Step 時(shí)會展示 Content 內(nèi)容,但小菜嘗試做一個(gè)物流信息時(shí)間軸,Content 內(nèi)容都要展示,因此添加一個(gè)狀態(tài),允許用戶是否全部展示 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

??????為了擴(kuò)展 Stepper 展示效果的靈活性,小菜添加了 ThemeData 主題靈活展示各位置顏色等;

class ACEStepThemeData {
  final Color circleColor,      // 圓環(huán)默認(rèn)顏色
      circleActiveColor,        // 圓環(huán)選中顏色
      contentColor,             // 圓環(huán)內(nèi)容默認(rèn)顏色
      contentActiveColor,       // 圓環(huán)內(nèi)容選中顏色
      lineColor;                // 連線顏色
  final double circleDiameter;  // 圓環(huán)直徑

  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 數(shù)組
  this.physics,                         // 滑動(dòng)動(dòng)畫
  this.type = ACEStepperType.vertical,  // 方向:橫向/縱向
  this.currentStep = 0,                 // 當(dāng)前 ACEStep
  this.onStepTapped,                    // ACEStep 點(diǎn)擊回調(diào)
  this.onStepContinue,                  // ACEStep 繼續(xù)按鈕回調(diào)
  this.onStepCancel,                    // ACEStep 取消按鈕回調(diào)
  this.isContinue = true,               // 繼續(xù)按鈕顯隱性
  this.isCancel = true,                 // 取消按鈕顯隱性
  this.headerHeight,                    // 橫向 Header 高度
  this.controlsBuilder,                 // 自定義控件
  this.themeData,                       // 主題樣式
  this.isAllContent = false});          // 內(nèi)容是否全部展示

const ACEStep(
    {@required this.title,              // 標(biāo)題 Widget
    @required this.circleData,          // 標(biāo)題圖標(biāo)內(nèi)容
    this.content,                       // 內(nèi)容 Widget
    this.subtitle,                      // 副標(biāo)題 Widget
    this.toptips,                       // 頂部提示 Widget
    this.lineType = LineType.normal,    // 連線方式
    this.iconType = IconType.text,      // 標(biāo)題圖標(biāo)方式
    this.isActive = false});            // 是否高亮

??????分析源碼,小菜自定義的 ACEStepperStepper 用法類似,只是增加了擴(kuò)展項(xiàng),具體的使用請到 GitHub

注意事項(xiàng)

1. Header 連接方式

??????Step Header Icon 的連接是由兩條固定長度的連線與圓環(huán)的拼接,連線處在第一個(gè)和最后一個(gè)時(shí)隱藏展示;因此造成一個(gè)問題,當(dāng) Title / subTitle 內(nèi)容設(shè)置過大時(shí),會造成 HeaderContent 連線不銜接;小菜暫未找到合適的處理方式,希望有解決方案的朋友多多指導(dǎo)!

2. Content 連接方式

??????在縱向 StepperContent 的展示對應(yīng)的連線是單獨(dú)的連線,與上下兩個(gè) Header 進(jìn)行銜接;但 Content 大小并不固定,而小菜繪制的圓點(diǎn)虛線需要獲取其高度進(jìn)行繪制;小菜分析源碼通過 State / AspectRatio 進(jìn)行處理,AspectRatio 的研究會在后續(xù)博客中學(xué)習(xí)研究;

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 時(shí)用 ListView 存放 ACEStepper,解決了橫向溢出的問題;但將 HeaderContent 放在 Column 中是會涉及到 ListView 高度錯(cuò)誤的問題,小菜采用 Expend 方式也未很好處理,目前設(shè)置了基本的高度;有更好方案的朋友請多指導(dǎo)!


??????小菜對 ACEStepper 的自定義還不夠成熟,還有很多需要優(yōu)化的地方,有建議的地方請多多指導(dǎo)!

來源: 阿策小和尚

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 我想要的大學(xué)生活,不是她們的“不在圖書館,就在去圖書館的路上” 我想要的大學(xué)生活很簡單,就是每天把單詞記完,作業(yè)寫...
    阿欒_serene閱讀 323評論 0 0
  • 演講無處不在,我與Ta的私生活就從那時(shí)候開始了…… 表達(dá)自我并影響他人的最好方式,一個(gè)是語言交流,那就是演講;一個(gè)...
    李羅伯閱讀 483評論 1 2
  • 當(dāng)我慢慢將你忘卻 你也漸漸將我模糊 情已走遠(yuǎn) 話已隨風(fēng) 不再有牽掛 不再糾纏絲毫 各不相欠 淡淡的回憶里 只有淺淺...
    君酒濃閱讀 270評論 0 1
  • 我以為,到了談戀愛的時(shí)間后,你會屬于我,后來才知道,當(dāng)初的我,是有多傻! 帶著一顆,終于可以和...
    葉寒清閱讀 319評論 1 2