Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

Flutter筆記發表於2019-08-01

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

起因

前段時間群裡的一個小夥伴問了這樣一個 UI:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

因為當時瞭解過 Material 元件庫裡有一個 Stepper 控制元件,是類似的效果,我就和他說,可以魔改一下 Stepper,感覺應該不難,然後他回過來了一個這個表情:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

emmmm,沒自己做過就說簡單,確實有點「雲程式設計師」。

那既然如此,沒辦法,只能把他拎到河邊烤了,哦不對,只能自己寫一個了。

實現效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

經過

那下面就來說說編寫該控制元件的經過,

首先我們應該瞭解一下原生 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);
複製程式碼
  1. steps:型別為 List<Step>,每一步的物件
  2. type:預設為豎向排列,我們這裡需要改為 StepperType.horizontal 變成橫向
  3. currentStep:當前到了哪一步
  4. 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,看下效果:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

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 的程式碼改一下,改成三個:

Flutter | 超詳細教你如何自定義一個 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

  1. 上面是一個 Row:這個就是看到的步驟的文字和 icon。
  2. 下面是一個 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,
        ),
      ),
    );
  }
}
複製程式碼

也是簡單講一下:

  1. 迴圈 widget.steps
  2. 先新增一個 Row,Row 裡面是一個 _buildIcon 和一個 _buildHeaderText
  3. 最後判斷當前 index 是否是最後一個,如果不是的話加一根橫線 Container

這樣就組成了我們上面看到的圖,這樣也就把原始碼簡單的分析了一遍了。

魔改原始碼達到效果

還是先把改好的效果圖放上來,對應著看

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

1. copy 原始碼

先把原始碼 copy 出來,然後換個名字:「CustomStepper」:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

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 ,這裡看看都有哪些是無用的:

  1. Controls:這個是一點用都沒有,並且使用了 Expanded 來佔據下面所有的位置,這樣的話不僅沒用,而且還亂佈局
  2. 一個陰影設定為了 2.0,這個也沒什麼用
  3. 通過 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

效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

可以發現位置確實是變化了,但是這個線的位置好像不太對,不是在 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,
          ),
        ),
      ),
    ),
  );
}

複製程式碼

效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

發現效果並沒有改,

那是因為 Row 的 crossAxisAlignment 值預設為:CrossAxisAlignment.center

我們需要把它改為:CrossAxisAlignment.start

效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

線的位置是正確了,但是和我們的效果圖還有差距,

問題就是線和 icon 沒有連上。

5. 把線和 icon 連上

我們翻一下剛才的程式碼,可以簡單的發現問題所在:

  1. 首先 children 新增了一個 Column,Column 裡面是 icon 和 text
  2. 然後新增的線

問題就出在這裡,如果 Column 裡下面的字越多,那麼線就越短,因為是在 Column 後面新增的線,

那就只能先把下面的文字拿掉,讓 icon 和 線連起來:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

6. 把文字放上去

線和 icon 連起來了,那現在就要把文字放上去,如何平分螢幕:

textChildren.add(Expanded(
  child: Center(
    child: _buildHeaderText(i),
  ),
));

複製程式碼

定義一個 textChildren,在迴圈的時候新增一個 Expanded & Center,這樣就保證了平分,並且文字在中間:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

7. icon 和文字對齊

文字新增好了之後,就顯示出來 icon 的問題,那麼如何把 icon 和文字對齊?

首先確定好思路:給第一個 icon 和最後一個 icon 設定邊距

邊距的公式為:

螢幕寬度 / steps.length / 2 - icon.width / 2

解釋一下就是:

  1. 用螢幕的寬度 除以 steps 的數量得到等分的寬度,

  2. 然後用這個寬度除以 2 得到一半的寬度

  3. 然後用一半的寬度 減去 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);
}

複製程式碼
  1. 首先判斷是不是第一個或者最後一個
  2. 如果是的話,則分別設定左右邊距
  3. 如果不是,則預設就好

這時候我們再把中間兩個步驟加上,效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

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,效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

icon的顏色沒問題了,線的顏色還有問題,

同樣的解決方案,判斷一下 index,最終效果如下:

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

總結

其實看文章寫了這麼多,但是隻要思路清晰,簡單魔改一下原始碼就可以輕鬆達到我們想要的效果,

不得不說:魔改一時爽,一直魔改一直爽!

這個效果從頭到實現也就一個小時的時間,

這一個小時不光了解了該控制元件是如何實現的,還認識了很多其他的控制元件。

完整程式碼已經傳至GitHub:github.com/wanglu1209/…

Flutter | 超詳細教你如何自定義一個 Stepper 步驟元件

相關文章