iT邦幫忙

2024 iThome 鐵人賽

DAY 9
0

基本結構

第一步先來建立基本樣式,需求提到「預設 p 標籤,切分後的文字使用 span 標籤,且需要符合 a11y」。

可以想像結構如下圖。

D09 (1).png

讓我們加入 template。

src\components\text-characters-transition\text-characters-transition.vue

<template>
  <p>
    <span>我</span>
    <span>是</span>
    <span>文</span>
    <span>字</span>
  </p>
</template>

沒錯,模板部分就這麼單純。◝( •ω• )◟

程式邏輯

外觀有了,讓我們來實作程式邏輯吧。

動態產生結構

第一步定義元件參數與事件。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
// #region Props
interface Props {
  /** 顯示顯示 */
  visible?: boolean;
  /** 文字內容,矩陣表示已經分割好的文字 */
  label: string | string[];

  /** html tag
   * 
   * @default 'p'
   */
  tag?: string;

  /** 如何切割文字
   * 
   * 只有在 label 為 string 時有效
   * 
   * @default /.*?/u
   */
  splitter?: RegExp | ((label: string) => string[]);
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  visible: true,
  label: '',
  tag: 'p',
  splitter: undefined,
});

// #region Emits
const emit = defineEmits<{
  'before-enter': [];
  'after-enter': [];
  'before-leave': [];
  'after-leave': [];
}>();
// #endregion Emits

...

</script>

...

可以看到有一個 tag 參數,這表示使用者可以自定義容器的 tag 類型,預設為 p。

鱈魚:「現在讓我們把所有 HTML tag 用 v-if,列舉一遍吧!ლ(´∀`ლ)」

路人:「你認真!?Σ(ˊДˋ;)」

鱈魚:「開玩笑啦,這裡使用 Vue 內建的 component 元件處理。ヾ(◍'౪`◍)ノ゙」

路人:「component 不就是元件?…('◉◞⊖◟◉` )」

鱈魚:「不是啦,是這個元件就叫做 component。(́⊙◞౪◟⊙‵)」

多說無益,上程式碼比較好理解。

src\components\text-characters-transition\text-characters-transition.vue

<template>
  <component :is="props.tag">
    <span>我</span>
    <span>是</span>
    <span>文</span>
    <span>字</span>
  </component>
</template>

...

這時候回到網頁中,會發現 component 元件被渲染成 p 標籤。

image.png

component 可用於動態變換元素,除了 HTML tag 以外,還可以變成 Vue 元件。

接下來讓我們拆分文字。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** 拆分文字為多個字元 */
const chars = computed(() => pipe(
  props.label,
  (data) => {
    if (Array.isArray(data)) {
      return data;
    }

    if (typeof props.splitter === 'function') {
      return props.splitter(data);
    }

    /** Regex 加上 u 才不會導致 emoji 被拆分成亂碼 */
    return data.split(props.splitter ?? /.*?/u);
  },
  map.strict.indexed((data, i) => ({
    value: data,
    i,
  }))
));

/** 組合完整文字 */
const labelText = computed(() => pipe(
  chars.value,
  map((char) => char.value),
  join(''),
));

...
</script>

...

利用 v-for 產生每個字元,順便加上 a11y 用的 aria 屬性。

src\components\text-characters-transition\text-characters-transition.vue

<template>
  <component
    :is="props.tag"
    :aria-label="labelText"
  >
    <span
      v-for="char, i in chars"
      :key="i"
      aria-hidden
      class="inline-block"
    >
      {{ char.value }}
    </span>
  </component>
</template>

...

class 加上 inline-block 是為了未來用於 transform 效果,讓文字產生偏移、縮放等等效果。

現在讓我們在 basic-usage 加上必填參數吧。

src\components\text-characters-transition\examples\basic-usage.vue

<template>
  <div ... >
    <text-characters-transition label="我是鱈魚" />
  </div>
</template>

...

成功!看起來很讚!( ‧ω‧)ノ╰(‧ω‧ )

image.png

結合 anime.js

動畫部份我們使用 anime.js 實現,anime.js 是一個 API 簡潔、好用的動畫套件。

官網也很簡潔俐落,歡迎大家去逛逛。( ´ ▽ ` )ノ

老樣子先安裝依賴:

npm i -D animejs @types/animejs

讓我們簡單入門 anime.js 的用法。

anime({
  targets: '.cod',  // 所有帶有 cod 名稱 class 的元素
  translateX: 270,  // 使用 CSS 的 tranform 動畫,X 偏移 270px
  duration: 1000,   // 動畫時長
  delay(el, i) {    // 延遲時間。除了直接給數值,也可以給 function
    return i * 100;
  },
});

anime.js 用法就這麼簡單。◝( •ω• )◟

當然還有更多更進階的用法,就請大家去官網看囉。

實現過場動畫

這裡我們將「動畫」與「元件」分開,動畫統一由 transitionProvider 提供動畫,元件只負責取得、呼叫動畫。

src\components\text-characters-transition\transition-provider.ts

import anime from 'animejs';

export type AnimeFuncParam = (
  /** 目前 index。例:第 3 個字,此值就是 2 */
  index: number,
  /** 動畫總數。例:共 10 個字,此值就會是 10 */
  length: number,
) => anime.AnimeParams;

/** 過場動畫名稱 */
export enum TransitionName {
  /** 淡入淡出 */
  FADE = 'fade',
}

export const transitionProvider: Record<
  TransitionName,
  /** 提供 enter 與 leave 動畫 */
  {
    enter: AnimeFuncParam;
    leave: AnimeFuncParam;
  }
> = {
  [TransitionName.FADE]: {
    enter: (i) => ({
      opacity: 1,
      delay: i * 50,
    }),
    leave: (i) => ({
      opacity: 0,
      delay: i * 50,
    }),
  },
}

接著在元件參數新增 name,可以讓使用者指定要使用哪一個過場。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

// #region Props
interface Props {
  ...

  /** 過場名稱。使用預設內容 */
  name?: `${TransitionName}`;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  ...
  name: 'fade',
});

...
</script>

接下來準備讓我們在元件中呼叫動畫,首先我們先讓元件可以產生不重複的專屬 id,用來幫助 anime.js 定位目標元素。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...
// #endregion Emits

/** 唯一 id,用來讓 anime.js 定位目標元素 */
const id = crypto.randomUUID();

/** 拆分文字為多個字元 */

...
</script>

現在讓我們調整一下 chars 內容,加入動畫與參數。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** 拆分文字為多個字元 */
const chars = computed(() => pipe(
  props.label,
  ...  map.strict.indexed((data, i, array) => {
    const elId = `${id}-${i}`;
    const animate = transitionProvider[props.name];

    return {
      value: data,
      id: elId,
      i,
      enter: () => animate.enter(i, array.length),
      leave: () => animate.leave(i, array.length),
    }
  })
));

...
</script>

調整一下 template 綁定的參數。

src\components\text-characters-transition\text-characters-transition.vue

<template>
  <component ... >
    <span
      v-for="char, i in chars"
      :id="char.id"
      :key="i"
      :class="id"
      ...
    >
      {{ char.value }}
    </span>
  </component>
</template>

...

這樣我們就可以使用 id 精準呼叫動畫,並在需要的時候使用 class 一口氣清除所有動畫了。( ´ ▽ ` )ノ

再來就是實際呼叫動畫執行的部分了,首先是 startEnter。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** 進入動畫
 *
 * @param end 用於初始化時,立即完成動畫
 */
async function startEnter(end = false) {
  /** 移除所有現有動畫 */
  anime.remove(`.${id}`);

  emit('before-enter');

  const tasks = chars.value.map((char) => {
    const data = char.enter();

    if (end) {
      data.duration = 0;
      data.delay = 0;
    }

    /** 使用 animejs 自身的 CSS selector 不穩定,有時候會無法初始化,改用 js 原生語法 */
    const targets = document.getElementById(char.id)
    return anime({
      ...data,
      targets,
    }).finished;
  });

  await Promise.allSettled(tasks);

  emit('after-enter');
}

...
</script>

接著是 startLeave,與 startEnter 相同概念。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** 離開動畫
 * 
 * @param end 用於初始化時,立即完成動畫
 */
async function startLeave(end = false) {
  anime.remove(`.${id}`);

  emit('before-leave');

  const tasks = chars.value.map((char) => {
    const data = char.leave();

    if (end) {
      data.duration = 0;
      data.delay = 0;
    }

    const targets = document.getElementById(char.id)
    return anime({
      ...data,
      targets,
    }).finished;
  });

  await Promise.allSettled(tasks);

  emit('after-leave');
}

...
</script>

最後則是實際呼叫動畫執行的部分了。ヽ(●`∀´●)ノ

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** visible 變更時自動執行動畫 */
watch(() => props.visible, (visible) => {
  visible ? startEnter() : startLeave()
});

/** DOM 綁定完成後立即完成對應動畫 */
onMounted(() => {
  props.visible ? startEnter(true) : startLeave(true)
});

/** 元件解除前刪除所有動畫 */
onBeforeUnmount(() => {
  anime.remove(`.${id}`);
})
</script>

現在我們回到 basic-usage 中,加個 checkbox,切換狀態試試看。

src\components\text-characters-transition\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border border-gray-300 p-6">
    <label class=" flex items-center border p-4 rounded">
      <input
        v-model="visible"
        type="checkbox"
      >
      <span class="ml-2">
        顯示文字
      </span>
    </label>

    <text-characters-transition
      :visible
      label="我是鱈魚"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import TextCharactersTransition from '../text-characters-transition.vue';

const visible = ref(true);
</script>

現在切換 checkbox,每個文字都有獨立的過場動畫了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

動畫.gif

提供微調動畫效果參數

讓我們在元件上加上 enter 與 leave 參數,讓使用者可以微調動畫參數。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

// #region Props
interface Props {
  ...
  
  /** 進入動畫設定 */
  enter?: AnimeFuncParam;
  /** 離開動畫設定 */
  leave?: AnimeFuncParam;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  ...
  enter: undefined,
  leave: undefined,
});

...
</script>

調整 chars 內容,合併使用者的動畫設定。

src\components\text-characters-transition\text-characters-transition.vue

...

<script setup lang="ts">
...

/** 拆分文字為多個字元 */
const chars = computed(() => pipe(
  ...
  map.strict.indexed((data, i, array) => {
    ...
    
    return {
      ...
      enter: () => ({
        ...animate.enter(i, array.length),
        ...props.enter?.(i, array.length),
      }),
      leave: () => ({
        ...animate.leave(i, array.length),
        ...props.leave?.(i, array.length),
      }),
    }
  })
));

...
</script>

現在讓我們新增一段很長的文字並調整動畫參數。

src\components\text-characters-transition\examples\basic-usage.vue

<template>
  <div ... >
    ...

    <text-characters-transition
      :visible
      label="我是很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的文字"
    />
  </div>
</template>

...

這時候會發現由於文字很長,原本預設動畫的 delay 太久,導致兩段文字的動畫時間差很多。

動畫.gif

現在讓我們加入自定義參數。

src\components\text-characters-transition\examples\basic-usage.vue

<template>
  <div ... >
    ...

    <text-characters-transition
      :visible
      label="我是很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的文字"
      :enter="(i) => ({ delay: i * 5 })"
      :leave="(i) => ({ delay: i * 5 })"
    />
  </div>
</template>

...

現在兩段文字的動畫效果和諧許多了,恭喜我們完成所有規格了!(/≧▽≦)/

動畫.gif

總結

  • 完成「逐字轉場」樣式
  • 完成「逐字轉場」邏輯
  • 完成「逐字轉場」的 basic-usage 範例

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D09


上一篇
D08 - 逐字轉場:分析需求
下一篇
D10 - 逐字轉場:單元測試
系列文
要不要 Vue 點酷酷的元件?13
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言