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>

詢價表單 ​

聽說心情好更容易提高預算?ԅ(´∀` ԅ)

聊聊你的專案填寫以下資訊,我們會在三個工作天內回覆
預算NT$ 150,000
預算還沒想好?往下拉再放手,讓緣分決定 ◝( •ω• )◟
查看範例原始碼
vue
<template>
  <div class="example-wrap relative w-full flex justify-center">
    <div class="max-w-[20rem] w-full flex flex-col gap-5 py-6">
      <div class="flex flex-col gap-1">
        <span class="text-lg font-bold">{{ t('title') }}</span>
        <span class="text-sm op-60">{{ t('subtitle') }}</span>
      </div>

      <label class="flex flex-col gap-1">
        <span class="text-sm font-bold">{{ t('projectType') }}</span>

        <base-select
          v-model="form.projectType"
          class="w-full"
        >
          <option
            v-for="projectType in projectTypeList"
            :key="projectType"
            :value="projectType"
          >
            {{ t(`projectTypeOption.${projectType}`) }}
          </option>
        </base-select>
      </label>

      <div class="flex flex-col gap-2">
        <div class="flex items-baseline justify-between">
          <span class="text-sm font-bold">{{ t('budget') }}</span>
          <span class="text-2xl font-bold">{{ budgetText }}</span>
        </div>

        <slider-slingshot
          v-model="form.budget"
          class="max-w-[12rem] w-full self-center"
          :min="30000"
          :max="1000000"
          :step="10000"
          thumb-color="#6366f1"
          track-color="#e0e7ff"
        />

        <span class="text-xs op-60">{{ t('budgetHint') }}</span>
      </div>

      <base-input
        v-model="form.email"
        type="email"
        :label="t('email')"
        placeholder="[email protected]"
      />

      <div class="mt-3 flex justify-center">
        <base-btn
          :label="t('submit')"
          @click="isSubmitted = true"
        />
      </div>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
        @click="reset"
      >
        <div class="flex flex-col items-center gap-2">
          <span class="text-xl tracking-wide">
            {{ t('submitted') }}
          </span>

          <span class="text-sm">
            {{ summaryText }}
          </span>
        </div>

        <span class="cursor-pointer text-xs">
          {{ t('retry') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import BaseSelect from '../../base-select.vue'
import SliderSlingshot from '../slider-slingshot.vue'

type ProjectType = 'brandSite' | 'eCommerce' | 'app' | 'other'

interface QuoteForm {
  projectType: ProjectType;
  budget: number;
  email: string;
}

const { t } = useI18n()

const projectTypeList: ProjectType[] = ['brandSite', 'eCommerce', 'app', 'other']

function createForm(): QuoteForm {
  return {
    projectType: 'brandSite',
    budget: 150000,
    email: '',
  }
}

const form = ref(createForm())
const isSubmitted = ref(false)

/** 千分位固定用逗號,伺服器渲染與瀏覽器的語系不同也不會對不上 */
const budgetText = computed(() =>
  `${t('currency')}${form.value.budget.toLocaleString('en-US')}`,
)

const summaryText = computed(() => t('summary', {
  projectType: t(`projectTypeOption.${form.value.projectType}`),
  budget: budgetText.value,
}))

function reset() {
  isSubmitted.value = false
  form.value = createForm()
}
</script>

<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

贊助鱈魚 ​

開啟 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 opacity-60">
        <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>

停用 ​

開啟 disabled 後會鎖定數值。ԅ(´∀` ԅ)

滿意度評分3 / 5
查看範例原始碼
vue
<template>
  <div class="example-wrap relative w-full flex justify-center">
    <div class="max-w-[20rem] w-full flex flex-col gap-4 py-6">
      <div class="flex items-baseline justify-between">
        <span class="text-sm op-60">{{ t('score') }}</span>
        <span class="text-2xl font-bold">{{ value }} / 5</span>
      </div>

      <slider-slingshot
        v-model="value"
        class="w-full"
        :disabled="confirmed"
        :max="5"
      />

      <base-checkbox
        v-model="confirmed"
        class="text-sm"
        :label="t('confirmation')"
      />

      <div class="mt-3 flex justify-center">
        <base-btn
          :label="t('submit')"
          :disabled="!confirmed"
          ignore-click
          @click="isSubmitted = true"
        />
      </div>
    </div>

    <transition name="opacity">
      <div
        v-if="isSubmitted"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
        @click="reset"
      >
        <span class="text-xl tracking-wide">
          {{ t('submitted', { value }) }}
        </span>

        <span class="cursor-pointer text-xs">
          {{ t('retry') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import SliderSlingshot from '../slider-slingshot.vue'

const { t } = useI18n()

const value = ref(3)
/** 確認後評分定案,握把還拉得動,但射不出去也改不了分數 */
const confirmed = ref(false)
const isSubmitted = ref(false)

function reset() {
  isSubmitted.value = false
  confirmed.value = false
}
</script>

<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
  opacity: 0 !important
</style>

原理 ​

握把有五種狀態:停在軌道上(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 或 disabled 後握把改綁在「繫點」上。 繫點是握把在橡皮筋上的位置,跟著目前數值移動。 這時兩段各有各的原長(restLeft = anchorX),往旁邊拉就會有一段拉緊,另一段則可能鬆垮垂下。

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

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

加速與震盪 ​

握把離軌道中線的垂直距離,以下稱拉扯量。 拉扯量不到 minLaunchLength,握把就不會飛出去,改由橡皮筋拉回軌道中線; 達到門檻才會進入 launching,握把仍黏在橡皮筋上,由張力推著加速。 開啟 disabled 時這道門檻等於無限大,握把永遠留在原地。

停用時彈回得特別誇張。橡皮筋角頻率先放大兩倍,回彈又快又急; 放手除了位移,還多給一股往繫點甩回去的初速,大小是位移的 1.5 倍 × 角頻率,握把因此衝過頭跑到另一側; 阻尼比也從 0.6 降到 0.12,讓它晃好幾輪才停,擺明了告訴使用者「不行」。

加速度大小取「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.90.7