🌻

Vueでドラムロールを実装する

2021/07/13に公開

ドラムロールとは・・・
数字がじゃかじゃか回るやつです。

今回は数字が画面内に入ってきたら、数字のドラムロールが開始されるというアニメーションをプラグインを使わずに実装したいと思います。(Nuxt+Typescriptで開発しています。)

目次

最初に考えた方法
もうちょっと賢い方法
最終型を見たい方はもうちょっと賢い方法から見てください。

最初に考えた方法

まずはコードから。

<div data-destination="2000" class="number">0</div>
mounted() {
  const el = document.querySelector<HTMLElement>('.number')

  const entry = (intersectionObserverEntry: any) => {
    if (intersectionObserverEntry[0].isIntersecting) {
      if (el !== null) {
        this.counter(el)
        observer.unobserve(el)
      }
    }
  }

  const observer = new IntersectionObserver(entry)
  if (el !== null) observer.observe(el)
}

data(){
  return {
    duration: 1,
    fps: 60
  }
},

methods: {
  counter(el: HTMLElement) {
    let current = 0
    const destination = Number(el.dataset.destination)
    const diff = destination / (this.duration * this.fps)
    const intervalId = window.setInterval(() => {
      const next = current + diff
      let value
      if (next >= destination) {
        value = destination.toLocaleString()
        window.clearInterval(intervalId)
      } else {
        current = next
        value = (next | 0).toLocaleString()
      }
      el.innerHTML = value
    }, (1 / this.fps) * 1000)
  },
}

解説

まず、数字が画面内に入ってくるというイベントはIntersection Observerを利用して実装します。
scrollイベントでも同様のことはできますが、実装の手軽さとパフォーマンスの観点からIntersection Observerを利用したほうが良いそうです。(https://ics.media/entry/190902/)

監視する要素を取得し、画面内に要素が入ってきたらドラムロールを実行します。
スクロールで行ったり来たりする際に、監視したい要素が初めて画面内に入ってきたときのみイベントを実行したいので、一度イベントが発火したらobserver.unobserve()でイベントの削除を行っています。
これを書かないと要素が画面内に入るたびにイベントが発火します。

次に、ドラムロールの実装ですが数字をカウントアップしていくときに何秒間で(duration)、どれぐらいの滑らかさで(fps)実行したいかを考えます。
fpsとはframe per secondのことで1秒間に何回描画しているかということを表します。そのため、fpsの値が大きいほど描画回数が多くなり、滑らかに数字がカウントアップしていくように見えます。
durationとfpsを決めたら、1回の描画でいくつずつ数字をカウントアップする必要があるか考えなければいけません。
const diff = end / (this.duration * this.fps)では最終値に達するまでに1回の描画でいくつずつ数字をカウントアップする必要があるかを計算しています。
diffを出したらあとはひたすら前の数にdiffを足してカウントアップしていきますが、設定する値によってはdiffが小数になることもあるので、値を整数のみで表示したいときは小数点を切り捨てるように実装します。
また、今回は数字をカンマ表示にしたかったのでtoLocaleString()でキャストしています。
値が最終値に到達したらclearInterval()を入れていますが、これを忘れると無限にsetInterval()が実行されるので気をつけましょう。

もうちょっと賢い方法

続いて、もうちょっと賢い実装方法を見ていきます。

counter(el: HTMLElement) {
  const startTime = Date.now()
  const destination = Number(el.dataset.destination)
  const intervalId = window.setInterval(() => {
    const dt = Date.now() - startTime
    let value
    if (dt >= this.duration * 1000) {
      value = destination.toLocaleString()
      window.clearInterval(intervalId)
    } else {
      value = this.linear(dt, 0, destination, this.duration * 1000)
      value = (value | 0).toLocaleString()
    }
    el.innerHTML = value
  })
},
linear(t: number, b: number, c: number, d: number) {
  return t === d ? b + c : (c * t) / d
}

解説

一番最初に考えた実装では1描画あたりの変化量を計算し、前の値に変化量を足していくという考え方で実装していました。しかし、今回の実装では経過時間(現在の時間からスタートした時間を引いた値)を元に、その地点での値を計算するという考え方で行っています。
関数の引数であるtはスタートしてからの経過時間、bは初期値、cは最終値、dはアニメーションが終了するまでの時間を示しています。

この考え方で実装すると何がいいかというと、数字のカウントアップする速度を関数で柔軟に設定できるということです。このような速度の増減をコントロールする関数をイージング関数といいます。
上の例では一定の速度で値が変化するように作っているので、linearという名前で関数を作成しています。

イージングには色々ありますが、Robert Pennerという方の考案した関数が特に有名です。
イージング関数はこちらのサイトにのっている関数を使うと良いですが、ライセンスが付いているので利用する際には著作権表示を行いましょう。

例えば、終盤にかけてカウントする速度を落としたいときはイージング関数の中のeaseOutExpoがおすすめです。

下記は実際にeaseOutExpoを適用した例です。

counter(el: HTMLElement) {
  const startTime = Date.now()
  const destination = Number(el.dataset.destination)
  const intervalId = window.setInterval(() => {
    const dt = Date.now() - startTime
    let value
    if (dt >= this.duration * 1000) {
      value = destination.toLocaleString()
      window.clearInterval(intervalId)
    } else {
      value = this.easeOutExpo(dt, 0, destination, this.duration * 1000)
      value = (value | 0).toLocaleString()
    }
    el.innerHTML = value
  })
},

/*
 *
 * TERMS OF USE - EASING EQUATIONS
 *
 * Open source under the BSD License.
 *
 * Copyright © 2001 Robert Penner
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *
 * Redistributions of source code must retain the above copyright notice, this list of
 * conditions and the following disclaimer.
 * Redistributions in binary form must reproduce the above copyright notice, this list
 * of conditions and the following disclaimer in the documentation and/or other materials
 * provided with the distribution.
 *
 * Neither the name of the author nor the names of contributors may be used to endorse
 * or promote products derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 *  COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 *  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
 *  GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
 * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 *  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 */
easeOutExpo(t: number, b: number, c: number, d: number) {
  return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
},

先ほどの速度一定で実装したソースコードと比較すると、以下の部分のみが変わっています。

value = this.linear(dt, 0, destination, this.duration * 1000)
// ↓ 適用するイージング関数を変えただけ
value = this.easeOutExpo(dt, 0, destination, this.duration * 1000)

最後に

今のところこれが自分の中で一番いい実装方法ですが、もし他にもっと良い方法があればアップデートしたいと思います。

今回のサンプルコードはこちら↓
https://github.com/an-0305/drumroll-sample

Discussion