天天看點

Flutter 70: 圖解自定義 ACEStepper 步進器

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

  1. Step 之間的連線支援 直線和圓點虛線,且顔色尺寸均可自定義;
  2. Step Header Icon 中支援 自定義文字/icon/本地圖檔/網絡圖檔,且尺寸顔色均可分别自定義;
  3. 橫向 Stepper 支援滑動,不限制整體寬度;
  4. Step 中按鈕支援單個顯隐性處理;
  5. Stepper 中每個 Step 内容支援全部展示和單獨展示;
  6. 其他自定義 ThemeData;
Flutter 70: 圖解自定義 ACEStepper 步進器

      小菜準備在 Stepper 基礎上進行擴充,首先要了解 Stepper 的構成,根據一切都是 Widget 的思想,小菜繪制了一個基本的構成圖:

Flutter 70: 圖解自定義 ACEStepper 步進器

新特性擴充

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)));
  }
}           
Flutter 70: 圖解自定義 ACEStepper 步進器

2. Header Icon 内容自定義

      Step Header Icon 有四種屬性,但展示内容除了數組下标遞增其餘 Icon 不可變,小菜增加了自定義文本/Icon/本地圖檔/網絡圖檔的展示,并非單一的數組下标;

  1. 定義 Header 類型;text 為展示文本内容,icon 為 IconData,ass_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)))
  ]);
}           
Flutter 70: 圖解自定義 ACEStepper 步進器

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()
    ]))
  ]);
}           
Flutter 70: 圖解自定義 ACEStepper 步進器

4. 單個按鈕顯隐性

      縱向 Stepper 中 Controls 按鈕是預設展示的,小菜為了适應更多場景,允許按鈕單獨展示;

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()
        ]));
}           
Flutter 70: 圖解自定義 ACEStepper 步進器

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))
  ]);
}           
Flutter 70: 圖解自定義 ACEStepper 步進器

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});            // 是否高亮           

      分析源碼,小菜自定義的 ACEStepper 與 Stepper 用法類似,隻是增加了擴充項,具體的使用請到

GitHub

Flutter 70: 圖解自定義 ACEStepper 步進器

注意事項

1. Header 連接配接方式

      Step Header Icon 的連接配接是由兩條固定長度的連線與圓環的拼接,連線處在第一個和最後一個時隐藏展示;是以造成一個問題,當 Title / subTitle 内容設定過大時,會造成 Header 與 Content 連線不銜接;小菜暫未找到合适的處理方式,希望有解決方案的朋友多多指導!

2. Content 連接配接方式

      在縱向 Stepper 中 Content 的展示對應的連線是單獨的連線,與上下兩個 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,解決了橫向溢出的問題;但将 Header 與 Content 放在 Column 中是會涉及到 ListView 高度錯誤的問題,小菜采用 Expend 方式也未很好處理,目前設定了基本的高度;有更好方案的朋友請多指導!

      小菜對 ACEStepper 的自定義還不夠成熟,還有很多需要優化的地方,有建議的地方請多多指導!

來源: 阿策小和尚