スクロールに連動したコマ送りアニメーションを作る

## はじめに
LP作成の際以下のgifのような、スクロールに連動してパラパラ漫画の様に画像が連続で切り替わるコマ送りアニメーションを実装しました。
タイトルやプログレスサークルを出したり、フェードアニメーションを入れたりと要件が複雑であったため、ライブラリを利用せずTypescriptで実装しました。
実際に実装したものはカスタムイベントや[Object.defineProperty](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)を利用した状態監視のようなこともしていますが、ここではアニメーションの根本的な実装に絞って紹介します。

:::note
この記事で出来上がるものは以下で試せます。
https://codesandbox.io/p/sandbox/scrollframeadvanceanimation-qt7qt4
:::

![ezgif.com-optimize.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482641/a38791a2-c97a-8951-49e1-b664744ba3a8.gif)

## 紹介するもの
以下のような特徴を持つコマ送りアニメーションを実装します。
- スクロールに連動して画像が切り替わる
- 画像はTypescriptで読み込む
- HTML側に`canvas`要素を置いておき、その要素にTypescriptで画像を描画する
- ページ着地時の負荷軽減のため、最初は画像を間引いて読み込む
  - 後ほど間引かれた画像が読み込まれる

## 実装
### 動くものを作る
まずは画像を読み込む実装です。
画像の名前が 1.png, 12.png, 123.png のようになっていると仮定しています。
この例では 1.png〜76.png の画像を使用します。

```ts
const FILE_PATH = 'any imagePath'
const NUM_OF_IMAGES = 76 // 画像の枚数

//逃げのグローバルlet変数で失礼します🙏
let imageNames: string[] = []
let images: Promise<HTMLImageElement | undefined>[] = []

const imageLoad = (src: string): Promise<HTMLImageElement> => {
  return new Promise((resolve, reject) => {
    const image = new Image()
    image.onload = (): void => {
      resolve(image)
    }
    image.onerror = (): void => {
      reject()
    }
    image.src = src
  })
}

const loadInitialImages = () => {
  imageNames = [...Array(NUM_OF_IMAGES)].map(
    (_, index) => `${index + 1}.png`
  )

  images = imageNames.map(async imageName => {
    const image = await imageLoad(`${FILE_PATH}${imageName}`)
    return image
  })
}

loadInitialImages()
```

続いてスクロールを検出し、画像を切り替えながら描画する部分の実装です。
`SWITCH_IMAGE_AMOUNT`で大体どれくらいのスクロール量で画像が切り替わるかを調整しています。
次のHTMLの様に`data-frame-advance-target`というアトリビュートを持つ`canvas`要素を用意しておきます。


```ts
const SWITCH_IMAGE_AMOUNT = 30
const canvas = document.querySelector<HTMLCanvasElement>(
  '[data-frame-advance-target]'
)
const ctx = canvas?.getContext('2d')

const drawCanvasImage = (index: number) => {
  if (!canvas || !ctx) {
    return
  }

  images[index].then(image => {
    if (!image) {
      return
    }

    ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
  })
}

const frameAdvanceAnimation = () => {
  const index = Math.floor(window.scrollY / SWITCH_IMAGE_AMOUNT)
  const currentIndex = index >= NUM_OF_IMAGES ? NUM_OF_IMAGES - 1 : index

  drawCanvasImage(currentIndex)
}

const initializeFrameAdvanceAnimation = (): void => {
  frameAdvanceAnimation() // スクロールするまで画像が表示されないためここで一度呼び出す

  window.addEventListener('scroll', frameAdvanceAnimation)
}

initializeFrameAdvanceAnimation()
```

以下のようなHTML要素を持たせたHTMLファイルで、上記TypescriptファイルをビルドしたJavascriptファイルを読み込むとコマ送りアニメーションが動作するかと思います。
今回の例では16：9の画像を想定しています。

```html
<div style="height: 10000px" data-canvas-container>
  <canvas
    style="position: fixed"
    width="640"
    height="360"
    data-frame-advance-target
  ></canvas>
</div>
```


ここまでのTypescriptファイルをひとまとめにした物が以下です。

```ts
const FILE_PATH = 'any imagePath'
const NUM_OF_IMAGES = 76 // 画像の枚数
const SWITCH_IMAGE_AMOUNT = 30
const canvas = document.querySelector<HTMLCanvasElement>(
  '[data-frame-advance-target]'
)
const ctx = canvas?.getContext('2d')

//逃げのグローバルlet変数で失礼します🙏
let imageNames: string[] = []
let images: Promise<HTMLImageElement | undefined>[] = []

const drawCanvasImage = (index: number) => {
  if (!canvas || !ctx) {
    return
  }

  images[index].then(image => {
    if (!image) {
      return
    }

    ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
  })
}

const frameAdvanceAnimation = () => {
  const index = Math.floor(window.scrollY / SWITCH_IMAGE_AMOUNT)
  const currentIndex = index >= NUM_OF_IMAGES ? NUM_OF_IMAGES - 1 : index

  drawCanvasImage(currentIndex)
}

const initializeFrameAdvanceAnimation = (): void => {
  frameAdvanceAnimation() // スクロールするまで画像が表示されないためここで一度呼び出す

  window.addEventListener('scroll', frameAdvanceAnimation)
}

const imageLoad = (src: string): Promise<HTMLImageElement> => {
  return new Promise((resolve, reject) => {
    const image = new Image()
    image.onload = (): void => {
      resolve(image)
    }
    image.onerror = (): void => {
      reject()
    }
    image.src = src
  })
}

const loadInitialImages = () => {
  imageNames = [...Array(NUM_OF_IMAGES)].map(
    (_, index) => `${index + 1}.png`
  )

  images = imageNames.map(async imageName => {
    const image = await imageLoad(`${FILE_PATH}${imageName}`)
    return image
  })
}

loadInitialImages()
initializeFrameAdvanceAnimation()
```

### ページ着地時の読み込み負荷を減らす
現状の実装では、コマ送りアニメーションで使用するすべての画像をページ着地時に読み込んでいます。
そのため、読み込む画像の枚数が多かったり、画像サイズが大きかったりするとページ着地時の読み込み負荷が高く、読み込みに時間がかかってしまいます。
これを解消するため、ページ着地時にすべての画像を読み込むのではなく、半分に間引いて読み込むように変更します。

:::note warn
間引きすぎるとアニメーションに影響が出る可能性があるので注意が必要です
:::

```diff_typescript
const loadInitialImages = () => {
  imageNames = [...Array(NUM_OF_IMAGES)].map(
    (_, index) => `${index + 1}.png`
  )

-  images = imageNames.map(async imageName => {
+  images = imageNames.map(async (imageName, index) => {
-   const image = await imageLoad(`${FILE_PATH}${imageName}`)
+   const image = index % 2 === 0 ? await imageLoad(`${FILE_PATH}${imageName}`) : undefined
    
    return image
  })
}
```

また、現在表示されている画像の前後数枚の画像も予め読み込むように変更します。

```diff_typescript
+ const loadAdditionalImages = (index: number) => {
+  // ここでは現在表示されいる画像の前後20枚ずつ読み込む
+  const previousImageIndex = index - 20 >= 0 ? index - 20 : 0
+  const nextImageIndex =
+    index + 20 < NUM_OF_IMAGES ? index + 20 : NUM_OF_IMAGES - 1
+
+  for (let index = previousImageIndex; index <= nextImageIndex; index++) {
+    images[index].then(image => {
+      // 既に読み込み済みの場合はとばす
+      if (image) {
+        return
+      }
+
+      const loadedImage = imageLoad(`${FILE_PATH}${imageNames[index]}`)
+      images[index] = loadedImage
+    })
+  }
+ }

const frameAdvanceAnimation = () => {
  const index = Math.floor(window.scrollY / SWITCH_IMAGE_AMOUNT)
  const currentIndex = index >= NUM_OF_IMAGES ? NUM_OF_IMAGES - 1 : index

+ loadAdditionalImages(currentIndex)
  drawCanvasImage(currentIndex)
}
```

これでページ着地時の読み込み負荷も考慮しつつ、アニメーションへの影響も抑えた実装が出来たかと思います。
以下が今回のすべてのコードとなります。

```ts
const FILE_PATH = 'any imagePath'
const NUM_OF_IMAGES = 76 // 画像の枚数
const SWITCH_IMAGE_AMOUNT = 30
const canvas = document.querySelector<HTMLCanvasElement>(
  '[data-frame-advance-target]'
)
const ctx = canvas?.getContext('2d')

//逃げのグローバルlet変数で失礼します🙏
let imageNames: string[] = []
let images: Promise<HTMLImageElement | undefined>[] = []

const drawCanvasImage = (index: number) => {
  if (!canvas || !ctx) {
    return
  }

  images[index].then(image => {
    if (!image) {
      return
    }

    ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
  })
}

const loadAdditionalImages = (index: number) => {
  // ここでは現在表示されいる画像の前後20枚ずつ読み込む
  const previousImageIndex = index - 20 >= 0 ? index - 20 : 0
  const nextImageIndex = index + 20 < NUM_OF_IMAGES ? index + 20 : NUM_OF_IMAGES - 1

  for (let index = previousImageIndex; index <= nextImageIndex; index++) {
    images[index].then(image => {
      // 既に読み込み済みの場合はとばす
      if (image) {
        return
      }

      const loadedImage = imageLoad(`${FILE_PATH}${imageNames[index]}`)
      images[index] = loadedImage
    })
  }
}

const frameAdvanceAnimation = () => {
  const index = Math.floor(window.scrollY / SWITCH_IMAGE_AMOUNT)
  const currentIndex = index >= NUM_OF_IMAGES ? NUM_OF_IMAGES - 1 : index

  loadAdditionalImages(currentIndex)
  drawCanvasImage(currentIndex)
}

const initializeFrameAdvanceAnimation = (): void => {
  frameAdvanceAnimation() // スクロールするまで画像が表示されないためここで一度呼び出す

  window.addEventListener('scroll', frameAdvanceAnimation)
}

const imageLoad = (src: string): Promise<HTMLImageElement> => {
  return new Promise((resolve, reject) => {
    const image = new Image()
    image.onload = (): void => {
      resolve(image)
    }
    image.onerror = (): void => {
      reject()
    }
    image.src = src
  })
}

const loadInitialImages = () => {
  imageNames = [...Array(NUM_OF_IMAGES)].map(
    (_, index) => `${index + 1}.png`
  )

  images = imageNames.map(async (imageName, index) => {
    const image = index % 2 === 0 ? await imageLoad(`${FILE_PATH}${imageName}`) : undefined
    return image
  })
}

loadInitialImages()
initializeFrameAdvanceAnimation()
```


```html
<div style="height: 10000px" data-canvas-container>
  <canvas
    style="position: fixed"
    width="640"
    height="360"
    data-frame-advance-target
  ></canvas>
</div>
```