Vue.js/Nuxt.jsでパララックス効果を実装

Vue/Nuxtでパララックスしようぜ！

※パララックス効果とは：スクロールしたときに、スクロール量に合わせてずれたり動いたりするやつ。

こういうやつ
![sukiyaki2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/4bd70709-cd9a-3834-65cc-d843fb999909.gif)

Vue.jsプラグインもいくつかあるけど、自前で実装した方が自由度高くて楽だった。勉強にもなるし。

# 実装方法概要

実装方法の大まかな説明

- scrollイベントをlisten
- スクロール量やら色々パラメータを取って移動量を計算
- imgのstyleに `object-position: {移動量X}% {移動量Y}%` をぶち込む
  - ※`object-position`を知らない方は先に [事前知識 : CSS object-position について](#%E4%BA%8B%E5%89%8D%E7%9F%A5%E8%AD%98--css-object-position-%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6) をどうぞ。
  - もちろん、 `background-position: {移動量X}% {移動量Y}%` プロパティでも可能です。

```index.vue
<template>
  <img
    ref="backgroundImg"
    class="background-img"
    src="~/assets/img/background.png"
    :style="`object-position: 50% ${objPosY}%;`"
  >
</template>

<script>
export default {
  data() {
    return {
      objPosY: 0
    }
  },
  mounted(){
    window.addEventListener('scroll', this.calculateScrollY) // スクロールイベントのlisten
  },
  methods: {
    calculateScrollY() {
      // this.objPosY に計算した移動量をぶち込む
    }
  }
}
```

以上の実装方法は、場合によって移動量の計算方法が異なる。
ページのトップにいてスクロールをしていないときに、画面上に画像が見えているか見えていないかで、計算方法を２パターン用意した。

1. img要素が完全に隠れている場合。（スクロールしないとimg要素が現れない場合）
2. img要素が最初から見えている場合。（スクロールしなくてもimg要素が少しでも見えていて、これからスクロールする場合。）

# 1. img要素が完全に隠れている場合

imgタグに以下のようにstyle属性を追加する。

```js
:style="`object-position: 50% ${objPosY}%;`"
// または :style="`background-position: 50% ${objPosY}%;`"
```

CSSの `object-position`（または`background-position`）を使用すると、以下のように画像の位置が変えられる。そのY軸の移動量が `objPosY`。これを制御したい。
-50%とか、150%とかの状態にならないように、0%から100%までで動かしたい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/47d64962-9b0f-73a3-e7f9-e2aaaa8f808f.png)

img要素が完全に隠れている場合、実装したい画像の移動量 `objPosY` の仕様は、以下の通り。

- スクロールして要素が見え始めた時は、移動量が0%である。  
  = ブラウザの**表示領域のbottom**が、**img要素のtop** に到達した時
- スクロールして要素が見えなくなった時は、移動量が100%である。  
  = ブラウザの**表示領域のtop**が、**img要素のbottom** に到達した時

以上のように、スクロール量に合わせて`objPosY`の値が、0から100まで動けばよい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/3e3b9290-5300-f3bf-7cf6-15ff75913b5b.png)

## 1.1 要素が見え始めた時にスクロール量が0となる基準変数を作る

`getBoundingClientRect()` を使ってスクロール量を取得し、新たに変数 `topVisibleScrollY` を定義した。

```js
const rect = this.$refs.backgroundImg.getBoundingClientRect();
console.log(rect.top) // ブラウザの表示領域を基準とする、img要素の絶対座標(top) スクロールによって可変

const innerHeight = window.innerHeight // ブラウザの表示領域の高さ。

const topVisibleScrollY = -rect.top + innerHeight // 要素が見え始めた時にスクロール量が0。スクロールによって可変
console.log(topVisibleScrollY)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/56439b8d-228f-689b-40e3-ca3da92e038e.png)

`getBoundingClientRect().top` は、ブラウザの表示領域を基準とする、img要素の相対座標である。
つまり、ブラウザの**表示領域のtop**と**img要素のtop**が重なった時に0になる。
「img要素のtop」を「img要素のbottom」に変えるために、img要素の高さを引いてやれば良い。

ただ、`getBoundingClientRect().top` は、下へスクロールするごとに値が小さくなっていくので、マイナスにして正負反転させていることに注意。こうすることで、imgが画面内に現れた後、下へのスクロールによって`topVisibleScrollY`の値は正の方へ増えていく。

これで、「スクロールして要素が見え始めた時は、移動量が0%である。」の条件がクリアできたので、今度は**「じゃぁ`topVisibleScrollY`が何pxのときに100%とみなせばいいのか」**を求めていきます。

## 1.2 `topVisibleScrollY`が何pxのときに100%とみなせばいい？

要素が見えなくなった時に `topVisibleScrollY` が何pxかがわかれば良い。
要素が見え始めた時を0としたスクロール量が`topVisibleScrollY`だから、計算は簡単。

答えは、**img要素の高さ**と**表示領域の高さ**を足したやつになる。

```js
// img要素の高さ
const height = this.$refs.backgroundImg.clientHeight

// `topVisibleScrollY`が何pxのときに100%とみなせばいいか
const bottomVisibleEndY = height + innerHeight 
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/dd5f03ad-6a9d-5508-66a3-59e2fb0f0687.png)


## 1.3 要素が見えなくなった時に、移動量が100%となるやつを作る

`topVisibleScrollY` が `bottomVisibleEndY` に達した時に100になるようにすればいい。

```js
this.objPosY = topVisibleScrollY / bottomVisibleEndY * 100 // 見え始めた時は0、見えなくなった時100
```

`topVisibleScrollY` も `bottomVisibleEndY` も単位がピクセル量なのでパーセントに変換。
おしまい！

## 1.4 全体のコード

```index.vue
<template>
  <img
    ref="backgroundImg"
    class="background-img"
    src="~/assets/img/background.png"
    :style="`object-position: 50% ${objPosY}%;`"
  >
</template>

<script>
export default {
  data() {
    return {
      objPosY: 0
    }
  },
  mounted(){
    window.addEventListener('scroll', this.calculateScrollY) // removeEventListenerは別で書きましょう。
  },
  methods: {
    calculateScrollY() {
      const rect = this.$refs.backgroundImg.getBoundingClientRect();
      const innerHeight = window.innerHeight // 表示領域の高さ
      const topVisibleScrollY = -rect.top + innerHeight // 要素が見え始めた時にスクロール量が0。スクロールによって可変。

      const height = this.$refs.backgroundImg.clientHeight // img要素の高さ
      const bottomVisibleEndY = height + innerHeight // topVisibleScrollYが何pxのときに100%とみなせばいいか

      this.objPosY = topVisibleScrollY / bottomVisibleEndY * 100 // 見え始めた時は0、見えなくなった時100
    }
  }
}
</script>
<style>
.background-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  overflow: hidden;
}
</style>
```

# 2. img要素が最初から見えている場合

この場合、imgが上側へ隠れることがないので、これまでの実装方法だと移動量が0%になることがない。スクロールしてない状態でも、40%とか30%とかになってしまう。

スクロールしてない状態なら0%にしたい。ということで以下に変えれば解決。

```js
  calculateScrollY() {
    const scrollY = document.body.scrollTop;
    const rect = this.$refs.backgroundImg.getBoundingClientRect();
    const height = this.$refs.backgroundImg.clientHeight
    const bottomVisibleEndY = height + rect.top + scrollY
    this.objPosY = scrollY / bottomVisibleEndY * 100
  }
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/918093f9-5e21-be48-6911-0d5118705cd4.png)

スクロール量については、途中から0で始める必要がなくなったので、rect.topをやめて `window.scrollTop` に変更。
`bottomVisibleEndY`（scrollTopが何pxのときに100%とみなせばいいか） は**img要素の高さ**と**img要素のY座標**を足し合わせればOK。
**img要素のY座標**は `rect.top + scrollY` で求められます。

# 事前知識 : CSS `object-position` について

今回は CSS の `object-position` を使って画像をずらしていたのでその説明。
参考：https://developer.mozilla.org/ja/docs/Web/CSS/object-position

画像の width と height を指定した上で、画像自体は cover にしたい時、よく使われるのは以下のようなコード。
backgroundに画像を指定する方法である。

```html
 <div class="hoge-img">
```
```css
.hoge-img {
  /* img要素ではなくdiv要素とかにつける */
  width: 100px;
  height: 100px;
  background: url('image.png') no-repeat;
  background-position: 50% 50%;  /* center; でも可 */
  background-size: cover;
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180775/683ac73f-c713-6ea6-1961-ae60e84f19ff.png)

ただこの方法は、CSSに画像のパスを配置する必要がある。なので、パスが動的に変わるような場合は、imgタグを使いたいときもある。
その際に登場するのが、`object-fit` と `object-position`。

```html
 <img src="画像パス" alt="" class="hoge-img">
```
```css
img.hoge-img {
  /* img要素につける */
  width: 100px;
  height: 100px;
  object-fit: cover;
  object-position: 50% 50%;  /* center; でも可 */
  overflow: hidden;
}
```
これでimg要素でも自由なサイズにクリッピングできる。ちなみにIEでは使えません。
https://caniuse.com/#feat=object-fit

今回は`object-position`を使ったが、`background-position`を使っても同様にパララックスできる。
こうすればいいだけ↓↓↓

```index.vue
<template>
  <div
    ref="backgroundImg"
    class="background-img"
    :style="`background-position: 50% ${objPosY}%;`"
  >
</template>
...
```

# ついでに：addEventListenerについて
`mixins.js`をpluginsフォルダにつくって、nuxt.config.jsに`plugins: ['~/plugins/mixins'],`とやる。
これでEventListenerを使う時は、`this.listen(window, 'scroll', this.calculateScrollY)` とするだけでよい。

```~/plugins/mixins.js
import Vue from 'vue'

Vue.mixin({
  destroyed() {
    if (this._eventRemovers) {
      this._eventRemovers.forEach(function(eventRemover) {
        eventRemover.remove()
      })
    }
  },
  methods: {
    listen(target, eventType, callback) {
      if (!this._eventRemovers) {
        this._eventRemovers = []
      }
      target.addEventListener(eventType, callback)
      this._eventRemovers.push({
        remove() {
          target.removeEventListener(eventType, callback)
        }
      })
    }
  }
})
```
remove()をいちいち書かなくて良いの天才すぎる。以下から参考にさせていただきました。(多分)

- [Nuxt.jsで異なるコンポーネントから共通で利用できる関数を定義する（mixin編） - Qiita ](https://qiita.com/itouuuuuuuuu/items/67afb038e2af5956148e)
- [ Vue.js 外側をクリックすると閉じるドロップダウンメニュー - Qiita ](https://qiita.com/huigo/items/a888dcee8f46d62bb9e3)
  - ここでイベントリスナーの知見を得た。あざます。

PS : Twitterで「パララックス、SafariとiOSで動かなかったはずでは?」って言われてビビってましたが、実装方法の勘違いだったようです。 Safariでも動作します。