iT邦幫忙

2024 iThome 鐵人賽

DAY 24
1

基本結構

滑動條主體很單純,使用 div 就可以完成,不過握把的部分需要使用 SVG 實現。

結構概念如下圖:

D24.png

  • 「容器」負責包裝所有內容,並提供水平移動的基準範圍。
  • 「軌道」展示握把可移動範圍。
  • 「握把」使用 SVG 繪製,方便實現彈性晃動效果。

首先加入 template 部分。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div class="slider-stubborn">
    <div class="track " />

    <svg class="thumb">
    </svg>
  </div>
</template>

相當單純。(´,,•ω•,,)

不過可以預期「握把」部分有許多內部邏輯,讓我們把「握把」獨立一個元件吧。( ´ ▽ ` )ノ

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg class="thumb">
  </svg>
</template>

<script setup lang="ts">
</script>

<style scoped lang="sass">
</style>

引入 thumb 元件。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div class="slider-stubborn">
    <div class="track " />

    <slider-thumb />
  </div>
</template>

<script setup lang="ts">
import SliderThumb from './slider-stubborn-thumb.vue'

...
</script>

...

接下來讓我們加入程式邏輯吧。

來個滑動條

既然名字裡有「滑動條」,還是該優先完成滑動條的基本功能。

首先來定義一下元件參數與事件。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div class="slider-stubborn">
    <div class="track " />

    <slider-thumb />
  </div>
</template>

<script setup lang="ts">
...

// #region Props
interface Props {
  modelValue: number;
  disabled?: boolean;
  min?: number;
  max?: number;
  /** 握把被拉長的最大長度 */
  maxThumbLength?: number;
  thumbSize?: number;
  thumbColor?: string;
  trackClass?: string;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  min: 0,
  max: 100,
  maxThumbLength: 200,
  thumbSize: 20,
  thumbColor: '#34c6eb',
  trackClass: ' bg-[#EEE]',
});

// #region Emits
const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
}>();
// #endregion Emits
</script>

...

參數基本上和 input slider 的內容相同,emit 中的 update:modelValue 則用於實現 v-model 邏輯。

現在來實作元件 v-model 功能吧。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div class="slider-stubborn">
    <div class="track " />

    <slider-thumb />
  </div>
</template>

<script setup lang="ts">
...

const modelValue = useVModel(props, 'modelValue', emit);
</script>

...

這裡使用 VueUse 的 useVModel 實現,你沒看錯,一行就完成惹。(≖‿ゝ≖)✧

現在只要 modelValue 數值發生變化,就會自動 emit 出去。

Vue 3.4 以上可以使用 defineModel,更簡潔方便。( ´ ▽ ` )ノ

接著讓我們加入容器相關邏輯與軌道樣式。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div
    ref="sliderRef"
    class="slider-stubborn relative py-3"
    @mousedown="(e) => e.preventDefault()"
    @mousemove="(e) => e.preventDefault()"
    @touchstart="(e) => e.preventDefault()"
    @touchmove="(e) => e.preventDefault()"
  >
    <div
      class="rounded-full h-[8px]"
      :class="props.trackClass"
    />

    <slider-thumb />
  </div>
</template>

<script setup lang="ts">
...

const sliderRef = ref<HTMLDivElement>();
const mouseInSlider = reactive(useMouseInElement(
  sliderRef, { eventFilter: throttleFilter(15) }
));
const sliderSize = reactive(useElementSize(sliderRef));
</script>

...

e.preventDefault() 用來阻止原本的相關事件,接著建立兩個變數:

  • mouseInSlider:滑鼠於容器位置,用來計算握把與滑鼠位置相關邏輯
  • sliderSize:容器尺寸,方便未來計算握把位置

讓我們判斷使用者是否「按住」滑動條。

src\components\slider-stubborn\slider-stubborn.vue

...

<script setup lang="ts">
...

const { pressed: isHeld } = useMousePressed({
  target: sliderRef,
})
</script>

...

沒錯,一樣一行解決。♪( ◜ω◝و(و

這就是 Vue 3 Composition API 的精隨,我們可以封裝各種功能,並在元件中簡單複用。

接下來計算滑鼠拉動位置與滑動條相關的計算邏輯。

src\components\slider-stubborn\slider-stubborn.vue

...

<script setup lang="ts">
...

/** 將目前數值根據比例轉換成 0 至 100 */
const ratio = computed(() => {
  const value = modelValue.value / (props.max - props.min) * 100;

  if (value < 0) return 0;
  if (value > 100) return 100;
  return value;
});

/** 滑鼠目前位置在滑動條中的比率(0~100) */
const mouseRatio = computed(() => {
  const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100;

  if (value < 0) return 0;
  if (value > 100) return 100;
  return value;
});

/** 計算並持續同步 modelValue 數值 */
watch(() => [mouseRatio, isHeld], () => {
  if (props.disabled || !isHeld.value) return;

  modelValue.value = (props.max - props.min) * mouseRatio.value / 100;
}, { deep: true })
</script>

...

這樣我們就完成一個稱職的滑動條該有的基本邏輯了。◝( •ω• )◟

接著加上握把的部分,將參數傳入並加上外觀,看看效果如何。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    width="50"
    height="50"
    viewBox="-50 -50 100 100"
    class="thumb absolute pointer-events-none"
  >
    <path
      d="M0 0 Z"
      :stroke="props.thumbColor"
      stroke-width="30"
      stroke-linejoin="round"
      stroke-linecap="round"
      fill="none"
      vector-effect="non-scaling-stroke"
    />
  </svg>
</template>

<script setup lang="ts">

interface Props {
  min: number;
  max: number;
  maxThumbLength: number;
  thumbSize: number;
  thumbColor: string;
  disabled: boolean;

  isHeld: boolean;
  ratio: number;
  mouseRatio: number;
  sliderSize: {
    width: number,
    height: number,
  };
}
const props = withDefaults(defineProps<Props>(), {});
</script>

<style scoped lang="sass">
.thumb
  top: 50%
  transform: translate(-50%, -50%)
</style>

說明如下:

  • svg 的尺寸先暫時設為 50
  • path 的 d 為 M0 0 Z 的意思表示「在座標 0, 0 的位置畫一個封閉的線」,所以看起來會是一個點

現在把相關參數傳入 thumb 元件。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div
    ref="sliderRef"
    ...
  >
    ...

    <slider-thumb
      v-bind="props"
      :is-held="isHeld"
      :ratio="ratio"
      :mouse-ratio="mouseRatio"
      :slider-size="sliderSize"
    />
  </div>
</template>

...

看起來像個滑動條了。( ´ ▽ ` )ノ

image.png

現在把滑動條數值綁定樣式,讓握把可以被滑鼠拉動吧。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ...
    :style="svgStyle"
  >
    ...
  </svg>
</template>

<script setup lang="ts">
...

const svgStyle = computed<CSSProperties>(() => {
  let leftValue = props.isHeld ? props.mouseRatio : props.ratio;

  if (props.disabled) {
    leftValue = props.ratio
  }

  return {
    left: `${leftValue}%`,
  }
});
</script>

...

同時調整一下 basic-usage 內容。

src\components\slider-stubborn\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border border-gray-300 p-6">
    目前數值:{{ Math.floor(value) }}
    <slider-stubborn v-model="value" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SliderStubborn from '../slider-stubborn.vue';

const value = ref(50);
</script>

終於是個稱職的滑動條了。

動畫.gif

讓握把可以拉長吧

現在讓我們進入重頭戲部分,來設計會伸長的握把。( ゚∀。)

首先取得畫面、SVG 相關的資訊,用於後續的計算邏輯。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ref="svgRef"
    ...
  >
    ...
  </svg>
</template>

<script setup lang="ts">
...

/** 視窗尺寸,用來防止物體意外超出畫面範圍 */
const windowSize = reactive(useWindowSize());

const svgRef = ref<SVGElement>();
const mouseInSvg = reactive(useMouseInElement(
  svgRef, { eventFilter: throttleFilter(15) }
));
/** 以 svg 中心為 0 點 */
const mousePosition = computed(() => ({
  x: mouseInSvg.elementX - mouseInSvg.elementWidth / 2,
  y: mouseInSvg.elementY - mouseInSvg.elementHeight / 2,
}));

const svgStyle = computed<CSSProperties>(...);
</script>

...

接著來新增握把相關參數。(◜௰◝)

這裡我們使用 SVG 的 path 元素繪製線條,使用 Q 指令(quadratic Bézier curve)繪製二次貝茲曲線。

Q 指令很單純,起點與終點共用同一個控制點,所以只要定義三個點即可,如下圖。

D24 (1).png

path 的指令相當多樣,有興趣的讀者可見此連結:MDN:SVG Paths

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ...
  >
    <path
      :d="pathD"
      ...
    />
  </svg>
</template>

<script setup lang="ts">
...

/** svg path 的 Q 指令需要控制點與終點,表達二次貝茲曲線
 * 
 * [文件](https://www.oxxostudio.tw/articles/201406/svg-04-path-1.html)
 */
const ctrlPoint = ref({ x: 0, y: 0 });
const endPoint = ref({ x: 0, y: 0 });

const pathD = computed(() => {
  const { x: ctrlX, y: ctrlY } = ctrlPoint.value;
  const { x: endX, y: endY } = endPoint.value;

  return [
    `M0 0`,
    `Q${ctrlX} ${ctrlY}, ${endX} ${endY}`,
  ].join(' ')
});
</script>

...

接著處理終點動畫。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ...
  >
    <path
      :d="pathD"
      ...
    />
  </svg>
</template>

<script setup lang="ts">
...

/** 處理終點動畫 */
useIntervalFn(() => {
  if (!props.isHeld || !props.disabled) return;

  const newPoint = {
    x: (mousePosition.value.x - endPoint.value.x) / 2 + endPoint.value.x,
    y: (mousePosition.value.y - endPoint.value.y) / 2 + endPoint.value.y,
  }

  const length = getVectorLength(newPoint);

  // 如果超過 maxThumbLength,則將 newPoint 限制在 maxThumbLength 範圍內
  if (length > props.maxThumbLength) {
    newPoint.x = newPoint.x * scaleFactor + noise;
    newPoint.y = newPoint.y * scaleFactor + noise;
  }

  endPoint.value = newPoint;
}, 15)
</script>

...

這裡使用 useIntervalFn 實現,可能會有讀者好奇為甚麼不使用 useRafFnuseRafFn 基於 requestAnimationFrame,理論上效果不是應該更好嗎?

理論上是這樣沒錯,但實際上比較 useIntervalFnuseRafFn 兩者後,useIntervalFn 的動畫效果比較流暢,雖然不知道為甚麼就是了。ヾ(◍'౪`◍)ノ゙

如果有讀者知道為甚麼歡迎留言補充。(´▽`ʃ♡ƪ)

現在讓我們在 basic-usage 新增停用 checkbox。

src\components\slider-stubborn\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border border-gray-300 p-6">
    <label class=" flex items-center border p-4 rounded">
      <input
        v-model="disabled"
        type="checkbox"
      >
      <span class="ml-2">
        停用按鈕
      </span>
    </label>

    目前數值:{{ Math.floor(value) }}
    <slider-stubborn
      v-model="value"
      :disabled="disabled"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SliderStubborn from '../slider-stubborn.vue';

const disabled = ref(false);
const value = ref(50);
</script>

可以注意到現在停用後可以拉長拉把了。( ´ ▽ ` )ノ

動畫.gif

路人:「握把被切掉啦!Σ(ˊДˋ;)」

鱈魚:「那就讓 SVG 大一點吧!( ゚∀。)」

路人:「這樣會不會導致 SVG 超出畫面時,產生多餘的滾動條嗎?(*´・д・)」

鱈魚:「也是啦,那就讓我們動態計算 SVG 尺寸吧。◝( •ω• )◟」

讓我們根據滑鼠拉動的位置,動態計算 SVG 尺寸。

新增 SVG 尺寸計算邏輯並加上 border 觀察一下。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ref="svgRef"
    v-bind="svgAttrData"
    :style="svgStyle"
    class="thumb absolute pointer-events-none"
  >
    ...
  </svg>
</template>

<script setup lang="ts">
...

/** 以 svg 中心為 0 點 */
const mousePosition = computed(...);

/** 動態調整 svg 尺寸,避免拉動 slider 時頁面產生多餘滾動條 */
const svgSize = ref(props.maxThumbLength + props.thumbSize * 1.5);
const svgAttrData = computed(() => ({
  width: svgSize.value,
  height: svgSize.value,
  viewBox: [
    svgSize.value / -2,
    svgSize.value / -2,
    svgSize.value,
    svgSize.value,
  ].join(' ')
}));

/** svgSize 動畫效果 */
useIntervalFn(() => {
  let newSize = Math.max(
    Math.abs(mousePosition.value.x),
    Math.abs(mousePosition.value.y),
    props.thumbSize,
  ) * 2;

  // 限制最大尺寸
  if (newSize > props.maxThumbLength * 2) {
    newSize = props.maxThumbLength * 2;
  }

  // 如果按鈕被按住或是停用,SVG 尺寸與握把相同即可
  if (!props.isHeld || !props.disabled) {
    newSize = props.thumbSize;
  }

  /** 1.5 是安全係數 */
  newSize += props.thumbSize * 1.5;

  const delta = newSize - svgSize.value;
  // 限制精度,減少不必要的運算
  if (Math.abs(delta) < 0.01) {
    svgSize.value = newSize;
    return;
  }

  // 長大要快,縮小要慢,避免切到正在播放動畫的握把
  if (delta > 0) {
    svgSize.value += delta;
  } else {
    svgSize.value += delta / 10;
  }
}, 15)

const svgStyle = computed<CSSProperties>(...);
...
</script>

...

動畫.gif

可以看到 SVG 尺寸會隨著拉動的範圍變化了!◝( •ω• )◟

不過握把沒有在放開的時候回歸,讓我們補充一下回歸邏輯。

src\components\slider-stubborn\slider-stubborn-thumb.vue

...

<script setup lang="ts">
import anime from 'animejs';

...

/** 放開時,播放回彈動畫 */
watch(() => props.isHeld, (value) => {
  if (value) return;

  anime({
    targets: endPoint.value,
    x: 0,
    y: 0,
    easing: 'easeOutElastic',
    duration: 300,
  });
})

/** 處理終點動畫 */
useIntervalFn(...)
</script>

...

使用 anime.js 輕鬆實現。

動畫.gif

現在放開後,握把會彈回原點了!ᕕ( ゚ ∀。)ᕗ

接著來實作「被拉長的握把會隨著長度變細」規格吧。

這個很簡單,只要將長度映射寬度即可。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg ... >
    <path
      ...
      :stroke-width="strokeWidth"
      ...
    />
  </svg>
</template>

<script setup lang="ts">
import anime from 'animejs';

...

const pathD = computed(...);

/** 目前長度 */
const length = computed(() => {
  /** 因為起點是 0, 0,所以變化量直接等於終點座標 */
  const delta = {
    x: endPoint.value.x,
    y: endPoint.value.y,
  }

  return getVectorLength(delta);
});

const strokeMinWidth = computed(() => Math.max(props.thumbSize * 0.1, 5));
const strokeWidth = computed(() => mapNumber(
  length.value,
  0,
  props.maxThumbLength,
  props.thumbSize,
  strokeMinWidth.value,
));

...
</script>

...

動畫.gif

現在握把會越拉越細了。ԅ(´∀` ԅ)

最後讓我們實現最關鍵的「被拉長後會有 Q 彈的慣性效果」規格吧。

這裡會稍為複雜一點,需要用上一點物理概念,首先定義相關係數。

src\components\slider-stubborn\slider-stubborn-thumb.vue

...

<script setup lang="ts">
...

const ctrlPoint = ref({ x: 0, y: 0 });
/** 控制點速度,用來模擬震盪效果 */
let ctrlPointVelocity = { x: 0, y: 0 };
/** 彈性係數,根據目前長度映射,模擬拉越緊震動越快 */
const ctrlPointStiffness = computed(() => mapNumber(
  length.value,
  0, props.maxThumbLength,
  3.5, 4.5,
));
/** 速度衰減率,根據目前長度映射,模擬越短震動越快停止
 * 
 * 範圍 0 ~ 1。越小衰減越快
 */
const ctrlPointDamping = computed(() => mapNumber(
  length.value,
  0, props.maxThumbLength,
  0.85, 0.75,
));

...
</script>

...

大家可以微調係數,看看會有甚麼效果喔。ԅ(´∀` ԅ)

接著處理控制點動畫並刪除觀察用的 border。

src\components\slider-stubborn\slider-stubborn-thumb.vue

<template>
  <svg
    ...
    class="thumb absolute pointer-events-none"
  >
    ...
  </svg>
</template>

<script setup lang="ts">
...

/** 處理控制點 */
useIntervalFn(() => {
  const targetPoint = {
    x: endPoint.value.x / 2,
    y: endPoint.value.y / 2,
  }

  const dx = targetPoint.x - ctrlPoint.value.x
  const dy = targetPoint.y - ctrlPoint.value.y

  // 彈力公式:F = -k * x (k 是彈性係數,x 是位移)
  ctrlPointVelocity.x += ctrlPointStiffness.value * dx
  ctrlPointVelocity.y += ctrlPointStiffness.value * dy

  // 阻尼,減少速度
  ctrlPointVelocity.x *= ctrlPointDamping.value
  ctrlPointVelocity.y *= ctrlPointDamping.value

  if (Math.abs(ctrlPointVelocity.x) < 0.001 && Math.abs(ctrlPointVelocity.y) < 0.001) {
    ctrlPointVelocity = { x: 0, y: 0 }
    return;
  }

  // 更新座標
  ctrlPoint.value.x += ctrlPointVelocity.x
  ctrlPoint.value.y += ctrlPointVelocity.y

  // 不可以超出畫面
  if (Math.abs(ctrlPoint.value.x) > windowSize.width / 2) {
    ctrlPoint.value.x = ctrlPoint.value.x > 0 ? windowSize.width / 2 : -windowSize.width / 2;
  }
  if (Math.abs(ctrlPoint.value.y) > windowSize.height / 2) {
    ctrlPoint.value.y = ctrlPoint.value.y > 0 ? windowSize.height / 2 : -windowSize.height / 2;
  }
}, 15)
</script>

...

動畫.gif

恭喜!我們完成一個 Q 彈又固執的滑動條了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

最後讓我們追加點 a11y 標記吧。

src\components\slider-stubborn\slider-stubborn.vue

<template>
  <div
    ref="sliderRef"
    role="slider"
    v-bind="ariaAttr"
    ...
  >
    ...
  </div>
</template>

<script setup lang="ts">
...

const ariaAttr = computed(() => ({
  'aria-valuemin': props.min,
  'aria-valuemax': props.max,
  'aria-valuenow': modelValue.value,
  'aria-orientation': 'horizontal',
  'aria-disabled': props.disabled,
} as const));
</script>

...

完美,收工。(´,,•ω•,,)

有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭

總結

  • 完成「固執的滑動條」樣式
  • 完成「固執的滑動條」邏輯
  • 完成「固執的滑動條」的 basic-usage 範例

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D24


上一篇
D23 - 固執的滑動條:分析需求
下一篇
D25 - 固執的滑動條:單元測試
系列文
要不要 Vue 點酷酷的元件?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言