Skip to content

主動的開關 toggle

停用時切換狀態會和你唱反調。( ´థ౪థ)

靈感來自 Useless machine,這個小廢物可是 Maker 的浪漫。(´,,•ω•,,)

至於為甚麼要用貓手,因為貓手是我想的到最欠揍的小手手。ヾ(◍'౪`◍)ノ゙

注意!Σ(ˊДˋ;)

請不要把 overflow 設成 hidden,否則會狠心切掉貓貓手

技術關鍵字

名稱描述
SVG Morph讓 SVG 在不同形狀間平滑過渡的技術
Anime.js輕量級 JavaScript 動畫函式庫
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等

使用範例

基本用法

開關停用時,切換開關會被貓貓手切回來。(◜௰◝)y

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 example-wrap">
    <base-checkbox
      v-model="disabled"
      :label="t('disableToggle')"
      class="example-ctrl"
    />

    <div class="flex flex-1 items-center justify-center">
      <toggle-proactive
        v-model="value"
        :disabled="disabled"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import ToggleProactive from '../toggle-proactive.vue'

const { t } = useI18n()
const disabled = ref(false)
const value = ref(false)
</script>

元件參數

樣式可隨意調整

查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col items-center gap-10 example-wrap p-8">
    <toggle-proactive
      :model-value="false"
      disabled
      size="3rem"
      fur-color="#DFC57B"
      pad-color="#FFF"
    />

    <toggle-proactive
      :model-value="true"
      disabled
      size="6rem"
      track-inactive-class="bg-red-400"
      track-active-class="bg-[#DFDFDF]"
      fur-color="#8D6F64"
      pad-color="#000"
    />

    <toggle-proactive
      :model-value="false"
      disabled
      size="4rem"
      track-active-class="bg-[#7DDAEA]"
      fur-color="#F3F2F2"
    />
  </div>
</template>

<script setup lang="ts">
import ToggleProactive from '../toggle-proactive.vue'
</script>

使用須知

讓使用者心甘情願(?閱讀須知。(´,,•ω•,,)

閱讀率 100% 時才能切換開關,否則會被強制回復。

🐟 鱈魚使用須知

📌 重要聲明

本指南適用於任何與鱈魚相關的活動,例如食用、觀賞、聊天、或試圖與其建立深厚友誼(不建議)。

🍽️ 食用須知

  • 請確保鱈魚已煮熟,除非你是北極熊。
  • 如果你發現鱈魚在盤子上對你微笑,請確認你沒有嗑藥。
  • 鱈魚富含不可名狀物質,吃多了可能出現幻覺。

🐠 觀賞須知

  • 鱈魚外觀樸素,請勿種族歧視。
  • 請勿在水族館對著鱈魚說「你好肥」,牠們也有自尊心。

💬 與鱈魚溝通須知

  • 鱈魚不會講話,請不要對牠進行長篇演講。
  • 如果鱈魚對你點頭,請不要高興得太早,牠可能只是因為水流晃動。
  • 與鱈魚進行心靈溝通時,請確保你沒有餓過頭導致出現幻覺。

🚨 禁忌事項

  1. 請勿將鱈魚放入洗衣機,可能也洗不乾淨。
  2. 請勿將鱈魚作為武器使用,除非已事先凍成冰塊。
  3. 請勿遛鱈魚,因為他不會走路

🎉 結語

請以尊重與幽默的態度對待鱈魚,如有任何不滿,請記得它只是一隻魚

歡迎提出 MR 補充以上須知

閱讀率:0.0%

UI:「這裡不應該用 toggle 吧! Σ(ˊДˋ;)」

查看範例原始碼
vue
<template>
  <div class="relative w-full flex flex-center flex-col gap-4 example-wrap">
    <div class="h-[40vh] overflow-y-auto example-ctrl">
      <h1>{{ t('title') }}</h1>

      <h2 :ref="titleRefList.set">
        {{ t('importantNotice') }}
      </h2>
      <p>{{ t('importantNoticeContent') }}</p>

      <h2 :ref="titleRefList.set">
        {{ t('eatingGuide') }}
      </h2>
      <ul>
        <li>{{ t('eating1') }}</li>
        <li>{{ t('eating2') }}</li>
        <li>{{ t('eating3') }}</li>
      </ul>

      <h2 :ref="titleRefList.set">
        {{ t('viewingGuide') }}
      </h2>
      <ul>
        <li>{{ t('viewing1') }}</li>
        <li>{{ t('viewing2') }}</li>
      </ul>

      <h2 :ref="titleRefList.set">
        {{ t('communicationGuide') }}
      </h2>
      <ul>
        <li>{{ t('communication1') }}</li>
        <li>{{ t('communication2') }}</li>
        <li>{{ t('communication3') }}</li>
      </ul>

      <h2 :ref="titleRefList.set">
        {{ t('taboos') }}
      </h2>
      <ol>
        <li>{{ t('taboo1') }}</li>
        <li>{{ t('taboo2') }}</li>
        <li>{{ t('taboo3') }}</li>
      </ol>

      <h2 :ref="titleRefList.set">
        {{ t('conclusion') }}
      </h2>
      <p>{{ t('conclusionContent1') }}</p>
      <p>{{ t('conclusionContent2') }}</p>
    </div>

    {{ t('readRate') }}{{ readRate.toFixed(1) }}%

    <label class="w-full flex-center cursor-pointer gap-6 border rounded-xl px-8 py-4 text-lg">
      {{ t('haveReadTerms') }}

      <toggle-proactive
        v-model="value"
        :disabled
        size="2rem"
      />
    </label>

    <transition name="opacity">
      <div
        v-if="value"
        class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-[#c7f6ff] bg-opacity-90"
      >
        <span class="text-xl tracking-wide">
          {{ t('thankYou') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import type { ComputedRef } from 'vue'
import { useTemplateRefsList } from '@vueuse/core'
import { map, pipe } from 'remeda'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useElementVisibilityTime } from '../../../composables/use-element-visibility-time'
import ToggleProactive from '../toggle-proactive.vue'

const { t } = useI18n()

const titleRefList = useTemplateRefsList<HTMLElement>()

const timeList = ref<ComputedRef<number>[]>([])
onMounted(() => {
  timeList.value = titleRefList.value.map((el) => {
    const { totalVisibleTime } = useElementVisibilityTime(el)
    return totalVisibleTime
  })
})

/** 最小閱讀時間 */
const MIN_READ_MS = 1000

/** 閱讀率 */
const readRate = computed(() => pipe(
  timeList.value,
  map((time) => time.value > MIN_READ_MS ? MIN_READ_MS : time.value),
  (timeList) => {
    const total = timeList.reduce((acc, time) => acc + time, 0)
    if (total === 0)
      return 0

    return total / (MIN_READ_MS * timeList.length) * 100
  },
))

const disabled = computed(() => readRate.value < 100)
const value = ref(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>

不可能的事

標籤可以自由編輯,一起可愛又不失禮貌地嗆爆...拒絕吧。ヾ(◍'౪`◍)ノ゙

查看範例原始碼
vue
<template>
  <div class="example-wrap w-full flex-center p-10">
    <div class="flex flex-col gap-4">
      <div
        v-for="state in stateList"
        :key="state.id"
        class="flex items-center justify-end gap-5"
      >
        <div
          v-static-text="state.label"
          contenteditable="plaintext-only"
          spellcheck="false"
          class="editable-label text-2xl"
          @input="updateLabel(state, $event)"
          @keydown.enter.prevent="finishEdit"
        />

        <toggle-proactive
          ref="toggleRefList"
          v-model="state.value"
          v-bind="colorData"
          size="3.5rem"
          :delay="0"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import type { Directive } from 'vue'
import { useCycleList } from '@vueuse/core'
import { pipe, reduce, sample } from 'remeda'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ToggleProactive from '../toggle-proactive.vue'

interface State {
  id: string;
  value: boolean;
  label: string;
}

type Toggle = InstanceType<typeof ToggleProactive>

const { t } = useI18n()
const toggleRefList = ref<Toggle[]>()

const {
  state: colorData,
  next: nextColor,
} = useCycleList([
  {
    furColor: '#7DDAEA',
    padColor: '#000',
  },
  {
    furColor: '#DFC57B',
    padColor: '#000',
  },
  {
    furColor: '#8D6F64',
    padColor: '#FFA5A5',
  },
  {
    furColor: '#444',
    padColor: '#FFA5A5',
  },
])

const stateList = ref<State[]>([
  {
    id: 'fast',
    label: t('wantFast'),
    value: false,
  },
  {
    id: 'good',
    label: t('wantGood'),
    value: false,
  },
  {
    id: 'cheap',
    label: t('wantCheap'),
    value: false,
  },
])
const booleanList = computed(
  () => stateList.value.map((state) => state.value),
)

/**
 * 只在掛載時寫入初始文字,之後不再跟著 model 更新,
 * 避免 Vue 重新渲染洗掉編輯中的游標位置
 */
const vStaticText: Directive<HTMLElement, string> = {
  mounted(element, binding) {
    element.textContent = binding.value
  },
}

function updateLabel(state: State, event: Event) {
  const element = event.target as HTMLElement
  state.label = element.textContent ?? ''
}

function finishEdit(event: KeyboardEvent) {
  const element = event.target as HTMLElement
  element.blur()
}

watch(booleanList, (value, oldValue) => {
  const allTrue = value.every((v) => v)
  if (!allTrue) {
    return
  }

  const targetIndex = pipe(
    oldValue,
    /** 排除最後一個切換的開關 */
    reduce(
      (acc: number[], boolValue, i) => boolValue ? [...acc, i] : acc,
      [],
    ),
    sample(1),
    ([i]) => i ?? 0,
  )

  nextColor()

  toggleRefList.value?.[targetIndex]?.toggle()
})
</script>

<style scoped lang="sass">
.editable-label
  cursor: text
  outline: none
  min-width: 3rem
  text-align: right
  border-bottom: 2px dashed
  border-color: color-mix(in srgb, currentColor 0%, transparent)
  transition: border-color 0.2s
  &:hover,
  &:focus
    border-color: color-mix(in srgb, currentColor 60%, transparent)
</style>

原理

用 anime.js 製作 svg 動畫。

裡面有個小細節,貓手一開始藏在 toggle 背後(手臂與手肘都在背後),播放切換動畫時,手臂一樣在 toggle 背後,但是手肘會伸到 toggle 前面。

SVG 內的物件為甚麼有辦法忽然變換堆疊順序?這個效果怎麼做出來,大家來猜猜看。(´,,•ω•,,)

賣個關子,想知道的話可以看看原始碼或留言讓我知道你的猜想。( ´ ▽ ` )ノ

底色為甚麼不用 background-color 換色

停用狀態下,貓手把開關切回後,軌道底色從綠變灰,偶爾會瞬間閃回綠色一下,而且只有桌機會出現,手機正常。(́⊙◞౪◟⊙‵)

一次只改一個變因,逐步逼出蟲蟲:

測試結果結論
啟用色從綠換成紅閃紅閃的就是軌道自己的舊底色
隱藏貓手 svg不閃svg 顯示出來才會閃
貓手 z-index 固定為 0照閃與堆疊順序、遮擋無關
軌道、svg 各自提升合成層更嚴重多一層就多一次混合,只會更複雜
軌道改 transition-duration: 0s不閃兇手是底色過渡本身

background-color 過渡跑在主執行緒,每幀都要重繪;貓手的 d 同時也在變形,一樣跑在主執行緒。兩者落在同一塊繪製區域,瀏覽器沒有正確更新軌道的繪製結果,畫面就露出過渡途中的舊色。

合成器執行緒是甚麼

瀏覽器把畫面畫出來的工作,分在兩條執行緒上。主執行緒負責執行 JavaScript、計算樣式與版面、產生繪製指令;合成器執行緒只把已經畫好的圖層依位移、縮放與透明度疊成一張畫面,交給 GPU 顯示。它不執行 JavaScript,也不計算版面。

階段在哪裡做做什麼
樣式計算 Style主執行緒執行 JavaScript,算出每個元素最終的樣式
版面 Layout主執行緒算位置與大小
繪製 Paint主執行緒產生繪製指令清單,還不是像素
光柵化 Raster光柵執行緒與 GPU把指令變成真正的像素
合成 Composite合成器執行緒把圖層依 transform、opacity 疊成畫面

transformopacityfilterbackdrop-filter 這四個屬性只改變圖層怎麼擺、怎麼混合,像素原封不動,合成器執行緒自己就能算完。所以主執行緒忙著跑 JavaScript 時,捲動與這類動畫還是很順。background-color 直接改動圖層裡的像素,每幀都得回主執行緒重繪。

底色因此改成兩層疊加,一層停用色、一層啟用色,切換時只讓上層的 opacity 淡入淡出。opacity 正好由合成器執行緒處理,底色層自始至終都是固定色,沒有中間值可以畫錯。改完之後不再閃爍,動畫整體也更順。

background-color:1 層opacity:2 層軌道,只有這一層啟用色層 opacity 0 → 1綠到灰都在這層上改沒有第二層停用色層 固定色同一層每幀重繪像素只調上層透明度,兩層都不重繪background-color 過渡opacity 交叉淡入繪製 Paint|主執行緒每幀重做繪製 Paint|主執行緒只做一次光柵化 Raster每幀重做光柵化 Raster只做一次合成 Composite合成器執行緒 每幀合成 Composite合成器執行緒 每幀調混合比例每幀重繪像素只在合成階段調混合比例

其他人也踩過同一類坑

  • 合成器執行緒只處理 transformopacityfilterbackdrop-filter,其餘屬性一律回主執行緒重繪,Blink 動畫說明文件MDN 動畫效能指南都寫得很清楚。
  • Chrome 確實替 background-color 動畫實作過合成器加速(BackgroundColorPaintWorklet),但條件嚴格,不符合就回退主執行緒。難怪同樣的操作有時閃、有時不閃,桌機與手機也不一樣。
  • 讓 SVG 的 d 跑動畫是公認昂貴的操作,會造成 CPU 重繪,SVG 效能的經典文章建議改用 transform,避免動畫直接改 layout 屬性。
  • opacity 交叉淡入取代顏色過渡是社群常見手法,深色模式轉場也是這樣做。
  • 提升合成層不是萬靈丹。Chromium 上有 transition 撞上 mix-blend-mode 的繪製錯亂,那個案例反而要加 translateZ(0) 才好。同一類問題解法未必相同,還是得自己實測。( ´ ▽ ` )ノ

原始碼

API

Props

interface Props {
  modelValue: boolean;
  disabled?: boolean;
  /** @default '4rem' */
  size?: string;
  /** 貓貓手出手前的等待時間,單位 ms
   *
   * 連續操作會逐次縮短,停手 2 秒後恢復
   * @default 1000
   */
  delay?: number;

  /** @default 'rounded-full' */
  trackClass?: string;
  /** @default 'bg-[#DFDFDF]' */
  trackInactiveClass?: string;
  /** @default 'bg-green-500' */
  trackActiveClass?: string;
  /** @default 'bg-white' */
  thumbClass?: string;
  /** @default '' */
  thumbInactiveClass?: string;
  /** @default '' */
  thumbActiveClass?: string;

  /** @default '#444' */
  furColor?: string;
  /** @default '#FFA5A5' */
  padColor?: string;
}

Emits

interface Emits {
  'update:modelValue': [value: boolean];
}

Methods

interface Expose {
  /** 觸發切換動畫 */
  toggle: () => Promise<void>;
}

v0.86.1