IntersectionObserverを使いやすくしたい

この記事は [ここのえ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/99no_exit) Day 19の記事です。

# Intersection Observer すぐ必要になりがち

Webフロントエンドを書いていると、どうしても付きまとうのが「スクロールに応じて特定の動きをしたい」というヤツです。

これに対応するためよく使うのが、Intersection Observerです。ターゲットとなる要素が表示されたとき（＝ビューポートと交差した時）に、特定のアクションを呼び出すことができます。

ホームページを作る際にあまりにも頻繁に使うため、Typescriptベースでクラスにまとめて使いまわしています。

今回は `threshold` を用いた表示領域に応じての操作などは行っていないため、単純に表示された・消えた時にイベントが発生するシンプルな構成になっています。

# 実装

全体を先に出しておきます。
Typescriptを使っているため、JSの場合は読み替えて下さい。

https://github.com/nonuplet/rirfee/blob/main/resources/ts/utils/AppearEvent.ts

```ts
export class AppearEvent<T extends () => any> {
  el: HTMLElement
  isIntersecting: boolean
  observer: IntersectionObserver
  appearCallback: T[]
  disappearCallback: T[]
  mode: 'forward' | 'backward' | 'both'

  constructor(el: HTMLElement, mode?: 'forward' | 'backward' | 'both') {
    this.el = el
    this.isIntersecting = false
    this.observer = new IntersectionObserver(this.intersect.bind(this))
    this.observer.observe(el)
    this.appearCallback = []
    this.disappearCallback = []
    this.mode = mode === undefined ? 'both' : mode
  }

  intersect(entry: IntersectionObserverEntry[]) {
    if (entry[0].isIntersecting) {
      if (
        this.mode === 'both' ||
        (this.mode === 'forward' && entry[0].boundingClientRect.y > 0) ||
        (this.mode === 'backward' && entry[0].boundingClientRect.y <= 0)
      )
        this.appeared()
    } else {
      this.disappeared()
    }
    this.isIntersecting = entry[0].isIntersecting
  }

  addAppearEvent(callback: T) {
    this.appearCallback.push(callback)
  }

  clearAppearEvents() {
    this.appearCallback = []
  }

  addDisappearEvent(callback: T) {
    this.disappearCallback.push(callback)
  }

  clearDisappearEvents() {
    this.disappearCallback = []
  }

  clearEvents() {
    this.clearAppearEvents()
    this.clearDisappearEvents()
  }

  appeared() {
    for (const callback of this.appearCallback) {
      callback()
    }
  }

  disappeared() {
    for (const callback of this.disappearCallback) {
      callback()
    }
  }
}

```

# 解説

大前提として、Intersection Observerのイベントが起きるタイミングは「**ビューポートに対して指定した要素が交差した時**」です。
言い換えれば、要素が画面に表示・非表示になったタイミングで発生するとも言えます。

クラスのフィールドはよく使うものを置いています。
```ts
el: HTMLElement
isIntersecting: boolean
observer: IntersectionObserver
appearCallback: T[]
disappearCallback: T[]
mode: 'forward' | 'backward' | 'both'
```

コールバックを複数実装できるようにするため、`appearCallback[]`と`disappearCallback[]`を定義しておき、`addAppearEvent(callback: T)
`で追加できるようにしています。

`isIntersecting`については`IntersectionObserverEntry.isIntersecting` の値をそのまま取ってきて、クラス側に保持することでいつでも参照しやすいようにしています。

`mode`はスクロールイベントの発生条件を定義しており、特定の方向からスクロールされた時のみ発生させるか、どちらでも発生させるか判断するために使用しています。

ここで実装上問題になるのが、「上下方向どちらからスクロールされた？」という判定です。
IntersectionObserverはあくまで画面と交差したタイミングでコールバックが呼び出されるだけなので、その判定については自前で実装する必要があります。

```ts
intersect(entry: IntersectionObserverEntry[]) {
  if (entry[0].isIntersecting) {
    if (
      this.mode === 'both' ||
      (this.mode === 'forward' && entry[0].boundingClientRect.y > 0) ||
      (this.mode === 'backward' && entry[0].boundingClientRect.y <= 0)
    )
      this.appeared()
  } else {
    this.disappeared()
  }
  this.isIntersecting = entry[0].isIntersecting
}
```

今回の実装では、`InsersectionObserverEntry.boudingClientRect` を使って対象のy座標を特定し、それを判断材料に使用しています。

この`boudingClientRect`は、`getBoudingRect`で計算される値と同一のものです。オブジェクトの型としては`DOMRectReadOnly`型が返ってきますが、`DOMRectReadOnly.y`でオブジェクトのy座標を取得できます。

要はDOM要素の一番上のy座標が返ってくるのですが、この時の値は `viewport` に対しての相対値になるので、上から下のスクロールでは `y` はビューポートのheightに近い値に、下から上のスクロールでは上にはみ出している分だけマイナスの値が返ってきます。

```bash
# 下スクロール
DOMRectReadOnly {x: 0, y: 561.8125, width: 1146.375, height: 660.015625, top: 561.8125, …}

# 上スクロール
DOMRectReadOnly {x: 0, y: -659.09375, width: 1146.375, height: 660.015625, top: -659.09375, …}
```

これを利用して特定のスクロール向きのみに動作するモード(`forward`, `backward`)を設定した際に、それぞれ`y`の値が満たしているかを判断しています。

対して `disappear` については実装をシンプルにしており、`IntersectionObserverEntry.isIntersecting` をチェックするだけにしています。

# まとめ

Typescriptベースでライブラリ依存せず実装しているので、`Vue`でも`React`でも使えると思います。

ちなみにサイト側では「コールバックに`anime.js`の処理を書いておき、IntersectionObserverでアニメーションを開始する」といった用途で使用しています。

IntersectionObserverは高機能ですがちょっと取り扱いづらいので、ちょっとした用途なら雑に使えるようにまとめとくと楽だよね、という話でした。

# 参考

https://developer.mozilla.org/ja/docs/Web/API/IntersectionObserver