彈弓滑動條 slider
拉一拉就射出去了 =͟͟͞͞( •̀д•́)
技術關鍵字
| 名稱 | 描述 |
|---|---|
| SVG | 可在 DOM 中使用的圖形格式,適合複雜圖形和動畫,配合 Vue v-bind 可以實現基於資料的動態效果 |
| 物理模擬 | 模擬真實世界物理現象,如重力、碰撞、速度等物理效果 |
| 彈簧阻尼系統 | 以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋 |
| 向量計算 | 處理方向、加速度、速度等等數學運算 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
使用範例
基本用法
上下拉成 V 字形,放開握把直接飛出去 =͟͟͞͞( •̀д•́)
飛行途中沒有阻力,撞到視窗邊緣反彈,只有碰到原軌道才停下來。
查看範例原始碼
<template>
<div class="w-full flex flex-col items-center py-24">
<div class="max-w-[180px] w-full flex flex-col gap-4">
<div>value: {{ value }}</div>
<slider-slingshot
v-model="value"
class="w-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SliderSlingshot from '../slider-slingshot.vue'
const value = ref(50)
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
贊助鱈魚
開啟 slideLocked 後,握把綁在橡皮筋上的那個點(繫點)釘在目前數值上, 左右拉只會拉緊或鬆垮,金額完全交給落點決定。
想調低金額請憑實力,歡迎挑戰命中 0 元 ( ˘ω˘ )
查看範例原始碼
<template>
<div class="w-full flex flex-col items-center py-24">
<div class="max-w-[300px] w-full flex flex-col gap-5">
<div class="flex items-baseline justify-between">
<span class="text-sm op-60">{{ t('title') }}</span>
<span class="text-2xl font-bold">{{ t('currency') }}{{ value }}</span>
</div>
<slider-slingshot
v-model="value"
slide-locked
class="w-full"
:max="500"
:step="5"
thumb-color="#f2b705"
track-color="#f7e7b0"
@land="handleLand"
/>
<div class="min-h-6 text-sm op-70">
<transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-150 ease-in"
enter-from-class="!op-0 translate-y-2"
leave-to-class="!op-0 -translate-y-2"
>
<span
:key="reactionKey"
class="block"
>
{{ t(`reaction.${reactionKey}`) }}
</span>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SliderSlingshot from '../slider-slingshot.vue'
const { t } = useI18n()
const value = ref(500)
const reactionKey = ref('waiting')
function handleLand(amount: number) {
if (amount === 0) {
reactionKey.value = 'zero'
return
}
if (amount < 100) {
reactionKey.value = 'small'
return
}
if (amount < 250) {
reactionKey.value = 'normal'
return
}
if (amount < 450) {
reactionKey.value = 'large'
return
}
reactionKey.value = 'huge'
}
</script>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
原理
握把有五種狀態:停在軌道上(idle)、拉扯中(pulling)、橡皮筋推著加速(launching)、 脫離橡皮筋後飛行(flying)、召回中(recalling)。整份邏輯的核心就是狀態切換。
以下把握把脫離橡皮筋稱為「脫手」,那是使用者放開指標之後才發生的事,兩者的時間點不同。
V 字形軌道
軌道其實是兩條 SVG path,左固定端到握把一條,握把到右固定端一條,各是一段二次貝茲曲線。 兩段的粗細要各算各,所以分開畫。 這條軌道同時就是橡皮筋,講幾何時叫軌道,講張力時叫橡皮筋:
<path
d="M0,centerY Q c1x,c1y thumbX,thumbY"
stroke-linecap="round"
fill="none"
/>
<path
d="MthumbX,thumbY Q c2x,c2y width,centerY"
stroke-linecap="round"
fill="none"
/>2
3
4
5
6
7
8
9
10
握把貼在中線時軌道就是一條直線;往上下一拉,握把離開中線,軌道自然凹成 V 字形。
虛線就是軌道中線,握把往下拉,兩段曲線跟著轉向。 兩個空心小點就是控制點,兩段都緊繃時剛好落在各自那段兩端點連線的中點上,曲線跟直線沒兩樣。
fill="none" 讓軌道只留筆畫,stroke-linecap 設成 round, 兩端與交接處才會變圓,外觀跟一般圓頭軌道一致。
原長指一段橡皮筋不受力時的長度。某一段的跨距(兩端點的直線距離)短於原長, 就代表它鬆掉了,控制點會往下移,那段就會垂成曲線。 垂下的深度來自原長與跨距構成的直角三角形:
sag = sqrt(restLength² - spanLength²) / 2二次貝茲曲線的中點落在「控制點佔一半、兩端點各佔四分之一」的加權平均上, 所以控制點往下推兩倍的 sag,曲線中點剛好垂下一個 sag。
拉緊的那段還會變細。筆畫扁平,面積固定時寬度與長度成反比, 所以粗細直接取原長除以跨距,拉得越長越細,最細不低於原本的三成五。 鬆掉的那段根本沒拉長,維持原本粗細。
SVG 本身設成 overflow: visible,握把拉出容器範圍,橡皮筋照樣畫得完整。
握把追指標
拉扯時握把不會直接貼到指標上。元件只記下指標位置,握把每幀往它追一段, 追的比例跟著幀時間走,60 fps 下大約是剩餘距離的三分之一; 按下的瞬間,握把也從原本的位置起步。
所以就算點軌道另一端,握把也會滑過去,不會瞬移。 沒開 slideLocked 時數值跟著握把走,兩者才不會脫節。
張力方向
兩段橡皮筋的張力,各自跟自己的伸長量成正比,方向則是握把指向左右固定端的單位向量。 原長則由「繫點」決定,也就是握把綁在橡皮筋上的位置,它跟著目前數值移動(外部改動數值時也是每幀逼近,不會瞬移):
ratio = (value - min) / (max - min)
displayRatio → ratio(每幀逼近,不會瞬移)
anchorX = displayRatio * width
restLeft = anchorX
restRight = width - anchorX
stretchLeft = max(0, length(left - thumb) - restLeft)
stretchRight = max(0, length(right - thumb) - restRight)
direction = normalize(
unit(left - thumb) * stretchLeft + unit(right - thumb) * stretchRight
)2
3
4
5
6
7
8
9
10
11
12
伸長量實作上改寫成平方差除以長度和,與上面的減法在代數上等價,但伸長量極小時也不會失準。
平常拉扯時繫點跟著握把跑,兩段永遠緊繃。 開啟 slideLocked 後繫點釘死,握把往旁邊拉,一段就會拉緊,另一段鬆垮垂下。 鬆垮那段伸長量取 0,自然不出力,合力只由緊繃的那段決定。
原長只在握把還黏在橡皮筋上時由繫點決定。握把不再黏著橡皮筋後沒有東西撐著, 原長得改跟著 V 字的轉折點走,否則程式會把回彈震盪的水平位移誤判成「這段變短了」, 橡皮筋就會不停在鬆垮與緊繃之間抖動。
握把往下拉時,原長比較短的那段伸長量比較大,張力也比較大,箭頭就畫得比較長。 虛線補完平行四邊形,粗箭頭就是相加後的合力。
所以兩段都緊繃時,握把越靠近左端,合力就越偏左,跟真實彈弓一樣,靠哪一邊就往哪一邊甩。
上圖示範開啟 slideLocked 往左下拉。左段跨距短於原長,鬆垮垂下也不出力; 右段緊繃,合力於是完全由它決定,握把會往右上方飛出去。
加速與震盪
握把離軌道中線的垂直距離,以下稱垂直拉扯量,簡稱拉扯量。 拉扯量不到 minLaunchLength,握把就不射出,改由橡皮筋拉回軌道; 達到這道門檻才會進入 launching,握把仍黏在橡皮筋上,由張力推著加速。
加速度大小取「launchPower 平方 × 垂直拉扯量」,方向則交給張力。 握把與繫點同時落在軌道水平正中央時左右張力對稱,水平分量互相抵消, 整個加速過程等同簡諧運動,脫手速度剛好是 launchPower × 該拉扯量。
偏離正中央時張力會偏向某一側,一部分速度轉到水平方向,垂直速度與總速率都會跟著變動。 握把越過軌道中線就脫手,極端角度下另設一秒的加速時間上限,免得卡在加速階段出不來。
脫手後橡皮筋就自己震盪,水平與垂直各用一組彈簧阻尼系統模擬。 阻尼比描述震盪衰減得多快,越接近 1 收得越乾脆。這裡取 0.6,會過衝一下再收斂, 不會來回晃很久;初速另有上限,免得大力射出時甩出畫面。
飛行與著陸
飛行時改用視窗座標(position: fixed),每幀等速移動, 撞到視窗四邊就把該軸速度扳成離開邊界的方向,已經朝外就不再翻,免得黏在牆上抖。
握把得先離開接觸範圍(握把半徑加軌道粗細的一半),或是飛出軌道的左右範圍, 著陸判定才會生效,不然脫手瞬間就直接著陸了。
判定分兩條,每幀先檢查有沒有穿越。握把跨過軌道中線時,前後兩幀相對中線的正負號會翻轉, 這時拿前後兩點跟中線求交點,交點落在軌道左右範圍內就著陸,並依交點換算數值。 沒穿越才檢查握把有沒有進入接觸範圍,而且同樣要在軌道的左右範圍內,數值取握把當下的位置。
原始碼
API
Props
interface Props {
modelValue: number;
/** 停用互動。@default false */
disabled?: boolean;
/** 鎖住繫點,握把仍可四處拉扯,但數值只由落點決定。@default false */
slideLocked?: boolean;
/** 最小值。@default 0 */
min?: number;
/** 最大值。@default 100 */
max?: number;
/** 數值間距。@default 1 */
step?: number;
/** 握把直徑(px)。@default 30 */
thumbSize?: number;
/** 握把顏色。@default '#34c6eb' */
thumbColor?: string;
/** 軌道顏色。@default '#EEE' */
trackColor?: string;
/** 軌道粗細(px)。@default 8 */
trackThickness?: number;
/** 射出握把所需的最短垂直拉扯量(px),不足則彈回軌道。@default 24 */
minLaunchLength?: number;
/** 橡皮筋的角頻率,也是每 1px 拉扯量換算成的脫手速度(px/s)。加速與回彈共用。@default 30 */
launchPower?: number;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Emits
const emit = defineEmits<{
'update:modelValue': [value: Props['modelValue']];
/** 握把脫手,帶出離手速度(px/s) */
'launch': [velocity: { x: number; y: number }];
/** 握把落回軌道,帶出落點換算的數值 */
'land': [value: number];
}>()2
3
4
5
6
7
Methods
interface Expose {
/** 召回飛行中的握把 */
recall: () => Promise<void>;
}2
3
4