小菜前幾天嘗試了 Flutter Stepper 簡單實用,但樣式等方面也有局限性,Stepper 的使用小菜在上一篇中有過嘗試 圖解基本 Stepper 步進器 ,現在小菜嘗試在此基礎上增加一些新特性;
- Step 之間的連線支援 直線和圓點虛線,且顔色尺寸均可自定義;
- Step Header Icon 中支援 自定義文字/icon/本地圖檔/網絡圖檔,且尺寸顔色均可分别自定義;
- 橫向 Stepper 支援滑動,不限制整體寬度;
- Step 中按鈕支援單個顯隐性處理;
- Stepper 中每個 Step 内容支援全部展示和單獨展示;
- 其他自定義 ThemeData;

小菜準備在 Stepper 基礎上進行擴充,首先要了解 Stepper 的構成,根據一切都是 Widget 的思想,小菜繪制了一個基本的構成圖:
新特性擴充
1. 圓點虛線
Step 之間的連線隻有直線有些單調,針對不同實際場景,小菜嘗試圓點虛線;
- 定義連線類型,nomal 為直線,circle 為圓點虛線;
enum LineType { normal, circle }
- 繪制圓點虛線,小菜準備支援自定義連線寬度(直線/虛線),是以圓點半徑根據寬度獲得,圓點之間的距離小菜嘗試的是一個圓點大小,在一段長度中繪制 _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);
}
}
- 場景繪制直線或圓角虛線;
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/本地圖檔/網絡圖檔的展示,并非單一的數組下标;
- 定義 Header 類型;text 為展示文本内容,icon 為 IconData,ass_url 為本地圖檔路徑,net_url 為網絡圖檔,均不設定預設為遞增的數組下标;
enum IconType { text, icon, ass_url, net_url }
- 繪制圓環;
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);
}
}
- 繪制 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;
}
}
- 将繪制 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. 單個按鈕顯隐性
縱向 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()
]));
}
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}); // 是否高亮
分析源碼,小菜自定義的 ACEStepper 與 Stepper 用法類似,隻是增加了擴充項,具體的使用請到
GitHub;
注意事項
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 的自定義還不夠成熟,還有很多需要優化的地方,有建議的地方請多多指導!
來源: 阿策小和尚