起因
前段時間群裡的一個小夥伴問了這樣一個 UI:
因為當時瞭解過 Material
元件庫裡有一個 Stepper
控制元件,是類似的效果,我就和他說,可以魔改一下 Stepper
,感覺應該不難,然後他回過來了一個這個表情:
emmmm,沒自己做過就說簡單,確實有點「雲程式設計師」。
那既然如此,沒辦法,只能把他拎到河邊烤了,哦不對,只能自己寫一個了。
實現效果如下:
經過
那下面就來說說編寫該控制元件的經過,
首先我們應該瞭解一下原生 Stepper
是個什麼樣子,並且分析一下原始碼。
瞭解原生 Stepper
還是老樣子,看一下構造方法:
const Stepper({
Key key,
@required this.steps,
this.physics,
this.type = StepperType.vertical,
this.currentStep = 0,
this.onStepTapped,
this.onStepContinue,
this.onStepCancel,
this.controlsBuilder,
}) : assert(steps != null),
assert(type != null),
assert(currentStep != null),
assert(0 <= currentStep && currentStep < steps.length),
super(key: key);
複製程式碼
- steps:型別為
List<Step>
,每一步的物件 - type:預設為豎向排列,我們這裡需要改為
StepperType.horizontal
變成橫向 - currentStep:當前到了哪一步
- controlsBuilder:控制的UI,預設是有「下一步」和 「取消」,我們這裡沒有用,可以設定為一個空的
Container
剩下沒說的就可以見名知意,關於 onStepTapped
這種就是點選的回撥,對於我們今天自定義 Stepper
也沒用,也就不多說了。
瞭解了構造方法,我們就可以寫一個 Demo 出來看一下:
Widget _buildStepper(){
return Stepper(
currentStep: 2,
type: StepperType.horizontal,
steps: ['提交任務', '本金返款', '評價返佣金', '追評返佣金', '任務完結']
.map(
(s) => Step(title: Text(s), content: Container(), isActive: true),
)
.toList(),
controlsBuilder: (BuildContext context,
{VoidCallback onStepContinue, VoidCallback onStepCancel}) {
return Container();
},
);
}
複製程式碼
設定 type 為 StepperType.horizontal
,看下效果:
23333,還溢位了,不用管,下面來看看原始碼,該控制元件是怎麼做出來的。
原始碼分析原生 Stepper
直接點進原始碼,發現是一個 StatefulWidget
,那就話不多說,直接找到相對應 State
裡的 build
方法:
@override
Widget build(BuildContext context) {
// .........
assert(widget.type != null);
switch (widget.type) {
case StepperType.vertical:
return _buildVertical();
case StepperType.horizontal:
return _buildHorizontal();
}
return null;
}
複製程式碼
前面「一大堆斷言」就省略了,可以看到是判斷了 type,那我們這裡用的是 StepperType.horizontal
,就可以直接去看 _buildHorizontal()
方法了。
先不看程式碼,把前面定義 Stepper
的程式碼改一下,改成三個:
這樣程式碼對照著圖片來看,就很容易看得懂。
先看 return 了什麼:
return Column(
children: <Widget>[
Material(
elevation: 2.0,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: children,
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(24.0),
children: <Widget>[
AnimatedSize(
curve: Curves.fastOutSlowIn,
duration: kThemeAnimationDuration,
vsync: this,
child: widget.steps[widget.currentStep].content,
),
_buildVerticalControls(),
],
),
),
],
);
複製程式碼
返回了一個 Column
:
- 上面是一個
Row
:這個就是看到的步驟的文字和 icon。 - 下面是一個
ListView
:這個是上面說到的 control,在後續我們自定義Stepper
是沒用的,不用管。
那我們就主要看一下 children
:
final List<Widget> children = <Widget>[];
for (int i = 0; i < widget.steps.length; i += 1) {
children.add(
InkResponse(
onTap: widget.steps[i].state != StepState.disabled ? () {
if (widget.onStepTapped != null)
widget.onStepTapped(i);
} : null,
child: Row(
children: <Widget>[
Container(
height: 72.0,
child: Center(
child: _buildIcon(i),
),
),
Container(
margin: const EdgeInsetsDirectional.only(start: 12.0),
child: _buildHeaderText(i),
),
],
),
),
);
if (!_isLast(i)) {
children.add(
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
height: 1.0,
color: Colors.grey.shade400,
),
),
);
}
}
複製程式碼
也是簡單講一下:
- 迴圈
widget.steps
- 先新增一個 Row,
Row
裡面是一個_buildIcon
和一個_buildHeaderText
- 最後判斷當前 index 是否是最後一個,如果不是的話加一根橫線
Container
這樣就組成了我們上面看到的圖,這樣也就把原始碼簡單的分析了一遍了。
魔改原始碼達到效果
還是先把改好的效果圖放上來,對應著看
1. copy 原始碼
先把原始碼 copy 出來,然後換個名字:「CustomStepper」:
2. 去除無用的東西
剛才說 Controls
是沒用的,那我們找到他並刪掉:
return Column(
children: <Widget>[
Material(
elevation: 2.0,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
children: children,
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.all(24.0),
children: <Widget>[
AnimatedSize(
curve: Curves.fastOutSlowIn,
duration: kThemeAnimationDuration,
vsync: this,
child: widget.steps[widget.currentStep].content,
),
_buildVerticalControls(),
],
),
),
],
);
複製程式碼
本來是返回一個 Column,上面是我們看到的UI,下面是 Controls
,這裡看看都有哪些是無用的:
Controls
:這個是一點用都沒有,並且使用了Expanded
來佔據下面所有的位置,這樣的話不僅沒用,而且還亂佈局- 一個陰影設定為了 2.0,這個也沒什麼用
- 通過
Container
設定了邊距,這個也沒用,一會再說
把他們全部刪掉:
return Material(
child: Row(
children: children,
),
);
複製程式碼
3. 把文字放 icon 下面
我們看更改後和更改前的頁面,最不舒服的就是文字在 icon 的後面,那先拿他開刀。
上面分析原始碼的時候看過這樣的程式碼:
Row(
children: <Widget>[
Container(
height: 72.0,
child: Center(
child: _buildIcon(i),
),
),
Container(
margin: const EdgeInsetsDirectional.only(start: 12.0),
child: _buildHeaderText(i),
),
],
),
複製程式碼
從這就能發現 icon 和 text 是連著的,那先把 Row
改成 Column
試試,
這裡需要注意一點,Column
預設是佔據最大空間,所以要設定這個屬性:mainAxisSize: MainAxisSize.min
,
效果如下:
可以發現位置確實是變化了,但是這個線的位置好像不太對,不是在 icon 的中間。
4. 更改線的位置
剛才我們也說了,這個線就是一個 Container
,定義如下:
if (!_isLast(i)) {
children.add(
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
height: 1.0,
color: Colors.grey.shade400,
),
),
);
}
複製程式碼
我們想要的效果是線和 icon 的中間對齊,那可以看上面的程式碼,icon 外面是套了一層 Container
,並且設定了 height = 72.0
。
那好,我們從這裡下手,把線也用 Container
包起來:
if (!_isLast(i)) {
children.add(
Expanded(
child: Container(
height: 72.0,
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
height: 1.0,
color: Colors.grey.shade400,
),
),
),
),
);
}
複製程式碼
效果如下:
發現效果並沒有改,
那是因為 Row 的 crossAxisAlignment
值預設為:CrossAxisAlignment.center
,
我們需要把它改為:CrossAxisAlignment.start
,
效果如下:
線的位置是正確了,但是和我們的效果圖還有差距,
問題就是線和 icon 沒有連上。
5. 把線和 icon 連上
我們翻一下剛才的程式碼,可以簡單的發現問題所在:
- 首先 children 新增了一個 Column,Column 裡面是 icon 和 text
- 然後新增的線
問題就出在這裡,如果 Column
裡下面的字越多,那麼線就越短,因為是在 Column
後面新增的線,
那就只能先把下面的文字拿掉,讓 icon 和 線連起來:
6. 把文字放上去
線和 icon 連起來了,那現在就要把文字放上去,如何平分螢幕:
textChildren.add(Expanded(
child: Center(
child: _buildHeaderText(i),
),
));
複製程式碼
定義一個 textChildren
,在迴圈的時候新增一個 Expanded & Center
,這樣就保證了平分,並且文字在中間:
7. icon 和文字對齊
文字新增好了之後,就顯示出來 icon 的問題,那麼如何把 icon 和文字對齊?
首先確定好思路:給第一個 icon 和最後一個 icon 設定邊距。
邊距的公式為:
螢幕寬度 / steps.length / 2 - icon.width / 2
解釋一下就是:
-
用螢幕的寬度 除以 steps 的數量得到等分的寬度,
-
然後用這個寬度除以 2 得到一半的寬度
-
然後用一半的寬度 減去 icon 寬度的一半
這樣就得到了應該設定的邊距。
公式瞭解了,下面就該獲取值,
螢幕寬度很好獲得:MediaQuery.of(context).size.width
,
icon 的寬度通過檢視原始碼也能發現:_kStepSize
,
那下面就可以寫程式碼了:
Widget child;
if(i == 0){
child = Container(
margin: EdgeInsets.only(left: MediaQuery.of(context).size.width / widget.steps.length / 2 - _kStepSize / 2),
child: _buildIcon(i),
);
} else if (_isLast(i)){
child = Container(
margin: EdgeInsets.only(right: MediaQuery.of(context).size.width / widget.steps.length / 2 - _kStepSize / 2),
child: _buildIcon(i),
);
}else{
child = _buildIcon(i);
}
複製程式碼
- 首先判斷是不是第一個或者最後一個
- 如果是的話,則分別設定左右邊距
- 如果不是,則預設就好
這時候我們再把中間兩個步驟加上,效果如下:
8. 當前步驟的問題
我們使用該控制元件最主要的點在於可以設定當前是在第幾步,
通過 currentStep
來控制,那我們現在無論設定與否都沒有效果,這是為何?
通過查詢 _buildIcon
的原始碼,一路追蹤,找到如下程式碼:
Color _circleColor(int index) {
final ThemeData themeData = Theme.of(context);
if (!_isDark()) {
return widget.steps[index].isActive ? themeData.primaryColor : Colors.black38;
} else {
return widget.steps[index].isActive ? themeData.accentColor : themeData.backgroundColor;
}
}
複製程式碼
可以看到這裡只是判斷了 widget.steps[index].isActive
,並沒有判斷 currentStep
,
這就很不合理,給他加上:
Color _circleColor(int index) {
final ThemeData themeData = Theme.of(context);
if (!_isDark()) {
return widget.steps[index].isActive && index <= widget.currentStep
? themeData.primaryColor
: Colors.black38;
} else {
return widget.steps[index].isActive
? themeData.accentColor
: themeData.backgroundColor;
}
}
複製程式碼
判斷了一下當前的 index 是否小於等於 widget.currentStep
,效果如下:
icon的顏色沒問題了,線的顏色還有問題,
同樣的解決方案,判斷一下 index,最終效果如下:
總結
其實看文章寫了這麼多,但是隻要思路清晰,簡單魔改一下原始碼就可以輕鬆達到我們想要的效果,
不得不說:魔改一時爽,一直魔改一直爽!
這個效果從頭到實現也就一個小時的時間,
這一個小時不光了解了該控制元件是如何實現的,還認識了很多其他的控制元件。
完整程式碼已經傳至GitHub:github.com/wanglu1209/…