Vue.jsでスクロール中はコンパクトになるヘッダーを実装してみた

Vue.jsを採用したWebアプリケーションで実装しているUIのサンプルです。

いわゆるTabBarがあるのにヘッダーをも追従させたいとのことで、画面が狭くならないように配慮した動作を実装しました。

[DEMOはこちら(jsdo.it)](http://jsdo.it/yd_niku/cRkq)

iOSのFacebookアプリ等のNavigationBarの挙動を参考に、下記のようなシナリオで条件を設計しました。

下にスクロールしているとき　⇒　コンテンツを読んでいるのでヘッダーが邪魔
上にスクロールしているとき　⇒　メニュー等に戻りたいのでヘッダーが欲しい
ページ上部をタップ　⇒　任意のタイミングでヘッダーを引き出したい

## マウントするHTML

前提としてサンプルでは下記のようにミニマムな構成で作成しています。
またCSSでレイアウトが指定してあり、ヘッダーの通常の高さは `60px` にしてあります。

```html
<div id="app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.4/vue.min.js"></script>
<script>
// ここにヘッダーを定義

// アプリケーションを定義
const App = new Vue({
  el: '#app',
  // ここにスクロールの処理を定義
});
</script>
```
　
## ヘッダーコンポーネントの実装
 
最初はシンプルに開閉のみのロジックを組みます。
開いているときは高さが `60px`、閉じているときは `0px` にします。
ヘッダーの中にあるボタンはアニメーションして消えるようにしました。

```js
const HEADER_MAX_HEIGHT = 60;
Vue.component('global-header', {
  props: {
    isOpen: {
      type: Boolean,
      default: true,
    },
  },
  computed: {
    height() {
      return this.isOpen ? HEADER_MAX_HEIGHT : 0;
    },
    itemStyle() {
      const opacity = this.isOpen ? 1 : 0;
      const scale   = this.isOpen ? 1 : 0;
      return {
        height: `${this.height}px`,
        transform: `scale(${scale}, ${scale})`,
        opacity,
      };
    },
    //
    headStyle() {
      return {
        height: `${this.height}px`,
        background: this.isOpen ? '#fff' : '#fefef0',
      };
    },
  },
  template: '\
    <header\
      :style="headStyle">\
      <span\
        :style="itemStyle"\
        class="menu-icon">MENU</span>\
    </header>',
});
```
素直にウインドウのスクロール量を取得し、スクロール量の向きを計算しています。
`scrollDirection` が `0` より小さければ下方向、大きければ上方向にスクロールという判断ができます。

計算した値をヘッダーコンポーネント `global-header` の `props` 経由で渡します。

```js
const App = new Vue({
  el: '#app',
  data: {
    scrollY: 0,
    prevScrollY: 0,
    scrollDirection: 0,
  },
  template: '\
    <div>\
      <global-header\
        :is-open="scrollDirection <= 0">\
      </global-header>\
      <div class="contents"></div>\
    </div>',
  mounted() {
    window.addEventListener('scroll', () => {
      this.prevScrollY = this.scrollY;
      this.scrollY = window.pageYOffset;
      this.scrollDirection = this.scrollY - this.prevScrollY;
    });
  }
});
```
これで一旦スクロール中は邪魔にならないヘッダーができました。

## スクロールに合わせて滑らかに追従させたい

Facebookアプリだと、スクロールし始めは NavigationBarの高さがスクロールに追従して縮小します。
実際ヘッダーがいきなり引っ込んでしまうと上部に余白ができたりして不格好なので、コンテンツエリアがスクリーン上部に達するまでは高さが追従するように改造してみます。

考え方としては、スクロール量(SV)がヘッダーの所定の高さ(60)より小さいときは 60 ~ 0 の間で実際の高さ(H)が追従し、 スクロール量が最大の所定の高さをこえると常に0になる様になります。

![Untitled.png](https://qiita-image-store.s3.amazonaws.com/0/3516/bbdf260f-4001-5287-4f58-d7902e6cf3f1.png)

H = 60 -SV (SV <= 60)
H = 0 (SV > 60)

60 ~ 0 を パーセントで表せるように 正規化します。

変化率 = (最大高さ - スクロール量) / 最大高さ

実際にはスクロールが60を超えると変化率がマイナスになってしまうので `Math.max(value, 0)` で補正します。

これで、下にスクロールしていて閉じているときの値を固定量ではなく、変化率の値に応じて 60 ~ 0で変化するようにしました。

```js
const HEADER_MAX_HEIGHT = 60;
Vue.component('global-header', {
  props: {
    scrollY: {
      type: Number,
      default: 0,
    },
    isOpen: {
      type: Boolean,
      default: true,
    },
  },
  computed: {
    percent() {
      const value = Math.max(0, HEADER_MAX_HEIGHT - this.scrollY);
      return value / HEADER_MAX_HEIGHT;
    },
    height() {
      return this.isOpen ? HEADER_MAX_HEIGHT : this.percent * HEADER_MAX_HEIGHT;
    },
    itemStyle() {
      const opacity = this.isOpen ? 1 : 0;
      const scale   = this.isOpen ? 1 : 0;
      return {
        height: `${this.height}px`,
        transform: `scale(${scale}, ${scale})`,
        opacity,
      };
    },
    //
    headStyle() {
      return {
        height: `${this.height}px`,
        background: this.isOpen ? '#fff' : '#fefef0',
      };
    },
  },
  template: '\
    <header\
      :style="headStyle">\
      <span\
        :style="itemStyle"\
        class="menu-icon">MENU</span>\
    </header>',
});
```

```html
// App
  <global-header\
    :scrol-y="scrollY"\
    :is-open="scrollDirection <= 0">\
```

## コンパクトにしたい

これだと上にスクロールしないとヘッダーがでてこないため、ちょっと不親切と思い、常にちっちゃいヘッダーを出しておくことにしました。

```js
    height() {
      return this.isOpen ? HEADER_MAX_HEIGHT : Math.min(this.percent * HEADER_MAX_HEIGHT, 30);
    },
```

ヘッダーの最小値を30にしてコンパクトになったように見せています。
またタップしたら `@click="isOpen = true"` とすれば任意のタイミングで開けるようにすることもできます。

冒頭で紹介した [最終版がこちら(jsdo.it)](http://jsdo.it/yd_niku/cRkq) になります。

## 追記:スクロール値がマイナスになる場合の対応

iOSのWebViewやトラックパッドでスクロールする場合、スクロール値がマイナスになるまで引っ張れるような動作をすることがあります。

ページの一番上まで引っ張ってから離すと、マイナスからゼロにスクロールするため、 `isOpen` が `false` になってしまい、ページの最上部なのにボタン類が消えてしまう状態になってしまいます。

そのため、スクロールの向きだけでなく、スクロール値がゼロ以下である場合も `isOpen` にする必要がありました。

```html
// App(修正版)
  <global-header\
    :scrol-y="scrollY"\
    :is-open="scrollY <= 0 || scrollDirection <= 0">\
```