Skip to content

彈弓滑動條 slider

拉一拉就射出去了 =͟͟͞͞( •̀д•́)

技術關鍵字

名稱描述
SVG可在 DOM 中使用的圖形格式,適合複雜圖形和動畫,配合 Vue v-bind 可以實現基於資料的動態效果
物理模擬模擬真實世界物理現象,如重力、碰撞、速度等物理效果
彈簧阻尼系統以勁度與阻尼常數模擬彈簧的震盪與回彈,常用於自然的 UI 動態回饋
向量計算處理方向、加速度、速度等等數學運算
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
Anime.js輕量級 JavaScript 動畫函式庫

使用範例

基本用法

上下拉成 V 字形,放開握把直接飛出去 =͟͟͞͞( •̀д•́)

飛行途中沒有阻力,撞到視窗邊緣反彈,只有碰到原軌道才停下來。

value: 50
查看範例原始碼
vue
<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>

贊助鱈魚

開啟 slideLocked 後,握把綁在橡皮筋上的那個點(繫點)釘在目前數值上, 左右拉只會拉緊或鬆垮,金額完全交給落點決定。

想調低金額請憑實力,歡迎挑戰命中 0 元 ( ˘ω˘ )

請鱈魚喝杯飲料NT$ 500
往下拉再放手,金額交給物理決定
查看範例原始碼
vue
<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>

原理

握把有五種狀態:停在軌道上(idle)、拉扯中(pulling)、橡皮筋推著加速(launching)、 脫離橡皮筋後飛行(flying)、召回中(recalling)。整份邏輯的核心就是狀態切換。

以下把握把脫離橡皮筋稱為「脫手」,那是使用者放開指標之後才發生的事,兩者的時間點不同。

V 字形軌道

軌道其實是兩條 SVG path,左固定端到握把一條,握把到右固定端一條,各是一段二次貝茲曲線。 兩段的粗細要各算各,所以分開畫。 這條軌道同時就是橡皮筋,講幾何時叫軌道,講張力時叫橡皮筋:

html
<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"
/>

握把貼在中線時軌道就是一條直線;往上下一拉,握把離開中線,軌道自然凹成 V 字形。

軌道中線 centerY拉扯量c1x,c1yc2x,c2y0,centerYwidth,centerYthumbX,thumbY

虛線就是軌道中線,握把往下拉,兩段曲線跟著轉向。 兩個空心小點就是控制點,兩段都緊繃時剛好落在各自那段兩端點連線的中點上,曲線跟直線沒兩樣。

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
)

伸長量實作上改寫成平方差除以長度和,與上面的減法在代數上等價,但伸長量極小時也不會失準。

平常拉扯時繫點跟著握把跑,兩段永遠緊繃。 開啟 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;
}

Emits

const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
  /** 握把脫手,帶出離手速度(px/s) */
  'launch': [velocity: { x: number; y: number }];
  /** 握把落回軌道,帶出落點換算的數值 */
  'land': [value: number];
}>()

Methods

interface Expose {
  /** 召回飛行中的握把 */
  recall: () => Promise<void>;
}

v0.86.1