【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


<<:  Day 5: 人工智慧在音乐领域的应用 (AI发展史与简介 - 第二次寒冬)

>>:  Dungeon Mizarka 008

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day12 文章列表

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

DAY26 - [React] 登入登出 router

今日文章目录 需求说明 过程纪录 参考文章 今天来练习Router切换页面。 需求说明 两个页面:...

Eloquent ORM - Model 资料转换

现在我们可以用各种方法将资料读取出来,不过通常读取後还要将资料做一些转换才适用,举个例子像是 boo...

如何快速上手第三方套件

在现在这种讲求快速开发的开发模式,我们通常不太会自己将所有功能都自己硬刻出来,而是会去使用第三方的套...