教學:固執的滑動條 tutorial
從零開始,一步步打造一個拖不動的固執滑動條。ᕕ( ゚ ∀。)ᕗ
完成品請見 固執的滑動條元件。
前言
想像你有一個滑動條,使用者試著把數值拖到超出允許範圍,結果握把被拉長了卻死都不肯移動,就像在拉一條很有彈性的橡皮筋。(ノ>ω<)ノ
這就是固執滑動條的核心概念:當數值碰到禁止範圍時,握把會像橡皮筋一樣被拉伸,但死也不讓你過去。
接下來我們將把這個元件的開發過程拆解為 10 個小步驟,每一步都只加入一個新概念。
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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
結構說明
整個元件的 HTML 骨架從這一步就定型了,分為兩層:
- 外框
div.slider:作為整個滑動條的定位參考點(position: relative)。握把會用absolute定位在這一層。 - 軌道
div.track:灰色的橫條,視覺上代表可拖動的範圍。
樣式細節
- 握把用
position: absolute+top: 50%+left: 50%定位在軌道中央 transform: translate(-50%, -50%)讓握把的中心點對齊位置,而非左上角- 軌道高度
8px搭配border-radius: 9999px形成圓角膠囊形狀
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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
mouseRatio 的計算
整個滑動條最核心的概念就是比例(ratio)。我們將滑鼠的 X 座標轉換成 0~100 的百分比:
const mouseRatio = computed(() => {
const ratio = mouseInSlider.elementX / mouseInSlider.elementWidth * 100
return Math.max(0, Math.min(100, ratio))
})2
3
4
Math.max(0, Math.min(100, ratio)) 確保比例不會超出 0~100 的範圍,即使滑鼠移到元素外面也不會出問題。
回傳值說明
| 回傳值 | 說明 |
|---|---|
elementX | 滑鼠相對於元素左邊界的 X 座標 |
elementWidth | 元素的寬度,用來計算百分比 |
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
ref="sliderRef"
class="slider relative cursor-pointer py-3"
>
<div class="track rounded-full" />
<!-- 顯示數值位置的握把 -->
<div
class="thumb"
:style="{ left: `${ratio}%` }"
/>
</div>
<input
v-model.number="modelValue"
type="number"
class="w-20 border rounded px-2 py-1 text-sm"
:min="0"
:max="100"
>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const modelValue = ref(50)
const min = 0
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
ratio:數值到位置的映射
ratio 是把數值轉換成百分比位置的關鍵:
const ratio = computed(() => {
const value = modelValue.value / (max - min) * 100
return Math.max(0, Math.min(100, value))
})2
3
4
例如 min=0, max=100, modelValue=50,ratio 就是 50%,握把會在軌道正中間。
ratio 與 mouseRatio 的差異
這兩個比例容易搞混,但各司其職:
- ratio:代表「目前數值」在軌道上的位置。由
modelValue決定。 - mouseRatio:代表「滑鼠」在軌道上的位置。由滑鼠座標決定。
正常拖動時,握把跟著 mouseRatio 走(即時回饋);放開後,握把回到 ratio 的位置(對齊數值)。
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 = 0
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
拖動原理
拖動的核心邏輯很簡單:當滑鼠按住時,將滑鼠位置換算成數值。
const { pressed: isHeld } = useMousePressed({
target: sliderRef,
})
watch([mouseRatio, isHeld], () => {
if (!isHeld.value)
return
modelValue.value = getValue(mouseRatio.value)
})2
3
4
5
6
7
8
9
getValue 負責把百分比轉回實際數值:
function getValue(ratio: number) {
const rawValue = min + (ratio / 100) * (max - min)
return Math.round(rawValue)
}2
3
4
為什麼握把位置要區分 isHeld?
const thumbStyle = computed(() => ({
left: `${isHeld.value ? mouseRatio.value : ratio.value}%`,
transitionDuration: isHeld.value ? '0s' : '0.2s',
}))2
3
4
- 按住時:握把跟著
mouseRatio(滑鼠位置),transition設為0s確保零延遲 - 放開後:握把回到
ratio(數值位置),transition設為0.2s產生平滑過渡
這樣拖動時感覺是即時的,放開後又有優雅的對齊動畫。
防止預設行為
@mousedown="(e) => e.preventDefault()"
@touchstart="(e) => e.preventDefault()"2
不加這兩行的話,拖動時瀏覽器會觸發文字選取或頁面滾動,影響操作體驗。
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="border rounded px-2 py-1"
>
<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 = 0
const max = 100
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
吸附原理
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)
}2
3
4
例如 step=5, rawValue=17:Math.round(17 / 5) * 5 = Math.round(3.4) * 5 = 3 * 5 = 15。
浮點數精度問題
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))
}2
3
4
5
6
7
8
9
10
11
step=0.1 → stepPrecision=1 → toFixed(1) → "50.1" → Number("50.1") → 50.1。精度問題解決。
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 = 0
const max = 100
const thumbSize = 20
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
為什麼用 SVG path?
一般的 HTML 元素只能畫矩形或圓形。但握把被拉伸時,需要:
- 一條可以任意彎曲的線段
- 線段的粗細可以動態變化
- 線段的端點要是圓頭的
SVG 的 path 元素搭配 Q(二次貝茲曲線)指令,完美滿足這些需求。
path 的 Q 指令
M0 0 Q cx cy, ex eyM0 0:起點,固定在 SVG 中心Q cx cy, ex ey:二次貝茲曲線,cx cy是控制點,ex ey是終點
目前控制點和終點都在 (0, 0),所以看起來就是一個點。下一步會讓終點跟著滑鼠移動。
stroke 相關屬性
<path
:stroke-width="thumbSize"
stroke-linejoin="round"
stroke-linecap="round"
fill="none"
vector-effect="non-scaling-stroke"
/>2
3
4
5
6
7
stroke-linecap="round":線段端點是圓頭的,所以即使是一個點也會顯示為圓形vector-effect="non-scaling-stroke":線段粗細不會受到 viewBox 縮放影響fill="none":只要線條,不要填充
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 = 0
const max = 100
const thumbSize = 20
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
拉伸的核心邏輯
拉伸效果的關鍵是控制 SVG path 的終點(endPoint):
const endPoint = ref({ x: 0, y: 0 })正常狀態下,終點在 (0, 0),path 只是一個圓點。disabled 時,終點朝滑鼠方向延伸,path 就被「拉長」了。
為什麼用 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)2
3
4
5
6
7
8
9
10
終點不是直接跳到滑鼠位置,而是每 15ms 移動一半的距離(/ 2)。這產生了指數衰減的追蹤效果,一開始追得快,越接近越慢,看起來就像有彈性一樣。
如果用 watch 直接設定位置,終點會瞬間跳到滑鼠那邊,完全沒有「拉橡皮筋」的感覺。
長度限制與抖動效果
拉太長不好看,所以加了 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
}2
3
4
5
6
noise 讓握把在極限長度時微微抖動,暗示「已經拉到極限了,再拉也沒用」。
線寬隨長度變化
const strokeWidth = computed(() => mapNumber(
length.value, 0, maxThumbLength, thumbSize, Math.max(thumbSize * 0.1, 5),
))2
3
mapNumber 做線性映射:長度為 0 時,線寬等於 thumbSize(看起來是圓形);拉到最長時,線寬縮到很細。就像真的橡皮筋越拉越細。
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 = 0
const max = 100
const thumbSize = 20
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
彈簧物理模型
控制點的震盪用的是經典的彈簧-阻尼系統(Spring-Damper System),公式很簡單:
加速度 = 彈性係數 × 位移
速度 = 速度 × 阻尼率
位置 = 位置 + 速度2
3
翻成程式碼:
// 彈力:離目標越遠,拉力越大
ctrlPointVelocity.x += ctrlPointStiffness.value * dx
ctrlPointVelocity.y += ctrlPointStiffness.value * dy
// 阻尼:每幀速度衰減一點點
ctrlPointVelocity.x *= ctrlPointDamping.value
ctrlPointVelocity.y *= ctrlPointDamping.value
// 更新位置
ctrlPoint.value.x += ctrlPointVelocity.x
ctrlPoint.value.y += ctrlPointVelocity.y2
3
4
5
6
7
8
9
10
11
參數映射
彈性係數和阻尼率不是固定值,而是根據目前握把長度動態映射:
// 拉越長 → 彈性係數越大 → 震動越快
const ctrlPointStiffness = computed(() => mapNumber(
length.value, 0, maxThumbLength, 2, 3,
))
// 拉越長 → 阻尼越大 → 震動持續越久
const ctrlPointDamping = computed(() => mapNumber(
length.value, 0, maxThumbLength, 0.7, 0.9,
))2
3
4
5
6
7
8
9
這讓握把在短的時候幾乎不晃,拉長後才明顯震盪,符合物理直覺。
控制點的目標位置
控制點的目標不是滑鼠位置,而是終點的中點:
const targetPoint = {
x: endPoint.value.x / 2,
y: endPoint.value.y / 2,
}2
3
4
因為貝茲曲線的控制點在中間,控制點追到終點中間,曲線才會自然地弧向外側。
動態 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)2
3
4
5
6
7
8
9
10
11
12
13
14
「長大要快,縮小要慢」是個小巧思:擴大時立刻跟上(避免裁切),縮小時慢慢來(避免閃爍)。
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 = 0
const max = 100
const thumbSize = 20
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
anime.js 回彈
watch(isHeld, (value) => {
if (value)
return
anime({
targets: endPoint.value,
x: 0,
y: 0,
easing: 'easeOutElastic',
duration: 300,
})
})2
3
4
5
6
7
8
9
10
11
12
easeOutElastic 是 anime.js 內建的彈性緩動曲線。它會讓數值「衝過」目標值再彈回來,重複幾次後穩定。搭配終點從 (x, y) 回到 (0, 0) 的動畫,看起來就像橡皮筋鬆手後彈回去一樣。
為什麼是 300ms?
duration: 300 是刻意設得很短的。太長的話回彈動畫會和下一次拖動衝突,使用者可能在動畫還沒播完就又開始拖了。300ms 足夠看到回彈效果,又不會妨礙操作。
為什麼不用 CSS transition?
CSS 的 transition-timing-function 沒有 elastic 緩動。雖然可以用 cubic-bezier 模擬,但效果遠不如 anime.js 的 elastic 自然。而且 anime.js 直接操作 JavaScript 物件(endPoint.value),不需要透過 DOM 屬性,和 Vue 的響應式系統整合得很好。
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 = 0
const max = 100
const step = 1
const thumbSize = 20
const thumbColor = '#34c6eb'
const maxThumbLength = 200
const 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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
逐步前進的拖動邏輯
部分停用的拖動邏輯比全部停用複雜得多。數值不能直接跳到滑鼠位置,而是要一步一步走,碰到邊界就停下來:
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
}
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
為什麼是 while 迴圈?
你可能會想:直接把 modelValue 設成 targetValue 再做邊界檢查不就好了?
問題在於 step 吸附。如果 step 是 5,targetValue 可能是 15,而 minDisabled 是 12。直接設成 15 會跳過 12 這個邊界。逐步走才能精確地在邊界停下來。
動態 disabled 狀態
元件不用一個固定的 disabled prop,而是用 disabledValue 動態判斷:
const disabledValue = ref(false)
const isDisabled = computed(() => disabledValue.value)2
拖動碰到邊界時 disabledValue 變成 true,握把開始拉伸。往回拖時 disabledValue 變成 false,回到正常模式。
範圍變動時的保護
watch([minDisabled, maxDisabled], () => {
modelValue.value = Math.max(
minDisabled.value,
Math.min(maxDisabled.value, modelValue.value),
)
})2
3
4
5
6
如果外部改變了 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(貝茲曲線)
使用者實際看到的握把。用 SVG 的 Q 指令畫出一條二次貝茲曲線,由三個關鍵點決定形狀:
- 起點
(0, 0):固定在 SVG 中心,也就是握把的圓形本體 - 終點
endPoint:disabled 時朝滑鼠方向延伸,用指數衰減追蹤滑鼠位置 - 控制點
ctrlPoint:追蹤終點中點,透過彈簧物理產生震盪效果
線寬(strokeWidth)會隨長度動態變化,越拉越細,模擬橡皮筋的視覺效果。
第 2 層:svg.thumb-svg(SVG 容器)
握把子元件的根節點。用 position: absolute 疊在軌道上方,left 值會根據 ratio 或 mouseRatio 動態切換:
- 拖動時跟著
mouseRatio(即時回饋) - 放開後回到
ratio(數值位置)
SVG 的 width、height、viewBox 也是動態的,被拉伸時自動擴大,避免曲線被裁切。
第 3 層:div.track(軌道)
純視覺元素。一條灰色圓角條,代表可拖動的範圍。沒有任何邏輯,CSS 就搞定。
最底層:div.slider-stubborn(外層容器)
最外層的定位參考點。負責攔截 mousedown、touchstart 等事件防止預設行為,同時透過 VueUse 的 composable 計算出所有核心狀態:
mouseRatio:滑鼠在軌道上的百分比位置isHeld:滑鼠是否正在按住sliderSize:軌道的實際尺寸(px)
這些狀態會透過 props 傳給內層的握把子元件。