Vue.jsでCanvasを使ったローディングアニメーション作成


概要

Vue.js(Nuxt.js) + Vuetifyで開発している自社サイトに、オリジナルのローディング画面を作るためにCanvasを利用してみました。

できあがったものはこちら。

Canvasの利用方法

要素のレンダリング

今回はVue.jsを使っているので、<template>内に<canvas>タグを置きます。ここではテンプレートエンジンにpugを使っています。

<template lang="pug">
canvas(
  width="200px"
  height="30px"
  ref="canvas"
)
</template>

refを貼ることでJavaScript側からアクセス可能にします。

コンテキストの取得

Canvasを利用するには、<canvas>要素からgetContextメソッドでCanvasを描画するためのコンテキストと呼ばれるオブジェクトを取得します。

this.ctx = this.$refs.canvas.getContext("2d");

よくインターネット上のコードでctxという変数に格納されるため、便乗してctxにしました。

最終的にローディング画面ごとVueのコンポーネントにまとめるので、this.ctxに保存しています。ローカル変数ではなくてメンバ変数に格納することでメソッドを切り分けても再度getContextしなくていいようにしています。

ドットの描き方

今回僕が思いついたローディング画面は、ドットが足跡のように画面の左から右まで進んでいくというものです。したがって構成要素としてドットを描画できる必要があります。
ドットは実質「円」なので、下記の書き方で実現できます。

    writeDot(step) {
      this.ctx.fillStyle = '#239348';
      this.ctx.beginPath();
      this.ctx.arc(
        200 - (170 / 6) * (6 - (step - 1)),
        step % 2 === 1 ? 10 : 20,
        5,
        0,
        Math.PI * 2
      );
      this.ctx.fill();
    },

fillStyleで色をまず指定して、beginPathをおまじないで実行しておき、arcで円を描きます。最後にfillを実行することで描いた円を塗りつぶします。

arcの引数はそれぞれX座標、Y座標、直径、描き始めの角度と書き終わりの角度です。角度の単位は高校数学あたりで履修するラジアン単位なので要注意ですが、円を描く場合はこの2つを渡すと覚えておけば差し支えないです。

座標の数式がちょっとややこしいですが、要は左から右に向かって一歩ずつドットを打ちながら、一歩ごとに少し上下にずれるのを表現しています。引数で現在の歩数であるstepを受け取ってその歩数に従った箇所に描画します。

Canvasへの描画をアニメーションさせる

setIntervalを使って先程のwriteDotメソッドを定期実行することで、一歩ずつ進んでいくようなローディングアニメーションを作成できます。

        let step = 1;
        this.canvasLoopId = setInterval(() => {
          if (step > 6) {
            this.ctx.clearRect(0, 0, 200, 30);
            step = 1;
            return;
          }
          this.writeDot(step++);
        }, 200);

stepが現在の歩数です。だいたい200ミリ秒ごとに1歩にするといい感じだったのでそのように設定し、6歩進むごとに画面全体をclearRectすることでリセットしてやりなおし、というようにやっています。

アニメーションの開始と終了

ローディングを表示するコンポーネントなので、開始と終了をコンポーネントの外から指定できるといいのですが、コンポーネントの外から中のメソッドを直接実行するのは無理があるので、propsstateを渡してwatchによってトリガを引くというようにしてみました。

  props: {
    state: {
      type: Boolean,
      required: true
    }
  },
  watch: {
    state(newState) {
      if (newState) {
        this.start();
      } else {
        this.stop();
      }
    }
  },

コンポーネントの外から$refs.ref.hoge()するようなやり方を提示するサイトもありますが、外のコンポーネントが内側のメソッド名に至るまで知っていないといけないのは運用上リスクが高いので、propsベースでやってみようと思いました。

ダイアログ形式で表示

ローディング画面をダイアログで表示させるためにVuetifyv-dialogを利用しました。

<template>タグの中身の全体はこのようになります。

<template lang="pug">
v-dialog(
  persistent
  width="200px"
  height="60px"
  :value="state"
)
  .loading.pt-3.pb-1
    p.mb-2.text-center 検索中...
    canvas(
      width="200px"
      height="30px"
      ref="canvas"
    )
</template>

v-dialogvalueにBooleanを渡すことで表示/非表示を切り替えることができるので、それに伴ってアニメーションを開始します。先程のwatchとの連携です。

.loadingクラスには別途scoped scssでスタイルを当てています。

余談ですが画面中央にモーダルを出すなどは地味に労力がかかるので、そういった基礎的かつ面倒な部分をUIフレームワークに投げられるのは便利だなと思いました。

ランダムにドットの色を変える

最後に、ドットの色をランダムにするために専用の関数を作りました。

    getRandomColor() {
      const getRandomInt = max => {
        return Math.floor(Math.random() * Math.floor(max));
      };
      const r = (getRandomInt(180) + 70).toString(16);
      const g = (getRandomInt(70) + 180).toString(16);
      const b = (getRandomInt(100) + 150).toString(16);
      return `#${r}${g}${b}`;
    }

こだわりとして、RGBのバランスを崩すことでグレーが強くならないようにするのと、全体的に明るめにすることでパステル調で可愛らしくなるように調整しています。

先程のwriteDotメソッドを以下のように書き換えることで、ドットごとに色を変えることができます。

this.ctx.fillStyle = this.getRandomColor();

最終結果

全体のコードを貼るとこんな感じになっています。

最後の最後に非常に重要なことを言いますと、start関数内でthis.$nextTickを使っていることが欠かせないポイントです。

ちょうどwatchをトリガーにstartが実行されたタイミングかつ、v-dialogでCanvasが表示されようとしているところなので、$nextTickを噛まさずに進めると$refsからcanvasが取得できずエラーになります。ご注意を。

<template lang="pug">
v-dialog(
  persistent
  width="200px"
  height="60px"
  :value="state"
)
  .loading.pt-3.pb-1
    p.mb-2.text-center 検索中...
    canvas(
      width="200px"
      height="30px"
      ref="canvas"
    )
</template>

<script>
export default {
  props: {
    state: {
      type: Boolean,
      required: true
    }
  },
  watch: {
    state(newState) {
      if (newState) {
        this.start();
      } else {
        this.stop();
      }
    }
  },
  methods: {
    start() {
      let step = 1;
      this.$nextTick(() => {
        this.ctx = this.$refs.canvas.getContext("2d");
        this.clearCanvas();
        this.canvasLoopId = setInterval(() => {
          if (step > 6) {
            this.clearCanvas();
            step = 1;
            return;
          }
          this.writeDot(step++);
        }, 200);
      });
    },
    clearCanvas() {
      this.ctx.clearRect(0, 0, 200, 30);
    },
    writeDot(step) {
      this.ctx.fillStyle = this.getRandomColor();
      this.ctx.beginPath();
      this.ctx.arc(
        200 - (170 / 6) * (6 - (step - 1)),
        step % 2 === 1 ? 10 : 20,
        5,
        0,
        Math.PI * 2
      );
      this.ctx.fill();
    },
    stop() {
      clearInterval(this.canvasLoopId);
    },
    getRandomColor() {
      const getRandomInt = max => {
        return Math.floor(Math.random() * Math.floor(max));
      };
      const r = (getRandomInt(180) + 70).toString(16);
      const g = (getRandomInt(70) + 180).toString(16);
      const b = (getRandomInt(100) + 150).toString(16);
      return `#${r}${g}${b}`;
    }
  },
  data() {
    return {
      canvasLoopId: null
    };
  }
};
</script>


<style lang="scss" scoped>
@import "@/assets/css/main.scss";

.loading {
  width: 200px;
  background-color: $white;

  display: flex;
  flex-direction: column;
  justify-content: center;
}
</style>

参考文献