iT邦幫忙

2021 iThome 鐵人賽

DAY 7
0
Modern Web

30 天擁有一套自己手刻的 React UI 元件庫系列 第 7

【Day07】數據輸入元件 - Slider

元件介紹

Slider 是一個滑動型輸入器,允許使用者在數值區間內進行選擇,選擇的值可為連續值或是離散值。

這邊不免俗的來名詞解釋一下,在 Slider 上面可讓我們拖拉的圓形小球一般都稱為 thumb,而整個 Slider 的可拖拉軌跡我們稱為 rail,而標示我們所選取範圍的軌跡我們稱為 track。

命名之所以重要不在於我們多麽想要裝逼來炫耀自己的英文,而是希望對於維護專案有一個共識,讓別人來維護這套程式的時候不至於因為命名的不統一而影響他對於程式碼的理解。

參考設計 & 屬性分析

這個元素看起來在 MUI 以及 Antd 都還蠻有共識的,在樣式上和他的變化型態都沒有太大的差異。大致上我們可以發現有幾種 Slider 的應用型態:

  • 連續數值選擇的 Slider
  • 離散數職選擇的 Slider
  • 橫向的 Slider
  • 縱向的 Slider
  • 一個 rail 上有兩個 thumb 來選取區間範圍的 Slider

我們常看見的應用有

  • 影片/音樂播放的音量 Slider
  • 螢幕調整亮度的 Slider
  • 電商、租屋網站等等在選取金額範圍的 Slider

為了簡化複雜度,我們會先只討論單一 thumb 的 Slider。

選取範圍屬性

由於 Slider 是為了方便我們在數值範圍內做選擇,當然首先我們必須要先定義他的範圍,在 MUI 及 Antd 在定義範圍時,都用 min, max 這兩個 props 來實現,輸入的值皆為 number。

step 屬性

定義完 min, max 的範圍之後,到底我們選取數值的顆粒度有多細呢?若 min, max 為 1 ~ 10,step 為 1 的話,表示 1 ~ 10 我們只有 10 種可能,最小單位為 1;在這邊建議 step 必須要大於 0,而且建議可被 min, max 整除。意思是,若 step 為 2,那我們 min, max 就不適合 1 ~ 10 ,因為 1 無法被 2 整除。

數值屬性

在數值輸入元件當中,最重要的 props 莫過於 value 以及 defaultValue 了,延續前一篇 Text Field 提到的,這邊也會分成 Controlled component 以及 Uncontrolled component,因此在使用 value 以及 defaultValue 時,同樣需要特別留意。

外觀屬性

外觀屬性我這邊挑一個簡單但常用的來做,因為有些網站需要配合主題來改變顏色,因此 themeColor 屬性算是還蠻常會被使用的屬性。

介面設計

屬性 說明 類型 默認值
min 最小值 number 0
max 最大值 number
step 步長,取值必須大於 0,並且可被 (max - min) 整除 number
value 當前數值 number
defaultValue 預設數值 number
onChange 數值改變的 callback function function(event: object) => void
themeColor 顏色 primary, secondary, 色票

元件實作

這邊分享兩種 Slider 元件的做法給大家

方法一:純手刻

我準備的 DOM 的結構如下:

<CustomSliderContainer
  ref={railRef}
  $thumbPosX={thumbPosX}
>
  <div ref={thumbRef} className="custom-slider__thumb" />
</CustomSliderContainer>

其中需要包含 Slider 三元素 rail, track, thumb,我的結構只有兩層,父層是 rail 以及 track,子層是 thumb。其中 track 會需要知道 thumb 的位置,並以 before 這個 Pseudo-elements 來畫出:

const CustomSliderContainer = styled.div`
  width: 320px;
  height: 6px;
  background: #ddd; /* rail */
  border-radius: 5px;
  position: relative;

  .custom-slider__thumb {
    width: ${SIZE_THUMB}px;
    height: ${SIZE_THUMB}px;
    border-radius: 100%;
    background: ${(props) => props.theme.color.primary};
    position: absolute;
    top: 50%;
    left: ${(props) => props.$thumbPosX}px;
    transform: translateY(-50%) translateX(-50%);
    cursor: pointer;
  }

  &:before {
    /* track */
    content: '';
    position: absolute;
    height: 6px;
    border-radius: 5px;
    width: ${(props) => props.$thumbPosX}px;
    background: ${(props) => props.theme.color.primary};
  }
`;

再來就是最重要的 拖拉 效果,我使用的方式是透過 RxJS 來實作拖拉功能,RxJS 是一套藉由 Observable sequences 來組合非同步行為和事件基礎程序的 Library。

2017 鐵人賽的得獎作品有一篇神作是講關於 RxJS 的觀念以及實作,其中的實作有包含簡易的拖拉功能,推薦大家可以看一看:
https://ithelp.ithome.com.tw/articles/10187333

其中拖拉的關鍵步驟及程式碼如下:

  1. 首先畫面上有一個元件(thumbDOM)。
  2. 當滑鼠在元件(thumbDOM)上按下左鍵(mousedown)時,開始監聽滑鼠移動(mousemove)的位置。
  3. 接著將滑鼠移動事件裏面的位置資訊(moveEvent.clientX)提取出來,並且每當改變的時候存進去 state,藉此我們能夠改變元件的樣式。
  4. 當滑鼠左鍵放掉(mouseup)時,結束監聽滑鼠移動。
const thumbDOM = thumbRef.current;
const { body } = document;
const mouseDown = fromEvent(thumbDOM, 'mousedown');
const mouseUp = fromEvent(body, 'mouseup');
const mouseMove = fromEvent(body, 'mousemove');
mouseDown
  .pipe(
    concatMap(() => mouseMove.pipe(takeUntil(mouseUp))),
    map((moveEvent) => moveEvent.clientX),
  )
  .subscribe((mousePosX) => {
    handleUpdatePosition({ mousePosX });
  });

到這邊其實重點功能就都已經完成了,手刻雖然很爽很屌,但是會需要自己處理許多細節,可能有些被習以為常,覺得是很自然會有的功能,但是你沒有實作的話,他就是沒有,用起來就是會怪怪的。例如 Slider 除了可以拖拉以外,還會有人希望他點擊 rail 的任何一個地方,thumb 就可以跳到那裡。

為了做到這個功能,我們就必須要在 rail 上面監聽點擊事件,關鍵步驟跟拖拉很像,只是這邊是直接把點擊事件的位置資訊取出來,這樣就完成了:

const railDOM = railRef.current;
const mouseDown = fromEvent(railDOM, 'mousedown');
mouseDown
  .pipe(
    map((mouseEvent) => mouseEvent.clientX),
  )
  .subscribe((mousePosX) => {
    handleUpdatePosition({ mousePosX });
  });

以上兩個部分,包含拖拉以及點擊,最終我們取得的 mousePosX 需要再把它轉換成 Slider bar 上面 track 的長度,做法是我們需要拿到 rail 的位置,把他跟 mousePosX 做相減,我們就能夠拿到以 rail 原點為中心距離點擊位置的長度了:

除了上述提到的點擊之後 thumb 要跳到點擊位置的功能之外,其實手刻還蠻多細節要處理的,下面是我想到的一些例子:

  • thumb 被拖曳的時候,不能超出 rail 長度的範圍,所以要處理最大值以及最小值
  • 要自己處理 min, max, step 等 slider 會用到的參數,需要做一些數學計算
  • 當外部傳入 defaultValue 時,這個 value 會是 min ~ max 中的值(理想上是這樣),需要把他轉換處理成 thumb 的位置以及 track 的長度
  • 手機上面點擊的時候可能用 click 事件會行不通,需要用 touch 事件來處理同樣的邏輯

當我們真的很懶得處理這些麻煩事的時候,或許我們可以採用方法二。

方法二:覆寫原生 input range 的樣式

由於 range 是 input 的一種類型,我們無法用傳統的 CSS 編輯方法來修改樣式,所以需要使用到 -webkit-appearance 這個特殊屬性,這是 webkit 特有的屬性,代表使用系統預設的外觀,只要我們將這個屬性設為 none,那麼原本 range 的樣式就不會呈現,我們就能加入自己希望的 CSS 屬性來改變 rail 的樣式:

<StyledSlider
  {...props}
/>
const railStyle = css`
  background: #ddd; /* rail color */
  width: 320px;
  height: 6px;
  border-radius: 5px;
`;

const StyledSlider = styled.input`
  &[type='range'] {
    -webkit-appearance: none;
    ${railStyle}
  }
`;

接下來我們要處理 thumb 的樣式,這時候我們要使用另外一個 webkit 的偽元素 ::-webkit-slider-thumb 來修改:

const StyledSlider = styled.input`
  &[type='range'] {
    -webkit-appearance: none;
    ${railStyle}
  }
	
  &[type='range']::-webkit-slider-thumb {
    /* thumb style */
    -webkit-appearance: none;
    width: ${SIZE_THUMB}px;
    height: ${SIZE_THUMB}px;
    border-radius: 100%;
    border: 2px solid white;
    background: white;
    cursor: pointer;
  }
`;

到目前為止,我們就能夠做到以下這樣的樣式:

只要稍微調整一下樣式,我們就充分能夠做到像 Google Color Pikcer 這樣的 Slider:

接下來我們要處理 track 的樣式,track 樣式我提供的方法是使用 input 的為元素 before 來實踐:

const trackStyle = css`
  background: ${(props) => props.$color};
  border-radius: 5px;
  height: 6px;
`;

const StyledSlider = styled.input`
  &[type='range'] {
    -webkit-appearance: none;
    ${railStyle}
		
    &:before {
      content: '';
      position: absolute;
      z-index: -1;
      width: ${(props) => props.$widthRatio}%;
      left: 0px;
      ${trackStyle}
    }
  }
	
  //...略
`;

主要的概念是,track 的長度是 rail 起始位置到 thumb 的距離,所以我們只要計算出這個距離並把他換算成 width 屬性的百分比就可以了。

<StyledSlider
  ref={sliderRef}
  type="range"
  $widthRatio={(currentValue / max) * 100}
  {...props}
/>

最後跟大家分享一個我過去做過跟 Slider 相關的 Sideproject,這個 Sideproject 詳細的內容我寫在 github 的 Readme:

https://github.com/TimingJL/dribbble-404-images-typescript


Slider 元件原始碼(純手刻版本):
Source code

Slider 元件原始碼(覆寫 input range 版本):
Source code

Storybook:
Slider


參考

改變 HTML5 range 樣式的兩種方法
https://www.oxxostudio.tw/articles/201503/html5-input-range-style.html

How to style range input with CSS and JavaScript for better usability
https://tippingpoint.dev/style-range-input-css


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

尚未有邦友留言

立即登入留言