每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
固執的滑動條
step-by-step完成品請見 固執的滑動條元件。
停用時絕不妥協的滑動條。(。-`ω´-)
把這個元件的開發過程拆解為 10 個小步驟,每一步都只加入一個新概念。
Step 1:靜態軌道
Section titled “Step 1:靜態軌道”萬事起頭難,先從一條靜態軌道和一個圓形握把開始吧。( ´ ▽ ` )ノ
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="slider relative py-3"> <div class="track rounded-full" />
<div class="thumb" /> </div> </div></template>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb width: 20px height: 20px border-radius: 50% background: #34c6eb position: absolute top: 50% left: 50% transform: translate(-50%, -50%)</style>整個元件的 HTML 骨架從這一步就定型了,分為兩層:
- 外框
div.slider:作為整個滑動條的定位參考點(position: relative)。握把會用absolute定位在這一層。 - 軌道
div.track:灰色的橫條,視覺上代表可拖動的範圍。
- 握把用
position: absolute+top: 50%+left: 50%定位在軌道中央 transform: translate(-50%, -50%)讓握把的中心點對齊位置,而非左上角- 軌道高度
8px搭配border-radius: 9999px形成圓角膠囊形狀
Step 2:追蹤滑鼠位置
Section titled “Step 2:追蹤滑鼠位置”握把要跟著滑鼠跑,首先得知道滑鼠在哪裡。
VueUse 的 useMouseInElement 幫我們追蹤滑鼠相對於元素的位置。只要傳入一個 ref 元素,就能拿到滑鼠在元素內的座標。
移動滑鼠到下方軌道上,觀察握把如何跟隨:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="info text-sm"> <div>elementX: {{ Math.round(mouseInSlider.elementX) }}</div> <div>elementWidth: {{ Math.round(mouseInSlider.elementWidth) }}</div> <div>mouseRatio: {{ mouseRatio.toFixed(1) }}%</div> </div>
<div ref="sliderRef" class="slider relative cursor-pointer py-3" > <div class="track rounded-full" />
<div class="thumb" :style="{ left: `${mouseRatio}%` }" /> </div> </div></template>
<script setup lang="ts">import { useMouseInElement } from '@vueuse/core'import { computed, reactive, ref } from 'vue'
const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
/** 滑鼠在 slider 中的百分比位置 */const mouseRatio = computed(() => { const ratio = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
// 限制在 0~100 之間 return Math.max(0, Math.min(100, ratio))})</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb width: 20px height: 20px border-radius: 50% background: #34c6eb position: absolute top: 50% transform: translate(-50%, -50%) transition: left 0.05s pointer-events: none
.info font-family: monospace opacity: 0.7</style>mouseRatio 的計算
Section titled “mouseRatio 的計算”整個滑動條最核心的概念就是比例(ratio)。我們將滑鼠的 X 座標轉換成 0~100 的百分比:
const mouseRatio = computed(() => { const ratio = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, ratio))})Math.max(0, Math.min(100, ratio)) 確保比例不會超出 0~100 的範圍,即使滑鼠移到元素外面也不會出問題。
| 回傳值 | 說明 |
|---|---|
elementX | 滑鼠相對於元素左邊界的 X 座標 |
elementWidth | 元素的寬度,用來計算百分比 |
Step 3:數值與握把位置
Section titled “Step 3:數值與握把位置”上一步的握把只是跟著滑鼠跑,沒有「數值」的概念。這一步加入 modelValue,讓握把位置反映實際數值。
透過下方的 input 修改數值,觀察握把如何跟著移動:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} </div>
<div class="slider relative cursor-pointer py-3"> <div class="track rounded-full" />
<!-- 顯示數值位置的握把 --> <div class="thumb" :style="{ left: `${ratio}%` }" /> </div>
<div class="border rounded"> <input v-model.number="modelValue" type="number" class="w-full px-2 py-1 text-sm" :min="0" :max="100" > </div> </div></template>
<script setup lang="ts">import { computed, ref } from 'vue'
const modelValue = ref(50)
const min = 0const max = 100
/** 將數值轉換為百分比位置 */const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb width: 20px height: 20px border-radius: 50% background: #34c6eb position: absolute top: 50% transform: translate(-50%, -50%) transition: left 0.2s pointer-events: none</style>ratio:數值到位置的映射
Section titled “ratio:數值到位置的映射”ratio 是把數值轉換成百分比位置的關鍵:
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})例如 min=0, max=100, modelValue=50,ratio 就是 50%,握把會在軌道正中間。
ratio 與 mouseRatio 的差異
Section titled “ratio 與 mouseRatio 的差異”這兩個比例容易搞混,但各司其職:
- ratio:代表「目前數值」在軌道上的位置。由
modelValue決定。 - mouseRatio:代表「滑鼠」在軌道上的位置。由滑鼠座標決定。
正常拖動時,握把跟著 mouseRatio 走(即時回饋);放開後,握把回到 ratio 的位置(對齊數值)。
Step 4:拖動互動
Section titled “Step 4:拖動互動”有了數值和位置的映射,接下來要讓使用者能夠拖動握把來改變數值。
VueUse 的 useMousePressed 偵測滑鼠是否按住,搭配 mouseRatio 就能實現拖動。
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} | 拖動中:{{ isHeld }} </div>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<div class="thumb" :style="thumbStyle" /> </div> </div></template>
<script setup lang="ts">import { useElementSize, useMouseInElement, useMousePressed,} from '@vueuse/core'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const min = 0const max = 100
const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))const sliderSize = reactive(useElementSize(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
/** 數值對應的百分比位置 */const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
/** 滑鼠對應的百分比位置 */const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
/** 根據滑鼠位置計算數值 */function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return Math.round(rawValue)}
/** 拖動時更新數值 */watch([mouseRatio, isHeld], () => { if (!isHeld.value) return
modelValue.value = getValue(mouseRatio.value)})
const thumbStyle = computed(() => ({ left: `${isHeld.value ? mouseRatio.value : ratio.value}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb width: 20px height: 20px border-radius: 50% background: #34c6eb position: absolute top: 50% transform: translate(-50%, -50%) transition-property: left pointer-events: none</style>拖動的核心邏輯很簡單:當滑鼠按住時,將滑鼠位置換算成數值。
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
watch([mouseRatio, isHeld], () => { if (!isHeld.value) return modelValue.value = getValue(mouseRatio.value)})getValue 負責把百分比轉回實際數值:
function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return Math.round(rawValue)}為什麼握把位置要區分 isHeld?
Section titled “為什麼握把位置要區分 isHeld?”const thumbStyle = computed(() => ({ left: `${isHeld.value ? mouseRatio.value : ratio.value}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))- 按住時:握把跟著
mouseRatio(滑鼠位置),transition設為0s確保零延遲 - 放開後:握把回到
ratio(數值位置),transition設為0.2s產生平滑過渡
這樣拖動時感覺是即時的,放開後又有優雅的對齊動畫。
防止預設行為
Section titled “防止預設行為”@mousedown="(e) => e.preventDefault()"@touchstart="(e) => e.preventDefault()"不加這兩行的話,拖動時瀏覽器會觸發文字選取或頁面滾動,影響操作體驗。
Step 5:Step 吸附
Section titled “Step 5:Step 吸附”上一步的滑動條只有整數值。但實際需求可能需要不同的步進,例如音量每次跳 5,或是精確到小數點後一位。
切換不同的 step 值,觀察拖動時數值如何「吸附」:
<template> <div class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6"> <div class="flex items-center gap-4 text-sm"> <label> step: <select v-model.number="step" class="example-select" > <option :value="1"> 1 </option> <option :value="5"> 5 </option> <option :value="10"> 10 </option> <option :value="0.1"> 0.1 </option> </select> </label>
<span>目前數值:{{ modelValue }}</span> </div>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<div class="thumb" :style="thumbStyle" /> </div> </div></template>
<script setup lang="ts">import { useMouseInElement, useMousePressed,} from '@vueuse/core'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const min = 0const max = 100const step = ref(1)
const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
/** 計算 step 的小數位數,用於修正浮點數精度 */const stepPrecision = computed(() => { const stepString = step.value.toString() if (stepString.includes('.')) { return stepString.split('.')[1]?.length ?? 0 } return 0})
function fixed(value: number) { return Number(value.toFixed(stepPrecision.value))}
/** 根據滑鼠位置計算數值,並對齊 step */function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return fixed(Math.round(rawValue / step.value) * step.value)}
watch([mouseRatio, isHeld], () => { if (!isHeld.value) return
modelValue.value = getValue(mouseRatio.value)})
const thumbStyle = computed(() => ({ left: `${isHeld.value ? mouseRatio.value : ratio.value}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb width: 20px height: 20px border-radius: 50% background: #34c6eb position: absolute top: 50% transform: translate(-50%, -50%) transition-property: left pointer-events: none</style>getValue 函式加入了 step 計算。先算出原始值,再用 Math.round 四捨五入到最近的 step 倍數:
function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return fixed(Math.round(rawValue / step.value) * step.value)}例如 step=5, rawValue=17:Math.round(17 / 5) * 5 = Math.round(3.4) * 5 = 3 * 5 = 15。
浮點數精度問題
Section titled “浮點數精度問題”JavaScript 的浮點數運算會產生精度誤差。例如 0.1 + 0.2 = 0.30000000000000004。
在 step 為小數(如 0.1)時,這個問題會導致數值顯示為 50.10000000000001 之類的結果。
解法是根據 step 的小數位數來 toFixed:
const stepPrecision = computed(() => { const stepString = step.value.toString() if (stepString.includes('.')) { return stepString.split('.')[1]?.length ?? 0 } return 0})
function fixed(value: number) { return Number(value.toFixed(stepPrecision.value))}step=0.1 → stepPrecision=1 → toFixed(1) → "50.1" → Number("50.1") → 50.1。精度問題解決。
Step 6:SVG 貝茲曲線握把
Section titled “Step 6:SVG 貝茲曲線握把”到目前為止,握把都是一個 CSS 圓形。但最終元件的握把是用 SVG 的 path 畫的,因為後面要讓它像橡皮筋一樣拉伸、彎曲,這些效果用純 CSS 做不到。
這一步先把圓形替換成 SVG path,但行為完全不變:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} </div>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<!-- SVG 握把:用 path 畫一條線 --> <svg :width="thumbSize" :height="thumbSize" :viewBox="`${thumbSize / -2} ${thumbSize / -2} ${thumbSize} ${thumbSize}`" :style="thumbStyle" class="thumb-svg pointer-events-none absolute" > <!-- 起點 0,0 到終點 0,0,目前只是一個點 --> <path d="M0 0 Q0 0, 0 0" :stroke="thumbColor" :stroke-width="thumbSize" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" /> </svg> </div> </div></template>
<script setup lang="ts">import { useMouseInElement, useMousePressed,} from '@vueuse/core'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const min = 0const max = 100const thumbSize = 20const thumbColor = '#34c6eb'
const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return Math.round(rawValue)}
watch([mouseRatio, isHeld], () => { if (!isHeld.value) return modelValue.value = getValue(mouseRatio.value)})
const thumbStyle = computed(() => ({ left: `${isHeld.value ? mouseRatio.value : ratio.value}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb-svg top: 50% transform: translate(-50%, -50%) transition-property: left transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)</style>為什麼用 SVG path?
Section titled “為什麼用 SVG path?”一般的 HTML 元素只能畫矩形或圓形。但握把被拉伸時,需要:
- 一條可以任意彎曲的線段
- 線段的粗細可以動態變化
- 線段的端點要是圓頭的
SVG 的 path 元素搭配 Q(二次貝茲曲線)指令,完美滿足這些需求。
path 的 Q 指令
Section titled “path 的 Q 指令”M0 0 Q cx cy, ex eyM0 0:起點,固定在 SVG 中心Q cx cy, ex ey:二次貝茲曲線,cx cy是控制點,ex ey是終點
目前控制點和終點都在 (0, 0),所以看起來就是一個點。下一步會讓終點跟著滑鼠移動。
stroke 相關屬性
Section titled “stroke 相關屬性”<path :stroke-width="thumbSize" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke"/>stroke-linecap="round":線段端點是圓頭的,所以即使是一個點也會顯示為圓形vector-effect="non-scaling-stroke":線段粗細不會受到 viewBox 縮放影響fill="none":只要線條,不要填充
Step 7:握把拉伸
Section titled “Step 7:握把拉伸”這一步是整個元件最核心的視覺效果:disabled 時,握把會像橡皮筋一樣被拉長。
勾選「停用滑動條」後,試著拖動握把,觀察它如何伸縮:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} | 停用:{{ disabled }} </div>
<label class="flex items-center gap-2 text-sm"> <input v-model="disabled" type="checkbox" > 停用滑動條 </label>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<svg :width="svgSize" :height="svgSize" :viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`" :style="svgStyle" class="thumb-svg pointer-events-none absolute" > <path :d="pathD" :stroke="thumbColor" :stroke-width="strokeWidth" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" /> </svg> </div> </div></template>
<script setup lang="ts">import type { CSSProperties } from 'vue'import { useIntervalFn, useMouseInElement, useMousePressed,} from '@vueuse/core'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const disabled = ref(false)const min = 0const max = 100const thumbSize = 20const thumbColor = '#34c6eb'const maxThumbLength = 200
const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
function getValue(ratio: number) { const rawValue = min + (ratio / 100) * (max - min) return Math.round(rawValue)}
watch([mouseRatio, isHeld], () => { if (!isHeld.value || disabled.value) return modelValue.value = getValue(mouseRatio.value)})
// --- SVG 握把 ---
/** 以 SVG 中心為原點的滑鼠座標 */const mousePosition = computed(() => ({ x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100), y: mouseInSlider.elementY - 24,}))
function getVectorLength({ x, y }: { x: number; y: number }) { return Math.sqrt(x * x + y * y)}
/** 線段終點:disabled 時跟著滑鼠拉伸 */const endPoint = ref({ x: 0, y: 0 })
useIntervalFn(() => { if (!isHeld.value || !disabled.value) { 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)
if (length > maxThumbLength) { const noise = Math.random() * 4 const scaleFactor = maxThumbLength / length newPoint.x = newPoint.x * scaleFactor + noise newPoint.y = newPoint.y * scaleFactor + noise }
endPoint.value = newPoint}, 15)
/** disabled 解除時,重設終點 */watch([() => disabled.value, () => isHeld.value], () => { if (!disabled.value || !isHeld.value) { endPoint.value = { x: 0, y: 0 } }})
const length = computed(() => getVectorLength(endPoint.value))
const pathD = computed(() => { const { x, y } = endPoint.value return `M0 0 Q${x / 2} ${y / 2}, ${x} ${y}`})
function mapNumber( current: number, inMin: number, inMax: number, outMin: number, outMax: number,) { const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))}
const strokeWidth = computed(() => mapNumber( length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),))
const svgSize = ref(thumbSize * 1.5)
const svgStyle = computed<CSSProperties>(() => ({ left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb-svg top: 50% transform: translate(-50%, -50%) will-change: left width height transition-property: left transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)</style>拉伸的核心邏輯
Section titled “拉伸的核心邏輯”拉伸效果的關鍵是控制 SVG path 的終點(endPoint):
const endPoint = ref({ x: 0, y: 0 })正常狀態下,終點在 (0, 0),path 只是一個圓點。disabled 時,終點朝滑鼠方向延伸,path 就被「拉長」了。
為什麼用 useIntervalFn 而不是 watch?
Section titled “為什麼用 useIntervalFn 而不是 watch?”useIntervalFn(() => { if (!isHeld.value || !disabled.value) 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, } endPoint.value = newPoint}, 15)終點不是直接跳到滑鼠位置,而是每 15ms 移動一半的距離(/ 2)。這產生了指數衰減的追蹤效果,一開始追得快,越接近越慢,看起來就像有彈性一樣。
如果用 watch 直接設定位置,終點會瞬間跳到滑鼠那邊,完全沒有「拉橡皮筋」的感覺。
長度限制與抖動效果
Section titled “長度限制與抖動效果”拉太長不好看,所以加了 maxThumbLength 上限。當超過上限時,按比例縮小並加入隨機 noise:
if (length > maxThumbLength) { const noise = Math.random() * 4 const scaleFactor = maxThumbLength / length newPoint.x = newPoint.x * scaleFactor + noise newPoint.y = newPoint.y * scaleFactor + noise}noise 讓握把在極限長度時微微抖動,暗示「已經拉到極限了,再拉也沒用」。
線寬隨長度變化
Section titled “線寬隨長度變化”const strokeWidth = computed(() => mapNumber( length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),))mapNumber 做線性映射:長度為 0 時,線寬等於 thumbSize(看起來是圓形);拉到最長時,線寬縮到很細。就像真的橡皮筋越拉越細。
Step 8:彈簧震盪
Section titled “Step 8:彈簧震盪”上一步的握把雖然會拉伸,但線條太「硬」了。真實的橡皮筋被拉動時會晃啊晃的,有種 Q 彈的感覺。
這一步加入控制點彈簧物理,讓貝茲曲線的彎曲程度產生震盪效果:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} | 停用:{{ disabled }} </div>
<label class="flex items-center gap-2 text-sm"> <input v-model="disabled" type="checkbox" > 停用滑動條 </label>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<svg :width="svgSize" :height="svgSize" :viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`" :style="svgStyle" class="thumb-svg pointer-events-none absolute" > <path :d="pathD" :stroke="thumbColor" :stroke-width="strokeWidth" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" /> </svg> </div> </div></template>
<script setup lang="ts">import type { CSSProperties } from 'vue'import { useIntervalFn, useMouseInElement, useMousePressed, useWindowSize,} from '@vueuse/core'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const disabled = ref(false)const min = 0const max = 100const thumbSize = 20const thumbColor = '#34c6eb'const maxThumbLength = 200
const windowSize = reactive(useWindowSize())const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
function getValue(mouseRatio: number) { const rawValue = min + (mouseRatio / 100) * (max - min) return Math.round(rawValue)}
watch([mouseRatio, isHeld], () => { if (!isHeld.value || disabled.value) return modelValue.value = getValue(mouseRatio.value)})
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) { return Math.sqrt(x * x + y * y)}
function mapNumber( current: number, inMin: number, inMax: number, outMin: number, outMax: number,) { const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))}
// --- SVG 握把 ---
const mousePosition = computed(() => ({ x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100), y: mouseInSlider.elementY - 24,}))
const endPoint = ref({ x: 0, y: 0 })const length = computed(() => getVectorLength(endPoint.value))
/** 處理終點動畫 */useIntervalFn(() => { if (!isHeld.value || !disabled.value) 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 pointLength = getVectorLength(newPoint) if (pointLength > maxThumbLength) { const noise = Math.random() * 4 const scaleFactor = maxThumbLength / pointLength newPoint.x = newPoint.x * scaleFactor + noise newPoint.y = newPoint.y * scaleFactor + noise }
endPoint.value = newPoint}, 15)
watch([() => disabled.value, () => isHeld.value], () => { if (!disabled.value || !isHeld.value) { endPoint.value = { x: 0, y: 0 } }})
// --- 控制點(彈簧震盪) ---
const ctrlPoint = ref({ x: 0, y: 0 })let ctrlPointVelocity = { x: 0, y: 0 }
/** 彈性係數:拉越長震動越快 */const ctrlPointStiffness = computed(() => mapNumber( length.value, 0, maxThumbLength, 2, 3,))
/** 阻尼:越短停得越快 */const ctrlPointDamping = computed(() => mapNumber( length.value, 0, maxThumbLength, 0.7, 0.9,))
/** 處理控制點彈簧物理 */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 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)
// --- 繪製 ---
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}`})
const strokeWidth = computed(() => mapNumber( length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),))
const svgSize = ref(thumbSize * 1.5)
/** 動態調整 SVG 尺寸 */useIntervalFn(() => { let newSize = thumbSize
if (isHeld.value && disabled.value) { newSize = Math.max( Math.abs(mousePosition.value.x), Math.abs(mousePosition.value.y), thumbSize, ) * 2
if (newSize > maxThumbLength * 2) { newSize = maxThumbLength * 2 } }
newSize += 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>(() => ({ left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb-svg top: 50% transform: translate(-50%, -50%) will-change: left width height transition-property: left transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)</style>彈簧物理模型
Section titled “彈簧物理模型”控制點的震盪用的是經典的彈簧-阻尼系統(Spring-Damper System),公式很簡單:
加速度 = 彈性係數 × 位移速度 = 速度 × 阻尼率位置 = 位置 + 速度翻成程式碼:
// 彈力:離目標越遠,拉力越大ctrlPointVelocity.x += ctrlPointStiffness.value * dxctrlPointVelocity.y += ctrlPointStiffness.value * dy
// 阻尼:每幀速度衰減一點點ctrlPointVelocity.x *= ctrlPointDamping.valuectrlPointVelocity.y *= ctrlPointDamping.value
// 更新位置ctrlPoint.value.x += ctrlPointVelocity.xctrlPoint.value.y += ctrlPointVelocity.y彈性係數和阻尼率不是固定值,而是根據目前握把長度動態映射:
// 拉越長 → 彈性係數越大 → 震動越快const ctrlPointStiffness = computed(() => mapNumber( length.value, 0, maxThumbLength, 2, 3,))
// 拉越長 → 阻尼越大 → 震動持續越久const ctrlPointDamping = computed(() => mapNumber( length.value, 0, maxThumbLength, 0.7, 0.9,))這讓握把在短的時候幾乎不晃,拉長後才明顯震盪,符合物理直覺。
控制點的目標位置
Section titled “控制點的目標位置”控制點的目標不是滑鼠位置,而是終點的中點:
const targetPoint = { x: endPoint.value.x / 2, y: endPoint.value.y / 2,}因為貝茲曲線的控制點在中間,控制點追到終點中間,曲線才會自然地弧向外側。
動態 SVG 尺寸
Section titled “動態 SVG 尺寸”握把被拉長時,SVG 元素的尺寸要跟著擴大,否則超出的部分會被裁切:
useIntervalFn(() => { let newSize = thumbSize if (isHeld.value && disabled.value) { newSize = Math.max( Math.abs(mousePosition.value.x), Math.abs(mousePosition.value.y), thumbSize, ) * 2 } newSize += thumbSize * 1.5 // 安全係數
// 長大要快,縮小要慢,避免閃爍 svgSize.value += delta > 0 ? delta : delta / 10}, 15)「長大要快,縮小要慢」是個小巧思:擴大時立刻跟上(避免裁切),縮小時慢慢來(避免閃爍)。
Step 9:回彈動畫
Section titled “Step 9:回彈動畫”上一步的握把在放開後會直接「消失」(終點瞬間回到 0,0)。加上 anime.js 的 easeOutElastic 緩動函式,讓放開的瞬間有橡皮筋回彈的效果:
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue }} | 停用:{{ disabled }} </div>
<label class="flex items-center gap-2 text-sm"> <input v-model="disabled" type="checkbox" > 停用滑動條(拖動後放開,觀察回彈效果) </label>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <div class="track rounded-full" />
<svg :width="svgSize" :height="svgSize" :viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`" :style="svgStyle" class="thumb-svg pointer-events-none absolute" > <path :d="pathD" :stroke="thumbColor" :stroke-width="strokeWidth" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" /> </svg> </div> </div></template>
<script setup lang="ts">import type { CSSProperties } from 'vue'import { useIntervalFn, useMouseInElement, useMousePressed, useWindowSize,} from '@vueuse/core'import anime from 'animejs'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const disabled = ref(false)const min = 0const max = 100const thumbSize = 20const thumbColor = '#34c6eb'const maxThumbLength = 200
const windowSize = reactive(useWindowSize())const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
function getValue(mouseRatio: number) { const rawValue = min + (mouseRatio / 100) * (max - min) return Math.round(rawValue)}
watch([mouseRatio, isHeld], () => { if (!isHeld.value || disabled.value) return modelValue.value = getValue(mouseRatio.value)})
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) { return Math.sqrt(x * x + y * y)}
function mapNumber( current: number, inMin: number, inMax: number, outMin: number, outMax: number,) { const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))}
// --- SVG 握把 ---
const mousePosition = computed(() => ({ x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100), y: mouseInSlider.elementY - 24,}))
const endPoint = ref({ x: 0, y: 0 })const length = computed(() => getVectorLength(endPoint.value))
useIntervalFn(() => { if (!isHeld.value || !disabled.value) 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 pointLength = getVectorLength(newPoint) if (pointLength > maxThumbLength) { const noise = Math.random() * 4 const scaleFactor = maxThumbLength / pointLength newPoint.x = newPoint.x * scaleFactor + noise newPoint.y = newPoint.y * scaleFactor + noise }
endPoint.value = newPoint}, 15)
/** 放開時,播放回彈動畫 */watch(isHeld, (value) => { if (value) return
anime({ targets: endPoint.value, x: 0, y: 0, easing: 'easeOutElastic', duration: 300, })})
/** disabled 解除時重設 */watch(() => disabled.value, () => { if (!disabled.value) { endPoint.value = { x: 0, y: 0 } }})
// --- 控制點(彈簧震盪) ---
const ctrlPoint = ref({ x: 0, y: 0 })let ctrlPointVelocity = { x: 0, y: 0 }
const ctrlPointStiffness = computed(() => mapNumber( length.value, 0, maxThumbLength, 2, 3,))
const ctrlPointDamping = computed(() => mapNumber( length.value, 0, maxThumbLength, 0.7, 0.9,))
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
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)
// --- 繪製 ---
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}`})
const strokeWidth = computed(() => mapNumber( length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),))
const svgSize = ref(thumbSize * 1.5)
useIntervalFn(() => { let newSize = thumbSize
if (isHeld.value && disabled.value) { newSize = Math.max( Math.abs(mousePosition.value.x), Math.abs(mousePosition.value.y), thumbSize, ) * 2 if (newSize > maxThumbLength * 2) newSize = maxThumbLength * 2 }
newSize += thumbSize * 1.5
const delta = newSize - svgSize.value if (Math.abs(delta) < 0.01) { svgSize.value = newSize return }
svgSize.value += delta > 0 ? delta : delta / 10}, 15)
const svgStyle = computed<CSSProperties>(() => ({ left: `${disabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.thumb-svg top: 50% transform: translate(-50%, -50%) will-change: left width height transition-property: left transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)</style>anime.js 回彈
Section titled “anime.js 回彈”watch(isHeld, (value) => { if (value) return
anime({ targets: endPoint.value, x: 0, y: 0, easing: 'easeOutElastic', duration: 300, })})easeOutElastic 是 anime.js 內建的彈性緩動曲線。它會讓數值「衝過」目標值再彈回來,重複幾次後穩定。搭配終點從 (x, y) 回到 (0, 0) 的動畫,看起來就像橡皮筋鬆手後彈回去一樣。
為什麼是 300ms?
Section titled “為什麼是 300ms?”duration: 300 是刻意設得很短的。太長的話回彈動畫會和下一次拖動衝突,使用者可能在動畫還沒播完就又開始拖了。300ms 足夠看到回彈效果,又不會妨礙操作。
為什麼不用 CSS transition?
Section titled “為什麼不用 CSS transition?”CSS 的 transition-timing-function 沒有 elastic 緩動。雖然可以用 cubic-bezier 模擬,但效果遠不如 anime.js 的 elastic 自然。而且 anime.js 直接操作 JavaScript 物件(endPoint.value),不需要透過 DOM 屬性,和 Vue 的響應式系統整合得很好。
Step 10:停用範圍
Section titled “Step 10:停用範圍”前面所有步驟的 disabled 都是「全部停用」。但實際使用場景更常見的是部分停用,例如免費方案只能選 1~3 隻魚,高級方案才能選更多。
調整下方的 minDisabled 和 maxDisabled,觀察握把碰到邊界時的行為:
<template> <div class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6"> <div class="text-sm"> 目前數值:{{ modelValue.toFixed(1) }} | 可用範圍:{{ minDisabled }} ~ {{ maxDisabled }} </div>
<div class="flex gap-4 text-sm"> <label> minDisabled: <input v-model.number="minDisabled" type="number" class="w-16 border rounded px-2 py-1" :min="0" :max="maxDisabled" > </label> <label> maxDisabled: <input v-model.number="maxDisabled" type="number" class="w-16 border rounded px-2 py-1" :min="minDisabled" :max="100" > </label> </div>
<div ref="sliderRef" class="slider relative py-3" :class="isHeld ? 'cursor-grabbing' : 'cursor-grab'" @mousedown="(e) => e.preventDefault()" @touchstart="(e) => e.preventDefault()" > <!-- 可用範圍指示條 --> <div class="track rounded-full" /> <div class="active-track absolute top-3 rounded-full" :style="{ left: `${minDisabled}%`, width: `${maxDisabled - minDisabled}%`, }" />
<svg :width="svgSize" :height="svgSize" :viewBox="`${svgSize / -2} ${svgSize / -2} ${svgSize} ${svgSize}`" :style="svgStyle" class="thumb-svg pointer-events-none absolute" > <path :d="pathD" :stroke="thumbColor" :stroke-width="strokeWidth" stroke-linejoin="round" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" /> </svg> </div> </div></template>
<script setup lang="ts">import type { CSSProperties } from 'vue'import { useIntervalFn, useMouseInElement, useMousePressed, useWindowSize,} from '@vueuse/core'import anime from 'animejs'import { computed, reactive, ref, watch } from 'vue'
const modelValue = ref(50)const min = 0const max = 100const step = 1const thumbSize = 20const thumbColor = '#34c6eb'const maxThumbLength = 200const minDisabled = ref(20)const maxDisabled = ref(80)
const windowSize = reactive(useWindowSize())const sliderRef = ref<HTMLDivElement>()const mouseInSlider = reactive(useMouseInElement(sliderRef))
const { pressed: isHeld } = useMousePressed({ target: sliderRef,})
const ratio = computed(() => { const value = modelValue.value / (max - min) * 100 return Math.max(0, Math.min(100, value))})
const mouseRatio = computed(() => { const value = mouseInSlider.elementX / mouseInSlider.elementWidth * 100 return Math.max(0, Math.min(100, value))})
function getValue(mouseRatio: number) { const rawValue = min + (mouseRatio / 100) * (max - min) return Math.round(rawValue / step) * step}
/** 當 disabled 範圍變動時,強制將數值限制在範圍內 */watch([minDisabled, maxDisabled], () => { modelValue.value = Math.max( minDisabled.value, Math.min(maxDisabled.value, modelValue.value), )})
/** 拖動方向 */const draggingDirection = computed(() => { return mouseRatio.value > ratio.value ? 1 : -1})
const disabledValue = ref(false)const isDisabled = computed(() => disabledValue.value)
/** 拖動時更新數值,碰到 disabled 範圍邊界就停止 */watch([mouseRatio, isHeld], () => { if (!isHeld.value) return
const targetValue = getValue(mouseRatio.value) let currentValue = modelValue.value
if (targetValue === currentValue) return
const direction = draggingDirection.value const stepDir = step * direction
while (true) { if ( (direction === -1 && currentValue < minDisabled.value) || (direction === 1 && currentValue > maxDisabled.value) ) { disabledValue.value = true return }
modelValue.value = currentValue disabledValue.value = false
if ( (direction === 1 && currentValue >= targetValue) || (direction === -1 && currentValue <= targetValue) ) { return }
currentValue += stepDir }}, { deep: true })
// --- 向量工具 ---
function getVectorLength({ x, y }: { x: number; y: number }) { return Math.sqrt(x * x + y * y)}
function mapNumber( current: number, inMin: number, inMax: number, outMin: number, outMax: number,) { const mapped = ((current - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin return Math.max(Math.min(mapped, Math.max(outMin, outMax)), Math.min(outMin, outMax))}
// --- SVG 握把 ---
const mousePosition = computed(() => ({ x: mouseInSlider.elementX - mouseInSlider.elementWidth * (ratio.value / 100), y: mouseInSlider.elementY - 24,}))
const endPoint = ref({ x: 0, y: 0 })const length = computed(() => getVectorLength(endPoint.value))
useIntervalFn(() => { if (!isHeld.value || !isDisabled.value) 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 pointLength = getVectorLength(newPoint) if (pointLength > maxThumbLength) { const noise = Math.random() * 4 const scaleFactor = maxThumbLength / pointLength newPoint.x = newPoint.x * scaleFactor + noise newPoint.y = newPoint.y * scaleFactor + noise }
endPoint.value = newPoint}, 15)
watch(isHeld, (value) => { if (value) return anime({ targets: endPoint.value, x: 0, y: 0, easing: 'easeOutElastic', duration: 300, })})
watch(() => isDisabled.value, () => { if (!isDisabled.value) { endPoint.value = { x: 0, y: 0 } }})
// --- 控制點 ---
const ctrlPoint = ref({ x: 0, y: 0 })let ctrlPointVelocity = { x: 0, y: 0 }
const ctrlPointStiffness = computed(() => mapNumber( length.value, 0, maxThumbLength, 2, 3,))const ctrlPointDamping = computed(() => mapNumber( length.value, 0, maxThumbLength, 0.7, 0.9,))
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
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)
// --- 繪製 ---
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}`})
const strokeWidth = computed(() => mapNumber( length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),))
const svgSize = ref(thumbSize * 1.5)
useIntervalFn(() => { let newSize = thumbSize if (isHeld.value && isDisabled.value) { newSize = Math.max( Math.abs(mousePosition.value.x), Math.abs(mousePosition.value.y), thumbSize, ) * 2 if (newSize > maxThumbLength * 2) newSize = maxThumbLength * 2 } newSize += thumbSize * 1.5
const delta = newSize - svgSize.value if (Math.abs(delta) < 0.01) { svgSize.value = newSize return } svgSize.value += delta > 0 ? delta : delta / 10}, 15)
const svgStyle = computed<CSSProperties>(() => ({ left: `${isDisabled.value ? ratio.value : (isHeld.value ? mouseRatio.value : ratio.value)}%`, transitionDuration: isHeld.value ? '0s' : '0.2s',}))</script>
<style scoped lang="sass">.track height: 8px background: #EEE border-radius: 9999px
.active-track height: 8px background: rgba(52, 198, 235, 0.2)
.thumb-svg top: 50% transform: translate(-50%, -50%) will-change: left width height transition-property: left transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1)</style>逐步前進的拖動邏輯
Section titled “逐步前進的拖動邏輯”部分停用的拖動邏輯比全部停用複雜得多。數值不能直接跳到滑鼠位置,而是要一步一步走,碰到邊界就停下來:
watch([mouseRatio, isHeld], () => { const targetValue = getValue(mouseRatio.value) let currentValue = modelValue.value const direction = draggingDirection.value const stepDir = step * direction
while (true) { // 碰到邊界,啟動 disabled if ( (direction === -1 && currentValue < minDisabled.value) || (direction === 1 && currentValue > maxDisabled.value) ) { disabledValue.value = true return }
modelValue.value = currentValue disabledValue.value = false
// 已經到達目標 if ( (direction === 1 && currentValue >= targetValue) || (direction === -1 && currentValue <= targetValue) ) { return }
currentValue += stepDir }})為什麼是 while 迴圈?
Section titled “為什麼是 while 迴圈?”你可能會想:直接把 modelValue 設成 targetValue 再做邊界檢查不就好了?
問題在於 step 吸附。如果 step 是 5,targetValue 可能是 15,而 minDisabled 是 12。直接設成 15 會跳過 12 這個邊界。逐步走才能精確地在邊界停下來。
動態 disabled 狀態
Section titled “動態 disabled 狀態”元件不用一個固定的 disabled prop,而是用 disabledValue 動態判斷:
const disabledValue = ref(false)const isDisabled = computed(() => disabledValue.value)拖動碰到邊界時 disabledValue 變成 true,握把開始拉伸。往回拖時 disabledValue 變成 false,回到正常模式。
範圍變動時的保護
Section titled “範圍變動時的保護”watch([minDisabled, maxDisabled], () => { modelValue.value = Math.max( minDisabled.value, Math.min(maxDisabled.value, modelValue.value), )})如果外部改變了 disabled 範圍,目前數值可能落在新範圍之外。這個 watch 確保數值永遠在合法範圍內。
恭喜你走完了所有步驟!讓我們回顧一下整個開發歷程:
| 步驟 | 概念 | 關鍵技術 |
|---|---|---|
| 1 | 靜態軌道 | HTML + CSS 基礎結構 |
| 2 | 追蹤滑鼠 | useMouseInElement |
| 3 | 數值映射 | ratio 百分比計算 |
| 4 | 拖動互動 | useMousePressed + watch |
| 5 | Step 吸附 | Math.round + 浮點數精度修正 |
| 6 | SVG 握把 | SVG path + Q 貝茲曲線指令 |
| 7 | 握把拉伸 | 終點追蹤 + useIntervalFn 指數衰減 |
| 8 | 彈簧震盪 | 彈簧-阻尼物理模型 + 動態 SVG 尺寸 |
| 9 | 回彈動畫 | anime.js easeOutElastic |
| 10 | 停用範圍 | 逐步前進邏輯 + 動態 disabled |
最終的元件還拆分成了主元件與握把子元件,加入了 v-model、thumbColor、thumbSize 等 props。
完整元件請見:固執的滑動條 🐟
最後,用分層爆炸圖來回顧整個元件的 DOM 堆疊結構:
整個元件由 4 層 DOM 節點堆疊而成。爆炸圖由上到下依照視覺前後順序排列,最上面是使用者看到的最前層,最下面是最底層:
最前層:path(貝茲曲線)
Section titled “最前層:path(貝茲曲線)”使用者實際看到的握把。用 SVG 的 Q 指令畫出一條二次貝茲曲線,由三個關鍵點決定形狀:
- 起點
(0, 0):固定在 SVG 中心,也就是握把的圓形本體 - 終點
endPoint:disabled 時朝滑鼠方向延伸,用指數衰減追蹤滑鼠位置 - 控制點
ctrlPoint:追蹤終點中點,透過彈簧物理產生震盪效果
線寬(strokeWidth)會隨長度動態變化,越拉越細,模擬橡皮筋的視覺效果。
第 2 層:svg.thumb-svg(SVG 容器)
Section titled “第 2 層:svg.thumb-svg(SVG 容器)”握把子元件的根節點。用 position: absolute 疊在軌道上方,left 值會根據 ratio 或 mouseRatio 動態切換:
- 拖動時跟著
mouseRatio(即時回饋) - 放開後回到
ratio(數值位置)
SVG 的 width、height、viewBox 也是動態的,被拉伸時自動擴大,避免曲線被裁切。
第 3 層:div.track(軌道)
Section titled “第 3 層:div.track(軌道)”純視覺元素。一條灰色圓角條,代表可拖動的範圍。沒有任何邏輯,CSS 就搞定。
最底層:div.slider-stubborn(外層容器)
Section titled “最底層:div.slider-stubborn(外層容器)”最外層的定位參考點。負責攔截 mousedown、touchstart 等事件防止預設行為,同時透過 VueUse 的 composable 計算出所有核心狀態:
mouseRatio:滑鼠在軌道上的百分比位置isHeld:滑鼠是否正在按住sliderSize:軌道的實際尺寸(px)
這些狀態會透過 props 傳給內層的握把子元件。