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

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

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ローディング画面の色をランダムにして全体的にパステル調？にしてみた <a href="https://t.co/z0QeGf97vW">pic.twitter.com/z0QeGf97vW</a></p>&mdash; 名人さん | ㈱NoSchool CTO (@Meijin_garden) <a href="https://twitter.com/Meijin_garden/status/1173894816398102530?ref_src=twsrc%5Etfw">September 17, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# Canvasの利用方法

## <canvas>要素のレンダリング

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


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

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

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

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

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

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

## ドットの描き方

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

```javascript
    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`メソッドを定期実行することで、一歩ずつ進んでいくようなローディングアニメーションを作成できます。

```javascript
        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`することでリセットしてやりなおし、というようにやっています。

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

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

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

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

## ダイアログ形式で表示

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

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

```vuejs
<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-dialog`の`value`にBooleanを渡すことで表示/非表示を切り替えることができるので、それに伴ってアニメーションを開始します。先程の`watch`との連携です。

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

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

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

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

```javascript
    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`メソッドを以下のように書き換えることで、ドットごとに色を変えることができます。

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

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

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

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

```javascript
<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>
```

# 参考文献
https://cr-vue.mio3io.com/examples/canvas.html#%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89

https://javascript.programmer-reference.com/js-canvas-arc/

https://marycore.jp/prog/js/convert-decimal-hex/

https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/arc