多邊形轉場 transition
Motion Graphic 風格的 Transition 元件。
甚麼是 Motion Graphic 轉場效果?可以看看這個影片:
Motion Graphic 轉場在影片製作其實很常見,不過網頁中就沒那麼常見了。
所以我就嘗試把效果搬上網頁了!ˋ( ° ▽、° )
雖然目前稍微有一點點瑕疵,不過基本功能與效果都已經實現,剩下的改天再來慢慢改進吧。(。・∀・)ノ
使用範例
基本用法
基於 Vue 內建的 Transition 元件,不過目前連續切換會壞掉,請先手下留情。 (〃` 3′〃)
即使新舊元素尺寸不同也可以用呦!ˋ( ° ▽、° )
鱈魚
🐟
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。('◉◞⊖◟◉` )
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col items-start gap-4 border border-gray-300 p-6">
<base-btn
class="w-full"
label="更換"
@click="change()"
/>
<div class="w-full flex flex-col gap-4 overflow-hidden border rounded bg-slate-100 p-6">
<div class="flex justify-center">
<transition-shape
:type="imgTransition"
@after-transition="handleReady"
>
<img
:key="index"
:src="profile"
class="h-60 w-60 border-[0.5rem] border-white rounded-full object-cover shadow-md"
>
</transition-shape>
</div>
<div class="flex flex-col items-center justify-center gap-2 text-2xl font-bold">
鱈魚
<transition-shape
:type="fishTransition"
@after-transition="handleReady"
>
<div
:key="index"
class="p-2 px-4 text-3xl"
>
{{ fish }}
</div>
</transition-shape>
</div>
<transition-shape
appear
:type="textTransition"
@after-transition="handleReady"
>
<div
:key="index"
class="p-8"
>
{{ introduction }}
</div>
</transition-shape>
</div>
</div>
</template>
<script setup lang="ts">
import type { TransitionType } from '../type'
import { debounce } from 'lodash-es'
import { hasAtLeast, piped, reverse } from 'remeda'
import { computed, ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import TransitionShape from '../transition-shape.vue'
const index = ref(0)
const fishList = [
'🐟',
'🐋🐋',
'🐠',
'🐡🐡',
]
const fish = computed(() => fishList[index.value % fishList.length])
const profileList = [
'/profile.webp',
'/profile-2.webp',
'/profile-3.webp',
]
const profile = computed(() => profileList[index.value % profileList.length])
const introductionList = [
`一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。('◉◞⊖◟◉\` )`,
'最擅長的球類是地瓜球,一打十輕輕鬆鬆。( •̀ ω •́ )✧',
`不知道是不是在水裡躺平躺久了,最近喝水也會胖。\n_(:3」ㄥ)_`,
]
const introduction = computed(() => introductionList[index.value % introductionList.length])
const isReady = ref(false)
const handleReady = debounce(() => {
isReady.value = true
}, 500)
function change() {
if (!isReady.value)
return
isReady.value = false
index.value++
}
const colors: [string, ...string[]] = ['#012030', '#13678A', '#45C4B0', '#9AEBA3', '#DAFDBA']
const reverseColors = piped(
reverse<string[]>(),
(result) => {
if (!hasAtLeast(result, 1)) {
throw new Error('At least one color is required')
}
return result
},
)
const baseOption = {
duration: 800,
delay: 100,
}
const imgTransition: TransitionType = {
name: 'round',
enter: {
action: 'spread-scale',
easing: 'easeOutQuart',
...baseOption,
},
leave: {
action: 'scale',
easing: 'easeInQuart',
...baseOption,
},
colors,
}
const fishTransition: TransitionType = {
name: 'rect',
enter: {
action: 'slide-down',
easing: 'easeOutQuart',
...baseOption,
},
leave: {
action: 'slide-right',
easing: 'easeInQuart',
...baseOption,
},
colors: reverseColors(colors),
}
const textTransition: TransitionType = {
name: 'fence',
enter: {
action: 'spread-left',
easing: 'easeOutQuart',
...baseOption,
},
leave: {
action: 'scale-x',
easing: 'easeInQuart',
...baseOption,
},
colors,
}
</script>
轉場參數
可以微調各類轉場參數。
顏色
進入 action
easing
delay (ms)
duration (ms)
離開 action
easing
delay (ms)
duration (ms)
🐟
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col items-start gap-4 border border-gray-300 p-6">
<div class="w-full flex flex-col gap-2">
<div class="w-full flex items-center gap-3 border p-1">
<div class="flex-1 text-right">
顏色
</div>
<div class="flex flex-1 gap-1 p-2">
<input
v-model="colors[0]"
type="color"
class="flex-1"
>
<input
v-model="colors[1]"
type="color"
class="flex-1"
>
<input
v-model="colors[2]"
type="color"
class="flex-1"
>
</div>
</div>
<!-- 進入 -->
<div class="w-full flex flex-col items-center gap-1 border p-1">
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
進入 action
</div>
<select
v-model="rectAction.enter.action"
class="flex-1 rounded bg-slate-200 p-2"
>
<option
v-for="option in rectActionOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
easing
</div>
<select
v-model="rectAction.enter.easing"
class="flex-1 rounded bg-slate-200 p-2"
>
<option
v-for="option in easingOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
delay (ms)
</div>
<input
v-model.number="rectAction.enter.delay"
type="number"
class="min-w-0 flex-1 rounded bg-slate-200 p-2"
>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
duration (ms)
</div>
<input
v-model.number="rectAction.enter.duration"
type="number"
class="min-w-0 flex-1 rounded bg-slate-200 p-2"
>
</div>
</div>
<!-- 離開 -->
<div class="w-full flex flex-col items-center gap-1 border p-1">
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
離開 action
</div>
<select
v-model="rectAction.leave.action"
class="flex-1 rounded bg-slate-200 p-2"
>
<option
v-for="option in rectActionOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
easing
</div>
<select
v-model="rectAction.leave.easing"
class="flex-1 rounded bg-slate-200 p-2"
>
<option
v-for="option in easingOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
delay (ms)
</div>
<input
v-model.number="rectAction.leave.delay"
type="number"
class="min-w-0 flex-1 rounded bg-slate-200 p-2"
>
</div>
<div class="w-full flex items-center gap-3 p-1">
<div class="flex-1 text-right">
duration (ms)
</div>
<input
v-model.number="rectAction.leave.duration"
type="number"
class="min-w-0 flex-1 rounded bg-slate-200 p-2"
>
</div>
</div>
<base-btn
class="w-full"
label="換魚"
@click="changeFish()"
/>
</div>
<transition-shape :type="transitionType">
<div
:key="fishIndex"
class="w-full text-center text-[10rem]"
>
{{ fishList[fishIndex] }}
</div>
</transition-shape>
</div>
</template>
<script setup lang="ts">
import type anime from 'animejs'
import type { TransitionType } from '../type'
import { computed, ref } from 'vue'
import BaseBtn from '../../base-btn.vue'
import TransitionShape from '../transition-shape.vue'
import { RectAction } from '../type'
const fishIndex = ref(0)
const fishList = [
'🐟',
'🐋',
'🐠',
'🐡',
]
function changeFish() {
fishIndex.value++
fishIndex.value %= fishList.length
}
const colors = ref<
[string, string, string]
>(['#7DC8FF', '#677580', '#374855'])
const rectAction = ref<
Record<'enter' | 'leave', {
action: `${RectAction}`;
easing: anime.EasingOptions;
delay: number;
duration: number;
}>
>({
enter: {
action: 'slide-right',
easing: 'easeOutQuart',
delay: 100,
duration: 800,
},
leave: {
action: 'slide-right',
easing: 'easeInQuart',
delay: 100,
duration: 800,
},
})
const rectActionOptions = Object.values(RectAction)
const timingList = ['In', 'Out', 'InOut']
const typeList = [
'Quad',
'Cubic',
'Quart',
'Quint',
'Sine',
'Expo',
'Circ',
'Back',
'Elastic',
'Bounce',
]
const easingOptions = [
'linear',
...typeList.flatMap((type) =>
timingList.map((timing) => `ease${timing}${type}`),
),
]
const transitionType = computed<TransitionType>(() => ({
name: 'rect',
enter: rectAction.value.enter,
leave: rectAction.value.leave,
colors: colors.value,
}))
</script>
Round 轉場
由圓形組成。
點擊任意魚,開始轉場。(≧∇≦)ノ
點擊展開
查看範例原始碼
vue
<template>
<div class="w-full flex flex-wrap justify-center gap-4 border border-gray-300 p-6">
<div
v-for="item in list"
:key="item.key"
class="w-full"
>
<transition-shape
:type="item"
@after-transition="handleInit()"
>
<div
:key="fishIndex"
class="w-full cursor-pointer py-6 text-center text-[5rem]"
@click="handleClick()"
>
{{ fishList[fishIndex] }}
</div>
</transition-shape>
</div>
</div>
</template>
<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, throttle } from 'lodash-es'
import { hasAtLeast, map, pipe, shuffle } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { RoundBaseAction, RoundEnterAction } from '../type'
const fishIndex = ref(0)
const fishList = [
'🐟',
'🐋',
'🐠',
'🐡',
]
function changeFish() {
fishIndex.value++
fishIndex.value %= fishList.length
}
const [isReady, toggleReady] = useToggle(false)
const handleInit = debounce(() => {
toggleReady(true)
}, 1000)
const handleClick = throttle(() => {
if (!isReady.value) {
handleClick.cancel()
return
}
changeFish()
}, 4000, {
leading: true,
trailing: false,
})
const leaveActions = Object.values(RoundBaseAction)
const enterActions = [
RoundEnterAction.SPREAD_LEFT,
RoundEnterAction.SPREAD_SCALE,
RoundBaseAction.SCALE,
RoundBaseAction.SCALE_LB,
]
type Item = TransitionType & {
key: string;
}
const list: Item[] = pipe(
enterActions,
map((action, i) => {
const leaveAction = leaveActions[i % leaveActions.length]
if (!leaveAction) {
throw new Error('Leave action is required')
}
const colors = shuffle(['#27A4F2', '#44C1F2', '#85DEF2', '#DCEEF2', '#91E9F2'])
if (!hasAtLeast(colors, 1)) {
throw new Error('At least one color is required')
}
const result: Item = {
key: action,
name: 'round',
enter: {
action,
duration: 900,
delay: 200,
easing: 'easeOutExpo',
},
leave: {
action: leaveAction,
duration: 900,
delay: 200,
easing: 'easeInExpo',
},
colors,
}
return result
}),
)
</script>
Fence 轉場
像柵欄一般,由多個矩形組成。
點擊任意魚,開始轉場。(≧∇≦)ノ
點擊展開
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col justify-center gap-4 border border-gray-300 p-6">
<div
v-for="item in list"
:key="item.key"
class="w-full"
>
<transition-shape
:type="item"
@after-transition="handleInit()"
>
<div
:key="fishIndex"
class="w-full cursor-pointer py-6 text-center text-[6rem]"
@click="handleClick()"
>
{{ fishList[fishIndex] }}
</div>
</transition-shape>
</div>
</div>
</template>
<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, shuffle, throttle } from 'lodash-es'
import { hasAtLeast, map, pipe, reverse } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { FenceAction } from '../type'
const fishIndex = ref(0)
const fishList = [
'🐟',
'🐋',
'🐠',
'🐡',
]
function changeFish() {
fishIndex.value++
fishIndex.value %= fishList.length
}
const [isReady, toggleReady] = useToggle(false)
const handleInit = debounce(() => {
toggleReady(true)
}, 1000)
const handleClick = throttle(() => {
if (!isReady.value) {
handleClick.cancel()
return
}
changeFish()
}, 3000, {
leading: true,
trailing: false,
})
const actions = Object.values(FenceAction)
// const actions = [FenceAction.SPREAD_RIGHT];
const reverseActions = pipe(actions, reverse())
type Item = TransitionType & {
key: string;
}
const list: Item[] = pipe(
actions,
map((action, i) => {
const targetAction = reverseActions[i] ?? action
const colors = shuffle(['#27A4F2', '#44C1F2', '#85DEF2', '#DCEEF2', '#91E9F2'])
if (!hasAtLeast(colors, 1)) {
throw new Error('At least one color is required')
}
const result: Item = {
key: action,
name: 'fence',
enter: {
action,
duration: 800,
delay: 100,
easing: 'easeOutQuart',
},
leave: {
action: targetAction,
duration: 800,
delay: 100,
easing: 'easeInQuart',
},
colors,
}
return result
}),
)
</script>
Converging Rect 轉場
多個矩形向中心匯聚,可以設定傾斜角度。
點擊任意魚,開始轉場。(≧∇≦)ノ
點擊展開
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col justify-center gap-4 border border-gray-300 p-6">
<div
v-for="item in list"
:key="item.key"
class="w-full"
>
<transition-shape
:type="item"
@after-transition="handleInit()"
>
<div
:key="fishIndex"
class="w-full cursor-pointer py-5 text-center text-[5rem]"
@click="handleClick()"
>
{{ fishList[fishIndex] }}
</div>
</transition-shape>
</div>
</div>
</template>
<script setup lang="ts">
import type { TransitionType } from '../type'
import { useToggle } from '@vueuse/core'
import { debounce, throttle } from 'lodash-es'
import { map, pipe } from 'remeda'
import { ref } from 'vue'
import TransitionShape from '../transition-shape.vue'
import { ConvergingRectAction } from '../type'
const fishIndex = ref(0)
const fishList = [
'🐟',
'🐋',
'🐠',
'🐡',
]
function changeFish() {
fishIndex.value++
fishIndex.value %= fishList.length
}
const [isReady, toggleReady] = useToggle(false)
const handleInit = debounce(() => {
toggleReady(true)
}, 1000)
const handleClick = throttle(() => {
if (!isReady.value) {
handleClick.cancel()
return
}
changeFish()
}, 3000, {
leading: true,
trailing: false,
})
type Item = TransitionType & {
key: string;
}
const list: Item[] = pipe(
[0, 10, -30],
map((angle) => {
const action = ConvergingRectAction.SLIDE
const result: Item = {
key: action,
name: 'converging-rect',
enter: {
action,
angle,
duration: 800,
delay: 100,
easing: 'easeOutExpo',
},
leave: {
action,
duration: 800,
delay: 100,
easing: 'easeInExpo',
},
colors: [
'#27A4F2',
'#44C1F2',
'#85DEF2',
'#DCEEF2',
'#91E9F2',
],
}
return result
}),
)
</script>
原理
基於 Vue 內建的 Transition 元件,使用 babylon.js 產生形狀遮罩,接著透過 Transition Event 控制動畫的開始與結束。
可以處理以下情境:
- v-if 條件渲染
- 變更元素 key
暫時不考慮 v-show 的情境。
定位問題
原本使用 fixed 配合頁面滾動事件來定位 canvas 位置,但是在快速滾動畫面時 sharp 還是會稍微偏移。
苦惱之際發現了 CSS Anchor Positioning API 這個神奇酷炫的東西,完美解決此問題!(๑•̀ㅂ•́)و✧
原始碼
API
Props
interface Props {
appear?: boolean;
type?: TransitionType;
}
/** 初始化後 name 變更會被忽略,其餘參數皆可動態調整 */
export type TransitionType =
TransitionRect
| TransitionConvergingRect
| TransitionRound
| TransitionFence
TransitionRect
export enum RectAction {
SLIDE_RIGHT = 'slide-right',
SLIDE_LEFT = 'slide-left',
SLIDE_UP = 'slide-up',
SLIDE_DOWN = 'slide-down',
SCALE = 'scale',
SCALE_X = 'scale-x',
SCALE_Y = 'scale-y',
}
interface TransitionRect {
name: 'rect';
enter: {
action: `${RectAction}`;
duration: number;
/** 每個 shape 延遲間距 */
delay: number;
easing: EasingOptions;
};
leave: {
action: `${RectAction}`;
duration: number;
delay: number;
easing: EasingOptions;
};
/** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
colors: [string, ...string[]];
}
TransitionConvergingRect
export enum ConvergingRectAction {
SLIDE = 'slide',
}
interface TransitionConvergingRect {
name: 'converging-rect';
enter: {
action: `${ConvergingRectAction}`;
angle?: number;
duration: number;
/** 每個 shape 延遲間距 */
delay: number;
easing: EasingOptions;
};
leave: {
action: `${ConvergingRectAction}`;
duration: number;
delay: number;
easing: EasingOptions;
};
/** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
colors: [string, ...string[]];
}
TransitionRound
export enum RoundEnterAction {
SPREAD_LEFT = 'spread-left',
SPREAD_RIGHT = 'spread-right',
SPREAD_UP = 'spread-up',
SPREAD_DOWN = 'spread-down',
SPREAD_SCALE = 'spread-scale',
}
export enum RoundBaseAction {
SCALE_LT = 'scale-lt',
SCALE_LB = 'scale-lb',
SCALE_RT = 'scale-rt',
SCALE_RB = 'scale-rb',
SCALE = 'scale',
}
interface TransitionRound {
name: 'round';
enter: {
action: `${RoundBaseAction}` | `${RoundEnterAction}`;
duration: number;
/** 每個 shape 延遲間距 */
delay: number;
easing: EasingOptions;
};
leave: {
action: `${RoundBaseAction}`;
duration: number;
delay: number;
easing: EasingOptions;
};
/** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
colors: [string, ...string[]];
}
TransitionFence
export enum FenceAction {
SPREAD_RIGHT = 'spread-right',
SPREAD_LEFT = 'spread-left',
SCALE_X = 'scale-x',
SCALE_Y = 'scale-y',
}
interface TransitionFence {
name: 'fence';
enter: {
action: `${FenceAction}`;
duration: number;
/** 每個 shape 延遲間距 */
delay: number;
easing: EasingOptions;
};
leave: {
action: `${FenceAction}`;
duration: number;
delay: number;
easing: EasingOptions;
};
/** HEX 格式。顏色數量等同 shape 數量,至少需要一個 */
colors: [string, ...string[]];
}
Emits
const emit = defineEmits<{
(e: 'init'): void;
(e: 'beforeTransition'): void;
(e: 'afterTransition'): void;
}>()
Slots
const slots = defineSlots<{
default?: () => unknown;
}>()