しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

Vue.js + Typescript で『リサイズ可能なレイアウト』をやってみる

はじめに

とあるWebアプリのダッシュボード画面を開発しているときに、ふと『AWSのマネジメントコンソール』みたいにマウスのドラッグ&ドロップでリストの表示領域をかえられたら便利!っと感じました。

f:id:Shikataramuno:20190305101031p:plain

早速、自前でリサイザブルなレイアウトを構築してみましたので紹介したいと思います。

出来上がりはこんな感じです!

まずはいつどおりに出来上がりのイメージからです。
こんな感じになりました。(今回はマウス操作が伴うので動画で紹介します)

youtu.be

コードはここです。
GitHub - Shikataramuno/resizable-div-layout at fixed-resizeArea

考え方

マウスのドラッグ&ドロップ操作に伴ってリサイズさせるので、

  • ① ドラッグ&ドロップ操作を可能とするための要素
  • ② その操作にともなってサイズを変化させる要素

が必要になります。 便宜的に①をリサイザー、②をリサイズエリアと呼びます。

f:id:Shikataramuno:20190305105842p:plain

リサイザーでドラッグイベントを補足してイベントオブジェクトのマウス座標から幅、高さを計算し、リサイズエリアのstyle属性を更新することでリサイズします。

一番左側に幅方向にリサイズ可能なリサイズエリア1と、その右側の領域を上下に2分割し、それぞれに高さ方向にリサイズ可能なリサイズエリア2、3の合計三つのリサイズエリアを設定します。 それぞれのリサイズエリアの間に幅方向、高さ方向のリサイザーX、Yを配置します。

ダッシュボードの様な画面では、リサイズエリアにコンポーネントを配置していくことになります。

プロジェクトの構造

それでは、実装を見ていきましょう。まずはプロジェクトの作成からです。 プロジェクト名を resizable-div-layoutとしてvue create コマンドでプロジェクトを作成します。

作成時のオプションや可視性(publicやらprivateやら)を省略するための設定、 デバッグ文を出力できるようにするための設定などは以下の記事を参考にしてください。

techblo.shikataramuno.com

上の考え方で示した構造はResizableDivView.vueというコンポーネントで定義します。
プロジェクトができたらsrc/component以下に「リサイザブルコンポーネント」を作成します。 フォルダ構成はこんな感じになります。

resizable-div-layout
│  babel.config.js
│  package.json
│  tsconfig.json
│  tslint.json
├─node_modules
├─public
└─src
    │  App.vue
    │  main.ts
    │  shims-tsx.d.ts
    │  shims-vue.d.ts
    │  tree.txt
    ├─assets
    │      logo.png
    └─components
            ResizableDivView.vue  <--これが「リサイザブルコンポーネント」

ではResizableDivView.vueの中身を見ていきましょう

HTMLの構造

ResizableDivView.vuetemplate部分でリサイザー、リサイズエリアを定義していきます。

<template>
  <div id="resizeArea" ref="resizeArea" class="element">
    <div id="element1" ref="element1" class="element">リサイズエリア1</div>
    <div id="resizerX" ref="resizerX" style="background: red;"
      draggable="true"
      @dragstart="xResizeStart"
      @dragend="xResizeEnd"
      @drag="xResize">
    </div>
    <div id="rightElements" ref="rightElements">
      <div id="element2" ref="element2" class="element">リサイズエリア2</div>
      <div id="resizerY" ref="resizerY" style="background: blue;"
        draggable="true"
        @dragstart="yResizeStart"
        @dragend="yResizeEnd"
        @drag="yResize">
      </div>
      <div id="element3" ref="element3" class="element">リサイズエリア3</div>
    </div>
  </div>
</template>

2行目でこのコンポーネントのトップ要素をresizeAreaという名前で定義しています。

その内部にリサイズエリア1、リサイザーXとその右側にrightElementsというdiv要素を配置し、その内部にリサイズエリア2,3とリサイザーYを配置しています。こうすることで、リサイザーXの移動に伴ってリサイズエリア1とrightElementsの幅を更新すれば幅方向のリサイズを実現できるようになります。

次に5~8行目、13~16行目でリサイザーX、Yそれぞれのdiv要素を定義しています。
ここではdiv要素にdraggableの属性をセットし、ドラッグイベントを捕捉できるようにし、ドラッグの開始、終了とドラッグ操作のイベントハンドラを登録しています。

振る舞い

次に実際にリサイズを行う振る舞いを解説していきます。
まず、コンポーネントのインターフェースからです。

@Component
export default class ResizableDivView extends Vue {
  name: string = 'ResizableDivView';
  padding: number = 5;
  margin: number = 0;
  border: number = 1;
  $refs!: {
    'resizeArea': HTMLDivElement;
    'rightElements': HTMLDivElement;
    'element1': HTMLDivElement;
    'element2': HTMLDivElement;
    'element3': HTMLDivElement;
    'resizerXY': HTMLDivElement;
    'resizerX': HTMLDivElement;
    'resizerY': HTMLDivElement;
  };

  // 横方向のリサイズ
  changeWidth(x: number): void {...}

  // 縦方向のリサイズ
  changeHeight(y: number): void {...}

  // ドラッグ&ドロップのイベントハンドラー
  // resizerX イベント
  xResizeStart(e: MouseEvent): void {...}
  xResizeEnd(e: MouseEvent): void {...}
  xResize(e: MouseEvent): void {...}

  // resizerY イベント
  yResizeStart(e: MouseEvent): void {...}
  yResizeEnd(e: MouseEvent) {...}
  yResize(e: MouseEvent): void {...}

  // 初期表示
  mounted(): void {...}
}

まず、8~15行目のrefsを使った各要素への参照の定義です。これは、drag操作に伴って各要素のstyleを更新するので、それぞれのelementを直接的に参照するための定義です。

次に、26~28行目、31~33行目の定義です。これはリサイザーX,Yそれぞれのdragの開始、終了、マウス移動のイベントハンドラの定義になります。drag操作に伴う幅、高さの再設定処理は19行目、22行目で定義しているchangeWidthchangeHeightのメソッドが該当し、それぞれdragイベントハンドラから呼び出す構造としています。

ドラッグ開始、終了イベント

視覚的にドラッグ中であることを視認しやすくするために、ドラッグ操作中はリサイザーの背景色を切り替えています。その切替を開始、終了のイベントハンドラで処理しています。

  xResizeStart(e: MouseEvent): void {
    const resizerX: HTMLElement = this.$refs.resizerX;
    resizerX.style.background = 'gray';
  }
  xResizeEnd(e: MouseEvent): void {
    const resizerX: HTMLElement = this.$refs.resizerX;
    resizerX.style.background = 'red';
  }

これはX方向のイベントハンドラです。リサイザーのdiv要素への参照を取得し、style.backgroudを更新して背景色を切り替えています。Y方向のイベントハンドラも同様です。

ドラッグ操作イベント

いよいよメインイベントの本題です。
dragイベントに伴うリサイズ処理は以下の様になります。

幅方向のリサイズ
  // 幅方向のリサイズ
  changeWidth(x: number): void {
    const resizeArea: HTMLDivElement = this.$refs.resizeArea;
    const element1: HTMLDivElement = this.$refs.element1;
    const resizerX: HTMLDivElement = this.$refs.resizerX;
    const rightElement: HTMLDivElement = this.$refs.rightElements;
    const element2: HTMLDivElement = this.$refs.element2;
    const resizerY: HTMLDivElement = this.$refs.resizerY;
    const element3: HTMLDivElement = this.$refs.element3;

    const adjust: number =
      element1.offsetLeft // element1 左端
      + 2 * (this.border + this.padding) // element1 左右 border,padding
      + resizerX.offsetWidth; // resizerX 幅
    const el1Width: number = x - adjust;
    element1.style.width = el1Width + 'px';
    const rightElementWidth =
      resizeArea.offsetWidth
      - 4 * (this.border + this.padding) // resizeArea, element1
      - el1Width
      - resizerX.offsetWidth
      - 1;
    rightElement.style.width = rightElementWidth + 'px';
  }
  xResize(e: MouseEvent): void {
    if (e.pageX) {
      this.changeWidth(e.pageX);
    }
  }

31行目でマウスイベントからマウスのX座標を取り出しています。
このpageXdocument全体の左端をX軸の原点とした座標(pixel)で表されます。
これをパラメタとしてchangeWidthメソッドを呼び出します。(27行目)

changeWidthメソッドではelement1要素(リサイズエリア1)の幅とrightElements要素の幅をそれぞれ計算しstyle.widthを更新しています。
それぞれの幅を次の様な計算で求めています。

  • element1 の幅
    マウス座標から調整値としてelement1の『左端の座標値』、『境界線の太さ』、『内部余白』、resizerXの『幅』の合計値を差し引きelement1の幅としています。(11~15行目)。 16行目でelement1styleを更新しています。

  • rightElements の幅
    基本的にelement1の幅と考え方は同じです。
    トップエリアのresizeAreaoffsetWidthからelement1の幅と調整値を差し引いてrightElementsの幅を求めています(17~22行目)。
    23行目でstyleを更新しています。

補足
今回、各エリアとも外部余白(margin)は0としています。また、どの要素にもposition属性を指定していません。従ってすべての要素でoffsetParentbody要素となりoffsetLeftで得られる値はpageXと同じ原点からの座標値となります。
下図のイメージに示す様にマウス座標から上で述べた調整値を差し引いた値がelement1の幅になります。

f:id:Shikataramuno:20190305222540p:plain

offsetWidth は『境界線』と『内部余白』を含んだ幅になりますが、resizerXborderpaddingともstyleの設定値は0pxです。

高さ方向のリサイズ
  // 高さ方向のリサイズ
  changeHeight(y: number): void {
    const rightElements: HTMLDivElement = this.$refs.rightElements;
    const element2: HTMLDivElement = this.$refs.element2;
    const resizerY: HTMLElement = this.$refs.resizerY;
    const element3: HTMLDivElement = this.$refs.element3;

    const adjust: number =
      element2.offsetTop // element2 上端
      + 2 * (this.border + this.padding) // element2 上下 border,padding
      + resizerY.offsetHeight; // resizerY 高さ
    const el2height: number = y - adjust;
    element2.style.height = el2height + 'px';
    const el3height: number =
      rightElements.offsetHeight
      - (el2height + 2 * (this.border + this.padding)) // element2
      - resizerY.offsetHeight // resizerY
      - 2 * (this.border + this.padding); // element3
    element3.style.height = el3height + 'px';
  }
  yResize(e: MouseEvent): void {
    if (e.pageY) {
      this.changeHeight(e.pageY);
    }
  }

考え方は幅方向の場合と同じです。
最初にマウス座標からelement2(リサイズエリア2)の高さを求め(8~13行目)、rightElementsの高さからelement2とresizerYの高さと調整値を差し引いてelement3の高さを求め(14~19行目)それぞれstyle.heightを更新しています。

出来上がり!

後はApp.vueHelloWorldResizableDivViewに変更してnpm run serveで出来上がりです。
今回は全体の幅、高さは変えずに各リサイズエリアの面積を変える様なリサイズの仕方にしてみました。これとは別にリサイズエリア1だけ幅を変化させる。または、リサイズエリア2だけ高さを変え、後は幅を変えずに自動的にスクロールバーを付けるといったやり方もできると思います。
むしろこちらが主流な気がしますね!

っというわけでやってみましたので確認してみてください(コードは以下です)
GitHub - Shikataramuno/resizable-div-layout at autoadjust-resizeArea

謝辞

リサイザブルなレイアウトはダッシュボードなどのUI構築でとても重宝できるのでは?
と密にほくそ笑んでおります。

少しでも参考になったならば幸いです。最後までお付き合いいただき、ありがとうございました。