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')

/** 落點金額由低到高對應的回應,取第一個沒超過上限的 */
const reactionList = [
  { maxAmount: 0, key: 'zero' },
  { maxAmount: 30, key: 'coin' },
  { maxAmount: 75, key: 'halfCup' },
  { maxAmount: 130, key: 'americano' },
  { maxAmount: 200, key: 'bubbleTea' },
  { maxAmount: 300, key: 'wholeWeek' },
  { maxAmount: 400, key: 'beyondDrink' },
  { maxAmount: 480, key: 'tooMuch' },
  { maxAmount: Number.POSITIVE_INFINITY, key: 'sugarDaddy' },
]

function handleLand(amount: number) {
  const reaction = reactionList.find((item) => amount <= item.maxAmount)

  reactionKey.value = reaction?.key ?? 'zero'
}
</script>

原理

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

握把脫離橡皮筋,以下稱「脫手」。脫手要等使用者放開指標才會發生。

V 字形軌道

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

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

握把貼在中線時,控制點落在兩端點連線的中點,軌道跟直線沒兩樣; 上下一拉,兩段跟著握把折過去,軌道就凹成 V 字形。

某一段的跨距(兩端點的直線距離)短於原長,就代表它鬆垮了,控制點往下移,那段垂成曲線。 二次貝茲曲線的中點是「控制點佔一半、兩端點各佔四分之一」的加權平均, 所以控制點往下移兩倍 sag,曲線中點剛好垂下 sag

sag = sqrt(restLength² - spanLength²) / 2

拉緊的那段還會變細。橡皮筋是一條面積固定的扁帶子,所以粗細與長度成反比, 實際取原長除以跨距,最細只到原本的三成五。鬆垮的那段沒拉長,維持原本粗細。

握把追指標

拉扯時握把不會直接貼到指標上。元件只記下指標位置,握把每幀往它追一段, 60 fps 下大約是剩餘距離的三分之一。按下的瞬間,握把從原本的位置起步。

所以就算點軌道另一端,握把也會滑過去,不會瞬移。數值則跟著握把重算,怎麼算見下一節。

張力方向

兩段橡皮筋的張力各跟自己的伸長率成正比,方向則是握把指向左右固定端的單位向量。 伸長率是伸長量除以原長,同樣拉長 10px,短的那段扯得更兇,張力自然更大。

原長怎麼算,要看握把有沒有綁死在橡皮筋上,這兩種情形差很多。

沒開 slideLocked 時握把在橡皮筋上自由滑動,就像穿在繩子上的環。 橡皮筋會從握把裡滑過去,直到兩段張力相等才停。 等效原長因此按跨距比例分配,兩段的伸長率跟著一致:

spanTotal = spanLeft + spanRight
restLeft  = width * spanLeft  / spanTotal
restRight = width * spanRight / spanTotal

這個分配比例還決定了數值。握把既然在橡皮筋上滑動,它在原本軌道上的位置就取 左段原長佔全長的比例。握把貼在中線時,左段比例剛好等於水平位置; 一旦往下拉,橡皮筋跟著滑,數值就往中間靠,拉得越深,最大與最小值越是搆不著。

左固定端右固定端握把左段張力右段張力合力方向

兩段張力相等,箭頭一樣長,虛線補完平行四邊形,粗箭頭就是合力,剛好平分兩段的夾角。 握把靠左,左段就陡、右段就平,平的那段幾乎只出水平力,合力於是偏右。 跟真實彈弓一樣,靠哪一邊就往另一邊甩。

開啟 slideLocked 後握把改綁在「繫點」上。繫點是握把在橡皮筋上的位置,跟著目前數值移動。 這時兩段各有各的原長(restLeft = anchorX),往旁邊拉就會有一段拉緊,另一段則可能鬆垮垂下。

左固定端右固定端繫點握把左段鬆垮,不出力合力=右段張力

上圖示範開啟 slideLocked 後往左下拉。左段跨距短於原長,鬆垮垂下,伸長率取 0 也就不出力; 合力完全由拉緊的右段決定,握把會往右上方飛出去。

加速與震盪

握把離軌道中線的垂直距離,以下稱拉扯量。 拉扯量不到 minLaunchLength,握把就不會飛出去,改由橡皮筋拉回軌道中線; 達到門檻才會進入 launching,握把仍黏在橡皮筋上,由張力推著加速。

加速度大小取「launchPower 平方 × 拉扯量」,方向則交給張力決定。 握把與繫點同時落在左右固定端的正中間時,左右張力對稱,水平分量互相抵消。 這時整個加速階段等同簡諧運動,脫手速度剛好是 launchPower × 放開指標當下的拉扯量。 偏離正中間時張力會偏向某一側,一部分速度轉到水平方向,垂直速度與總速率都會跟著變動。

握把越過軌道中線就脫手,偏得太離譜時另設一秒的加速時間上限,免得卡在加速階段出不來。 脫手後橡皮筋就自己震盪,水平與垂直各用一組彈簧阻尼系統模擬。

飛行與著陸

飛行時改用視窗座標(position: fixed),等速移動。 撞到視窗邊界,那一軸的速度就改成離開邊界的方向,已經朝外就維持原樣,免得貼著邊界一直發抖。

撞到邊界時也會震一下手機,震動時間跟撞擊速度成正比,落在 5 到 40 毫秒之間。 這裡直接用 VueUse 的 useVibrate,裝置不支援時它本身就是空操作。 iOS Safari 沒有實作 Vibration API,所以這個效果只有 Android 使用者感覺得到。

握把是圓形,軌道是有圓頭的粗線,兩個碰在一起就算著陸。 握把圓心離軌道不超過「握把半徑加軌道粗細的一半」就碰到了,預設是 19px。

把所有剛好碰到軌道時的圓心位置連起來,會圍出一塊膠囊形區域,兩端各是半圓,以下稱接觸範圍。 圓心落在接觸範圍裡就碰到了,稍微超出軌道左右兩端也一樣算。

握把接觸範圍軌道

一幀之間握把從前一幀的位置移到這一幀的位置, 中間有沒有哪一刻圓心進了接觸範圍?只檢查前後兩點不夠,圓心可能中途溜進去又出來。

前一幀這一幀中間這一刻碰到了接觸範圍軌道

所以要量整段路徑,也就是前後兩點連成的線段離軌道最近有多近。 不超過 19px 就著陸,數值則按最近那一刻圓心對應到軌道上的位置換算。

兩條線段沒有相交時,最短距離一定落在其中一條的端點上,這是現成結論。 所以四次投影加一次相交判定就能算出答案,不必一步步逼近。 從路徑的起點與終點分別量到軌道,從軌道的左右固定端分別量到路徑,穿過去就直接是零。

還有兩件事要處理。單幀位移可能比整條軌道還長,所以先依速度把一幀切成夠短的子步, 每步位移不超過 19px,順帶讓握把在路徑上正確的位置反彈。 另外脫手瞬間握把正貼著軌道,得等某一步的整段路徑都離開接觸範圍,才開始判定著陸。

比較一律用平方距離,著陸判定不必開根號。

原始碼

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.3