iT邦幫忙

2021 iThome 鐵人賽

DAY 2
0

元件介紹

Switch 元件是一個開關的選擇器。在我們表示開關狀態,或兩種狀態之間的切換時,很適合使用。根據 Antd 的說明,這個元件和 checkbox 其實很像,但區別是, checkbox 一般只用來標記狀態是否被選取,需要提交之後才會生效,而 Switch 則會在觸發的當下直接觸發狀態的改變。

我們生活中常見的 Switch 應用情境有:

  • Wi-Fi 開/關
  • 網頁 Dark Mode 開/關
  • iPhone 鬧鐘 開/關
  • 顯示隱藏的項目 顯示/隱藏
  • 開啟即時通知 開/關
  • 靜音模式 開/關

這邊有一篇 Checkbox vs Toggle Switch 的使用情境比較,說明得很不錯,提供給大家參考
https://designtongue.me/checkbox-_vs_toggle_switch/

參考設計 & 屬性分析

checked 屬性

checked 屬性是每個 Switch 必備的屬性,是一個 boolean 值,決定按鈕的開關狀態,這個屬性也比較沒有爭議,大家都是這樣做的。

事件屬性

Switch 的事件屬性也相對單純,但仔細去比對不同 library 的時候發現也是各家做法不同。像是 MUI 是透過 onChange 來改變 Switch 的開關;而 Antd 則是透過 onClick 來改變 Switch 的開關,另外也支援 onChange 事件,但意義上特別強調開關變化時會被觸發,意思就是說,有可能某些應用是不直接透過點擊 Switch 來改變開關的狀態,但是當開關的狀態改變時,還是會觸發 onChange 事件。

會有這樣的差異,經過觀察我猜測,應該是各家實作 Switch 的方式的不同造成的,因為 MUI 是透過改寫 <input type="checkbox" /> 元件來實做 Switch ,所以主要改變 input 元件的事件是用 onChange。而 Antd 則是透過改寫 button 元件來實做 Switch,因此主要來改變開關的事件才會使用 onClick。

狀態屬性

disabled 屬性,disabled 屬性也是各家元件庫都會有的屬性,透過 boolean 值來控制元件是否被禁用。

loading 屬性,讓我比較驚訝的是 Antd 居然有 loading 屬性,這個是在 MUI 以及 Bootstrap 沒有看到的,因為在我的使用經驗及開發經驗當中比較少看到這樣的設計,所以除非我的設計師在 guideline 上面這樣畫,否則我應該不會考慮把這個功能做進我的元件裡。但仔細想想,根據 Switch 的定義,因為 Switch 需要在觸發當下就生效,因此有可能會透過觸發 Switch 來發送 api,因此有個 loading 狀態應該也是合情合理,不過確實這個屬性讓我嚇了一跳並大開眼界(當然也有可能是我太孤陋寡聞 XD)。

顏色屬性

在 MUI 當中提供了 color 的 props 傳入,可以傳入的值也只能是預設的 primary, secondary, default,不過由於 MUI 的 JSS 可以讓我們更有彈性的客製化樣式,官網上面也提供範例,因此要更改成別的顏色也是沒有問題的。像有些比較絢麗的網站就可能會需要支援更多的顏色。而 Antd 就沒有提供 props 的介面傳入,因此如果要更改顏色,應該也只能夠過複寫他的 class 中的樣式來更改。但我覺得如果是對於顏色變化要求比較高的網站,有個 props 傳入會是比較方便的。

size 屬性

MUI 及 Antd 都共同擁有的屬性之一,就是 size 屬性,其中 MUI 提供傳入的是 mediumsmall,而 Antd 提供的是 defaultsmall,其實是大同小異。

label 屬性

label 屬性我覺得也算是 Switch 元件設計上的重頭戲,label 也算是 Switch 的必備屬性之一,但是市面上不同函式庫的位置也是五花八門。

MUI 這邊的設計還蠻有意思的,他不直接把一些 form 相關的屬性綁死在這個元件上,而是將這些 form 常會用到的屬性獨立抽出成一個元件叫做 FormControlLabel,如此一來,我們一些 form 元件會用到的屬性,例如 value, disabled, onChange, label, labelPlacement 等屬性就能夠讓 Radio, Switch, Checkbox 共用,看到這樣的設計也是讓我學了一課。

透過這個 FormControlLabel 的 label 屬性,可以決定 label 的內容,而 labelPlacement,可以決定他的上、左、下、右,分別是 topstartbottomend

Antd 提供的 label 就是另一套設計,是把 label 文字直接置入 Switch 當中,提供兩個 props checkedChildrenunCheckedChildren 分別代表開、關的文字內容,而且很厲害的是,隨著 label 文字的長短,整個 Switch 的長度也會彈性增減。當然 Antd 的 Switch 若要把 label 放在上、左、下、右也是沒有問題,但就不是透過他提供的 props 傳入就能做到,而是要自己另外刻畫面。

小結
雖然起初一看到 Switch 元件會覺得是一個不起眼的簡單元件,但看到 label 這個屬性之後,真的也不得不開始讚嘆他的複雜度。所以,比起問說,到底應該要怎麼設計才是一個好的 Switch?不如換個問法,到底跟你配合的設計師會做出怎麼樣的設計呢?光是一個 label 的變化就有這麼多種,所以千萬千萬不要相信你的 PM 或是設計師告訴你「我們會盡量在設計新的頁面的時候參考原本的設計,不會改太多」然後就他設計他的,你做你的,到時候你真的見到設計圖的時候,有非常高的機率會讓你後悔當初沒有找他一起討論並訂下 guideline。

當然各家 library 也有其有特色的屬性,像是 MUI 還可以讓我們傳入 icon 來改變 checked/unchecked 狀態的 thumb,剛剛提到 Antd 還有 loading 等等的屬性。這些介面和功能的設計,我覺得可以按照大家的需求來做取捨。雖然我們也很希望做一個元件就能夠包山包海,或許因此就能一勞永逸,但這樣的想法在變化多端的軟體開發領域或許真的很難達成,特別是若你是在新創公司,產品為了跟競爭對手較勁而必須要各種趕工的時候,真的沒有太多時間讓你可以慢慢地刻元件。

因此在這 30 天的挑戰當中,我們就只實現以我的經驗覺得必要且常見的功能,畢竟自己刻的元件還是需要符合自己的情境及需求,若讀者有自己不同的需要,也可以按照自己需要來調整。

介面設計

屬性 說明 類型 默認值
isChecked 開啟或關閉 boolean false
isDisabled 禁用狀態 boolean false
themeColor 設置顏色 primary, secondary, 色票 pirmary
onClick 點擊事件 function(event: object) => void
onChange 狀態改變的 callback function function(event: object) => void
checkedChildren 開啟狀態的內容 string
unCheckedChildren 關閉狀態的內容 string

元件實作

基礎結構

透過觀察這個按鈕,我希望整個結構上是一個 wrapper 包住 label 以及 thumb 元件,這邊的 label 指的是 children label,如果是外部的 label 我們另外處理,不綁死包含在這個元件當中,這樣的結構簡單而且直覺。可以想像,label 跟 thumb 是相對於 wrapper 做定位,因此 wrapper 的 position 會是 position: relative;,而 label 及 thumb 則是 position: absolute;

<SwitchButton
  onClick={() => {}}
  {...props}
>
  <Thumb $isChecked={checked} />
  <Label $isChecked={checked}>
    {
      checked
        ? checkedChildren
        : unCheckedChildren
    }
  </Label>
</SwitchButton>

定位

在設定完 absolute position 之後,我們能夠透過 top, right, bottom, left 來對上層的 wrapper 做定位,當 unchecked 的時候,thumb 在左邊,label 在右邊;當 checked 的時候,thumb 在右邊,label 在左邊。

但是要注意的是,如果我們要做到切換 Switch 時能夠有過場的 transition 滑動動畫,我們就只能選擇單一屬性來變化,例如我們這邊選用 left,thumb unchecked 的時候 left: 0px,而在 checked 的時候,要計算所要移動的距離,讓他可以靠在最右邊:

const Thumb = styled.div`
  // {...其他省略屬性}

  position: absolute;
  ${(props) => {
    if (props.$isChecked) {
      return `left: ${props.$switchWidth - props.$thumbSize}px;`;
    }
    return 'left: 0px;';
  }}
`;

以 left 來定位,若要計算靠右時所需要移動的距離,我們可以由下圖來觀察,以 thumb 最左邊的邊界作為移動中心點,如果移動一個 switch 寬度的距離,他就會跑出去 switch 外面,因此需要讓他回來一個 thumb 直徑的距離,他才會恰好在 switch 範圍內。

若不管 children label 的話,加上 css transition,到目前為止我們已經完成了一個簡單的 switch 了。

而且若我們把 switch width 以及 thumb size 都參數化,透過上述公式的計算,我們就能夠隨意改變 switch 的 size 而不會讓這個元件容易跑版。

Children label

children label 我是把他想像成一個反過來的 thumb,thumb 靠左邊的時候 label 就靠右邊,反之亦然;因此先前 thumb 用 left 來定位,這邊的 thumb 可以用 right 以同樣的邏輯來定位,移動一個 switch 寬度的距離,再扣掉 label 寬度的距離,有需要的話再稍微微調一下間距就可以了。

const Label = styled.div`
  // {...其他省略屬性}

  position: absolute;
  ${(props) => {
    if (props.$isChecked) {
      return `right: ${props.$switchWidth - props.$labelWidth}px;`;
    }
    return 'right: 0px;';
  }}
`;

其中這邊跟 thumb 不一樣的是,因為 label 的寬度會隨著傳進來的字詞長度做改變,因此我們需要去想辦法動態取得 label 的寬度。這邊我使用的方式是透過 useRef 這個 hook ,在 render 完之後去取得 label 的寬度。

<Label
  ref={labelRef}
>
  {
    checked
      ? checkedChildren
      : unCheckedChildren
  }
</Label>

再來因為我希望整個 switch 的寬度是由 thumb 以及 label width 所計算出來的,所以當 label 為空字串的時候,我希望也能夠留一點寬度讓 switch 不要變得太短,所以給一個 minLabelSize。

並且 minLabelSize 我希望是能夠相對於 thumbSize 來計算,如此未來如果我想要微調 switch 的大小的時候,我只要去調整 thumb size,其他的部分就能夠相對於 thumb size 的比例做調整。

但要注意的是,thumbSize * 1.2 很可能讓後來維護的人覺得是一個 magic number,因此如果沒有辦法有一個更容易讓人理解的寫法的話,建議可以寫個註解。

useEffect(() => {
  const minLabelSize = thumbSize * 1.2;
  const currentLabelWidth = labelRef?.current?.clientWidth;
  if (currentLabelWidth) {
    setLabelWidth(currentLabelWidth < minLabelSize ? minLabelSize : currentLabelWidth);
  }
}, [labelRef?.current?.clientWidth]);


Switch 元件原始碼:
Source code

Storybook:
Switch


上一篇
【Day01】數據輸入元件 - Button
下一篇
【Day03】數據輸入元件 - Radio
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言