水平リサイザーコンポーネントを作る

# 始めに
管理画面やエディタでよくありがちな2つの領域をドラッグでサイズ調整をするコンポーネントを作る機会がありましたので、その辺の内容を記事にしました。
ついでに開閉できるようにもしています。

![Dec-24-2020 16-27-08.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187768/4314cb14-356f-55dc-f5c8-7304501676d7.gif)

# 実装方法
## 開く、閉じる処理
初期値の高さを0にして、その高さを直接指定してtransitionでアニメーションします。

```vue:HorizontalResizer
<template lang="pug">
.resizer(ref="elWrapper")
  .resizer__content.-top
    slot(name="top")
  transition(name="keep")
    template(v-if="$data.isOpenBottom")
      .resizer__separator
  .resizer__content.-bottom(
    ref="elBottom"
    :style="{\
      height: `${$data.bottomHeight}px`,\
    }"
  )
    slot(name="bottom")
</template>

<script>
import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      isOpenBottom: false,
      isDragging: false,
      bottomHeight: 0,
    }
  },
  methods: {
    /**
     * 下のコンテンツを開く
     * @param {number} height - 高さ（割合。0.0〜1.0）
     */
    openBottomByRate(heightRate) {
      if (this.$data.isOpenBottom) {
        return;
      }
      const clientRect = this.$refs.elWrapper.getBoundingClientRect();
      this.$data.isOpenBottom = true;
      this.$data.bottomHeight = heightRate * clientRect.height;
    },
    /**
     * 下のコンテンツを閉じる
     */
    closeBottom() {
      this.$data.isOpenBottom = false;
      this.$data.bottomHeight = 0;
    },
  },
});
</script>

<style lang="scss">
.resizer {
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  height: 100%;

  &__content {
    display: flex;
    flex-direction: column;
    overflow-x: hidden;
    overflow-y: auto;

    &.-top {
      flex: 1 1 0;
    }

    &.-bottom {
      transition: height 0.3s;
    }
  }

  &__separator {
    height: 3px;
    background-color: #ddd;
    cursor: row-resize;
  }
}

// コンポーネントをキープするために使う
.keep {
  &-leave {
    &-active {
      transition: all 0.3s;
    }
    
    &-to {
      //
    }
  }
}
</style>
```

閉じる時のポイントとして、境界線は閉じている時は非表示で良いですが、アニメーション中に消えてしまうのは少し変に感じてしまいます（できればアニメーションが終わってから消えて欲しいですよね）。そんな時は、特にtransitionの変化はしなくてもとりあえずtransitionでラップしておくことで指定した時間分は表示を維持してくれます。

```vue:要素をキープする
<template lang="pug">
.resizer(ref="elWrapper")
  //- 消える時に0.3s維持できるようにアニメーションしなくてもtransitionを設定する
  transition(name="keep")
    template(v-if="$data.isOpenBottom")
      .resizer__separator
</template>

<style lang="scss">
// コンポーネントをキープするために使う
.keep {
  &-leave {
    &-active {
      transition: all 0.3s;
    }
    
    &-to {
      // アニメーションするわけではないので未設定
    }
  }
}
</style>
```

## リサイズ処理
単純に境界線をドラッグした時に応じて下のコンテンツの高さを調整します。デフォルトでは高さにtransitionがついているため、ドラッグ中はつかないようにします。

```vue:HorizontalResizer
<template lang="pug">
.resizer(ref="elWrapper")
  .resizer__content.-top
    slot(name="top")
  transition(name="keep")
    template(v-if="$data.isOpenBottom")
      .resizer__separator(
        @mousedown="onDragStart"
      )
  .resizer__content.-bottom(
    ref="elBottom"
    :style="{\
      height: `${$data.bottomHeight}px`,\
      transition: $data.isDragging ? 'none' : '',\
    }"
  )
    slot(name="bottom")
</template>

<script>
import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      isOpenBottom: false,
      isDragging: false,
      bottomHeight: 0,
    }
  },
  mounted() {
    document.addEventListener('mousemove', this.onDrag);
    document.addEventListener('mouseup', this.onDragEnd);
  },
  beforeDestory() {
    document.removeEventListener('mousemove', this.onDrag);
    document.removeEventListener('mouseup', this.onDragEnd);
  },
  methods: {
    // openBottomByRate, closeBottomは省略
    /**
     * ドラッグ開始
     */
    onDragStart(event) {
      event.preventDefault();
      event.stopPropagation();
      this.$data.isDragging = true;
      this.startY = event.clientY;
      this.startHeight = this.$data.bottomHeight;
      // ドラッグ中はリサイズカーソルを維持する（セパレートバーを出ると普通のカーソルに戻ることがあるため）
      document.body.style.cursor = 'row-resize';
    },
    /**
     * ドラッグ中
     */
    onDrag(event) {
      if (this.startY == null || this.startHeight == null) {
        return;
      }
      const moveValue = this.startY - event.clientY;
      this.$data.bottomHeight = Math.max(this.startHeight + moveValue, 0);
    },
    onDragEnd() {
      if (this.startY == null) {
        return;
      }
      
      this.$data.isDragging = false;
      this.startY = null;
      this.startHeight = null;
      
      // 全体の高さを超えないように調整する
      const clientRect = this.$refs.elWrapper.getBoundingClientRect();
      this.$data.bottomHeight = Math.min(this.$data.bottomHeight, clientRect.height);
      
      // カーソルスタイルを外す
      document.body.style.cursor = '';
    },
  },
});
</script>
```

# 終わりに
以上が水平リサイザーコンポーネントの実装方法でした。垂直方向も同様のやり方で実装できると思います。
実際のコードはCodePenに書きましたので、興味がある方は是非見てください。

<p class="codepen" data-height="457" data-theme-id="dark" data-default-tab="js,result" data-user="wintyo" data-slug-hash="poEWYMz" style="height: 457px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="HorizontalResizer">
  <span>See the Pen <a href="https://codepen.io/wintyo/pen/poEWYMz">
  HorizontalResizer</a> by wintyo (<a href="https://codepen.io/wintyo">@wintyo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>