iT邦幫忙

2022 iThome 鐵人賽

DAY 7
2
Modern Web

派對動物嗨起來!系列 第 7

D07 - 開趴前先 loading 一下:使用 Pinia 與 Composition API

  • 分享至 

  • xImage
  •  

本系列文已改編成書「甚麼?網頁也可以做派對遊戲?使用 Vue 和 babylon.js 打造 3D 派對遊戲吧!」

書中不只重構了程式架構、改善了介面設計,還新增了 2 個新遊戲呦!ˋ( ° ▽、° )

新遊戲分別使用了陀螺儀與震動回饋,趕快買書來研究研究吧!ლ(╹∀╹ლ)

在此感謝深智數位的協助,歡迎大家前往購書,鱈魚感謝大家 (。・∀・)。

助教:「所以到底差在哪啊?沒圖沒真相,被你坑了都不知道。(´。_。`)」

鱈魚:「你對我是不是有甚麼很深的偏見啊 (っ °Д °;)っ,來人啊,上連結!」

Yes


再來先來實現「建立派對」的功能,讓我們準備開趴!

第一步先來建立頁面組件,並在 router 新增 RouteName 定義。

src\views\game-console.vue

<template>
  <div class="flex">
    我是 game-console
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
</script>

<style scoped lang="sass">
</style>

src\router\router.ts

import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'

export enum RouteName {
  HOME = 'home',

  GAME_CONSOLE = 'game-console',
}

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    redirect: {
      name: 'home',
    }
  },

  {
    path: `/home`,
    name: RouteName.HOME,
    component: () => import('../views/the-home.vue')
  },

  {
    path: `/game-console`,
    name: RouteName.GAME_CONSOLE,
    component: () => import('../views/game-console.vue'),
  },

  {
    path: '/:pathMatch(.*)*',
    redirect: '/'
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

頁面建立完成後,讓我們在「建立派對」按鈕跳轉 route,跳來這一個頁面吧 ….?等等,這樣轉場不給力啊,遊戲應該要有遊戲專用的轉場才對不是嗎?

所以讓我們來實現自定義的 loading 效果吧。ヽ(✿゚▽゚)ノ

首先新增 loading.store,用來管理 loading 相關狀態。

src\stores\loading.store.ts

import { defineStore } from 'pinia';

interface State {
  isLoading: boolean,
  isEntering: boolean,
  isLeaving: boolean,
  /** loading 樣式,預留未來可以切換多種樣式 */
  type: string,
}

export const useLoadingStore = defineStore('loading', {
  state: (): State => ({
    isLoading: false,
    isEntering: false,
    isLeaving: false,

    type: ''
  }),
})

接著新增一個包裝 Vue transition 組件的 transition-mask 組件,用來偵測過場狀態並提供轉場效果。

首先定義 Props。

src\components\transition-mask.vue

<script lang="ts">
export enum AnimationType {
  ROUND = 'round',
}

</script>

<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';

interface Props {
  modelValue: boolean;
  type?: `${AnimationType}`;
}
const props = withDefaults(defineProps<Props>(), {
  type: AnimationType.ROUND,
});

...
</script>

如果 transition 組件可以設定 name 參數,用來指定欲使用的過場 class,這裡我們也提供 type 參數,作為相同概念用途。

接著新增狀態變數與各類轉場 hook,並將資料與事件綁定至 template 中。

<template>
  <transition
    :name="props.type"
    @before-enter="handleBeforeEnter"
    @after-enter="handleAfterEnter"
    @before-leave="handleBeforeLeave"
    @after-leave="handleAfterLeave"
  >
    <div
      v-if="props.modelValue"
      class="mask"
    >
      <slot />
    </div>
  </transition>
</template>

<script lang="ts">
...

export interface State {
  isEntering: boolean,
  isLeaving: boolean,
}
</script>

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

const emit = defineEmits<{
  (e: 'update', state: State): void;
}>();

const state = reactive<State>({
  isEntering: false,
  isLeaving: false,
});

watch(state, () => emit('update', state));

function handleBeforeEnter() {
  state.isEntering = true;
  state.isLeaving = false;
}
function handleAfterEnter() {
  state.isEntering = false;
}

function handleBeforeLeave() {
  state.isEntering = false;
  state.isLeaving = true;
}
function handleAfterLeave() {
  state.isLeaving = false;
}

defineExpose({
  state
});
</script>

...

還差設計轉場用的 CSS,但是先讓我們把總體結構都完成,等等測試時再設計動畫。

新增 loading-overlay 組件,用來包裝所有的 loading 相關組件。

<template>
  <transition-mask
    class="absolute inset-0"
  >
    <div class="absolute inset-0 bg-white" />
  </transition-mask>
</template>

<script setup lang="ts">
import { } from 'vue';

import TransitionMask from './transition-mask.vue';
</script>

接著新增 use-loading,用來提供所有 loading 功能。

src\composables\use-loading.ts

import { promiseTimeout, until, watchOnce } from '@vueuse/core';
import { defaults } from 'lodash-es';
import { storeToRefs } from 'pinia';
import { ref, watch } from 'vue'
import { useLoadingStore } from '../stores/loading.store'

export interface State {
  isEntering: boolean,
  isLeaving: boolean,
}

interface UseLoadingParams {
  /** 最小持續時間 (ms,預設 1000)
   * 
   * 讀取頁面進入完成至開始離開之間的最小時間,可用來展示動畫。
   */
  minDuration?: number;
}

const DefaultParams: Required<UseLoadingParams> = {
  minDuration: 1000,
}

export function useLoading(paramsIn?: UseLoadingParams) {
  const params = defaults(paramsIn, DefaultParams);

  const store = useLoadingStore();
  const { isLoading, isEntering, isLeaving } = storeToRefs(store);

  const visible = ref(false);

  const minDuration = params.minDuration;

  watch(isLoading, (value) => {
    visible.value = value;
  }, {
    immediate: true
  });

  watch(visible, async (value) => {
    // show() 要立即顯示
    if (value) {
      store.$patch({
        isLoading: true,
      });
      return;
    }

    await promiseTimeout(minDuration);

    // hide() entering 為 false,直接隱藏
    if (!isEntering.value) {
      store.$patch({
        isLoading: false,
      });
      return;
    }

    // entering 結束之後才可隱藏
    watchOnce(isEntering, () => {
      store.$patch({
        isLoading: false,
      });
    })
  }, {
    deep: true
  });

  /** 
   * 等到過場動畫進入完成後,才會完成 Promise
   * 可以避免動畫還沒完成就跳頁的問題
   */
  async function show() {
    visible.value = true;

    await until(isEntering).toBe(true);
    await until(isEntering).toBe(false);
  }

  /** 等到過場動畫離開完成後,才會完成 Promise */
  async function hide() {
    visible.value = false;

    await until(isLeaving).toBe(true);
    await until(isLeaving).toBe(false);
  }

  function handleUpdate({ isEntering, isLeaving }: State) {
    store.$patch({
      isEntering,
      isLeaving
    });
  }

  return {
    isLoading,
    isEntering,
    isLeaving,

    show,
    hide,
    handleUpdate
  }
}

在 loading-overlay 中導入 use-loading,綁定狀態吧!

<template>
  <transition-mask
    v-model="isLoading"
    class="absolute inset-0"
    @update="handleUpdate"
  >
    <div class="absolute inset-0 bg-white" />
  </transition-mask>
</template>

<script setup lang="ts">
import { } from 'vue';
import { useLoading } from '../composables/use-loading';

import TransitionMask from './transition-mask.vue';

const { isLoading, handleUpdate } = useLoading();
</script>

現在讓我們把 loading-overlay 加到 App.vue 並使用 use-loading,實測看看過場效果。

src\App.vue

<template>
  <router-view />
  <loading-overlay />
</template>

<script setup lang="ts">
import { ref } from 'vue';

import LoadingOverlay from './components/loading-overlay.vue';

import { useLoading } from './composables/use-loading';

const loading = useLoading();

setTimeout(() => {
  loading.show();
  setTimeout(() => {
    loading.hide();
  }, 2000);
}, 2000);

document.title += ` v${import.meta.env.PACKAGE_VERSION}`;
</script>

大家一定會覺得很奇怪,怎麼會是 2 秒後忽然全白,再 2 秒後白色消失,就這樣?(⊙_⊙)

別擔心,那是因為我們還沒在 transition-mask 中加入轉場用 class,所以只會瞬間進入、瞬間消失。

現在讓我們加上轉場用 class。

src\components\transition-mask.vue

...

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

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

現在是不是變成 2 秒後漸入全白,再 2 秒後全白漸出呢?這就表示 loading 功能正常運作了!

現在讓我們在 transition-mask 加入預期的轉場 class 吧!♪(´▽`)

src\components\transition-mask.vue

...

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

<style scoped lang="sass">
.round-enter-active
  animation-duration: 1.4s

.round-leave-active 
  transition-duration: 0.4s
  transition-timing-function: ease-in-out

.round-enter-from, .round-enter-to
  animation-name: round-in
  animation-fill-mode: forwards
@keyframes round-in
  0%
    clip-path: circle(3% at 46% -50%)
    animation-timing-function: cubic-bezier(0.005, 0.920, 0.060, 0.99)
  40%
    clip-path: circle(3% at 50% 50%)
    animation-timing-function: cubic-bezier(0.630, -0.170, 0.140, 0.980)
  100%
    clip-path: circle(70.7% at 50% 50%)

.round-leave-from
  clip-path: circle(70.7% at 50% 50%)
.round-leave-to 
  clip-path: circle(40% at 140% 140%)
</style>

目前過場動畫應該會如下圖。

ezgif-2-afa797e9a5.gif

大成功!ヽ(✿゚▽゚)ノ

助教:「只有白色的畫面,還敢端出來啊?(´。_。`)」

鱈魚:「當然不是只有這樣 ( •̀ ω •́ )✧,接下來讓我們加入 QQ 的載入畫面吧!」

總結

  • 自定義 loading 效果
  • 透過 Composition API 包裝 loading 功能。

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

GitLab - D07


上一篇
D06 - 打造遊戲選單按鈕:利用 SVG 產生文字外框
下一篇
D08 - 載入就應該要有載入的樣子
系列文
派對動物嗨起來!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言