主動的開關 toggle
停用時切換狀態會和你唱反調。( ´థ౪థ)
靈感來自 Useless machine,這個小廢物可是 Maker 的浪漫。(´,,•ω•,,)
至於為甚麼要用貓手,因為貓手是我想的到最欠揍的小手手。ヾ(◍'౪`◍)ノ゙
注意!Σ(ˊДˋ;)
請不要把 overflow 設成 hidden,否則會狠心切掉貓貓手
技術關鍵字
| 名稱 | 描述 |
|---|---|
| SVG Morph | 讓 SVG 在不同形狀間平滑過渡的技術 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
使用範例
基本用法
開關停用時,切換開關會被貓貓手切回來。(◜௰◝)y
查看範例原始碼
<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>元件參數
樣式可隨意調整
查看範例原始碼
<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% 時才能切換開關,否則會被強制回復。
🐟 鱈魚使用須知
📌 重要聲明
本指南適用於任何與鱈魚相關的活動,例如食用、觀賞、聊天、或試圖與其建立深厚友誼(不建議)。
🍽️ 食用須知
- 請確保鱈魚已煮熟,除非你是北極熊。
- 如果你發現鱈魚在盤子上對你微笑,請確認你沒有嗑藥。
- 鱈魚富含不可名狀物質,吃多了可能出現幻覺。
🐠 觀賞須知
- 鱈魚外觀樸素,請勿種族歧視。
- 請勿在水族館對著鱈魚說「你好肥」,牠們也有自尊心。
💬 與鱈魚溝通須知
- 鱈魚不會講話,請不要對牠進行長篇演講。
- 如果鱈魚對你點頭,請不要高興得太早,牠可能只是因為水流晃動。
- 與鱈魚進行心靈溝通時,請確保你沒有餓過頭導致出現幻覺。
🚨 禁忌事項
- 請勿將鱈魚放入洗衣機,可能也洗不乾淨。
- 請勿將鱈魚作為武器使用,除非已事先凍成冰塊。
- 請勿遛鱈魚,因為他不會走路
🎉 結語
請以尊重與幽默的態度對待鱈魚,如有任何不滿,請記得它只是一隻魚
歡迎提出 MR 補充以上須知
UI:「這裡不應該用 toggle 吧! Σ(ˊДˋ;)」
查看範例原始碼
<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>不可能的事
標籤可以自由編輯,一起可愛又不失禮貌地嗆爆...拒絕吧。ヾ(◍'౪`◍)ノ゙
查看範例原始碼
<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 疊成畫面 |
transform、opacity、filter、backdrop-filter 這四個屬性只改變圖層怎麼擺、怎麼混合,像素原封不動,合成器執行緒自己就能算完。所以主執行緒忙著跑 JavaScript 時,捲動與這類動畫還是很順。background-color 直接改動圖層裡的像素,每幀都得回主執行緒重繪。
底色因此改成兩層疊加,一層停用色、一層啟用色,切換時只讓上層的 opacity 淡入淡出。opacity 正好由合成器執行緒處理,底色層自始至終都是固定色,沒有中間值可以畫錯。改完之後不再閃爍,動畫整體也更順。
其他人也踩過同一類坑
- 合成器執行緒只處理
transform、opacity、filter、backdrop-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>;
}