Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Vueで複数表示もドラッグもできる最強のフローティングパネルを作った話

複数のフローティングパネルを表示する例
フロントエンドエンジニアのゆう(@aose_developer)です。

ヤプリのCMSにはBlock UIという、直感的かつフレキシブルにアプリを制作できる基盤があります。

Yappliの新基盤「Block UI」 のご紹介

個人的にはノーコードツールというだけでかなり楽しい開発ですが、中でもこのBlock UIはより複雑なUIや機能を作ることになるので、フロントエンドエンジニアとしてやりがいを感じています。

そこで今回は、Block UIでの開発を進めていく中で誕生した 卍最強のフローティングパネル卍 *1 について、最強たる所以をご紹介すると共に実装する上での技術的な解説をします。

https://lh3.googleusercontent.com/u/1/drive-viewer/AKGpihaubt15JG2JVtqvtVnm5_lYcqaZxIjyGUIyhTKHRAA0kDRjnSb1YatOC3SVerAHHZYCbMzZRg6kR-crGoMypp31JLzuD57d_Q=w3024-h1890-rw-v1

また、Vueでフローティングパネルを作成するにあたり、実装における重要ポイントを絞ったデモを用意し解説していますので、ぜひ参考にしてみてください。

目次

きっかけ

まず、今期に担当していた開発においては

「トリガー要素をクリックするとフローティングパネルが表示され、その中でさらに別のトリガー要素をクリックすると子フローティングパネルが表示される」

というような機能を作成していました。

ヤプリのCMSはそれなりに歴史もあるため、既に複数のポップオーバー系コンポーネントが存在しますが、既存のものでは以下の問題があり、要件を満たせませんでした。

  • 複数枚表示の想定がされていない
  • 外側クリックで閉じる機能が簡単に制御できず、複数枚の行き来ができない

これらの問題を解決するため、新しいフローティングパネルコンポーネントの開発に踏み切りました。

機能

このフローティングパネルには大まかに以下の機能があります。

  • ドラッグによる移動
  • 複数パネルの同時表示
  • 最後に操作したパネルを最前面に移動
  • 初期表示位置の細かい指定
  • 配置可能領域の指定
  • パネル外クリックによる非表示(他のパネルをクリックした場合は閉じない)

特に、複数表示とドラッグ機能の組み合わせは操作感が良く、これだけでも使い勝手を大きく向上させています。

他にも、フローティングパネルやポップオーバー系にはよくある「外側クリックで閉じる」機能も props で制御可能です。

そのため、閉じるボタンだけでパネルを非表示にしたい場合や、複数表示した状態で作業をしたい機能が今後出てくれば、 props 一つでオフにできます。

使用技術

卍最強のフローティングパネル卍 を実現するにあたって使用した技術は以下の通りです。

実は、フローティングパネルを作成している時期はちょうどNuxt3移行に向けての諸々の対応と重なっていたため、差分を取り込むまではNuxt2で使える PortalVue というライブラリを用いていました。

PortalVue

PortalVue の公式ガイドにある「What is PortalVue?」の日本語訳は以下の通りです。

PortalVue は、コンポーネントのテンプレート (またはその一部) をドキュメント内の任意の場所にレンダリングできるようにする 2 つのコンポーネントのセットです。

また、Vue3にはこちらと同等以上の機能を標準で提供する Teleport が存在しており、PortalVueの公式ガイドでも以下のような記述(日本語訳済み)があったので、移行に伴ってお引越ししました。

ほとんどの場合、portal-vueは必要ないかもしれません。新しいTeleportコンポーネントがこのライブラリよりも優れた機能を提供しているからです。

後述しますが、基本的には後から PortalVue を剥がす想定で作成していたため、あえて PortalVue の order プロパティを使用せずに実現できるように z-index を用いて実装していました。

PortalVueの複数表示・orderプロパティについて

加えて、複数枚表示を実現するにあたりフローティングパネルの状態管理ツールとして、既にプロダクト内で使用しているVuexを用いています。

実装のポイント

フローティングパネルを作成するにあたって根幹的なロジックはほぼ VueUse に依存する形で作成しています。

VueUse

このライブラリは痒いところに手が届く便利なコンポーザブルがたくさん集まったライブラリです。

既存のポップオーバー系コンポーネントでは独自実装された onClickOutside がありましたが、今回はVueUseを駆使して実装しています。

そのため、開発途中に潜在的なバグに見舞われることもなく、フローティングパネルのロジックのみに注力することができました。

また、フローティングパネルに関連するコードだけでもかなりの量があることに加え、プロダクトのコードを全部お見せするわけにもいかないため、簡単なデモを用意しました。

github.com

このデモでは、Vue3のみの機能を使って必要最低限の実装を行なっているので、あくまでもイメージとして参考にしてください。

フローティングパネルの表示準備

前述したように、Nuxt3対応に伴いVue3標準のTeleportに変更したため、デモでは最初からTeleportを使用しています。

使い方は至ってシンプルで、以下のようにテレポート先を to で指定するだけです。

  <Teleport v-if="visible" to="#teleport-for-floating-panel">

テレポート先となる箇所では以下のように記述しています。

<div id="teleport-for-floating-panel" />

Teleportはデフォルトで複数要素の転送に対応しているため、フローティングパネルを複数枚開くたびに #teleport-for-floating-panel の子要素として追加されていきます。

あとは各フローティングパネルの z-index を動的に更新するだけで、重なり順を変更することができます。簡単ですね。

パネル外クリックによる非表示

一見すると複雑なことをやっているように見えますが、大部分はVueUseが実装してくれているので、こちらも非常に簡単です。

onMounted(() => {
  const teleportForFloatingPanel = document.querySelector(
    '#teleport-for-floating-panel'
  ) as MaybeElementRef<MaybeElement>;

  onClickOutside(
    teleportForFloatingPanel,
    () => {
      hide();
      deleteAllFloatingPanels();
    },
    {
      // 他のフローティングパネルをクリックしたりパネルからモーダルを開いて操作しても、自身のパネルが閉じないように
      // #teleport-for-floating-panelを除外する
      ignore: [teleportForFloatingPanel],
    }
  );
});

重要なのは onClickOutside 関数です。

第一引数に外クリックの処理を付けたい要素を、第二引数に実行したい関数を、第三引数に実行時のオプションを設定します。

第三引数の ignore で外クリックの対象外にしたい要素を指定することができるので、子要素に複数のパネルが追加される teleportForFloatingPanel を指定しています。

canCloseOutside というプロパティが true のパネルのみ onClickOutside を実行するように設定しています。

ドラッグ機能

こちらに関する処理は useDraggablePanel.ts にまとめました。

VueUseには useDraggable という、第一引数に渡したrefに対してドラッグ機能を追加する便利なコンポーザブルが用意されているため、今回はこちらを利用しました。

この関数から返ってくるx, yをスタイルとして指定するだけで、いい感じにドラッグできるフローティングパネルの完成です。

また、ドラッグ機能においては 「ドラッグしたパネル=最後に操作したパネル」 に値するため、ドラッグ操作時に重なり順を変更する必要があります。

この useDraggable には第二引数にオプションを設定でき、onStart というプロパティに関数を渡せばドラッグ時に発火されるようになります。

今回はこのプロパティに後述する、重なり順の処理を行う関数を指定しました。

const setupPanelDraggable = () => {
  const {
    x: floatingPanelX,
    y: floatingPanelY,
    isDragging,
  } = useDraggable(floatingPanelRef, {
    initialValue: { x: 0, y: 0 },
    onStart: () => {
      reorderFloatingPanels(floatingPanelId.value);
    },
    capture: false,
  });

  return {
    floatingPanelX,
    floatingPanelY,
    isDragging,
  };
};

ついでにドラッグ中のカーソルを grab, grabbing に切り替えたかったため、同じく useDraggable から返される isDragging もまとめてreturnしています。

イベントフェーズについて

ここで重要なのが、オプションの3つ目の値です。

この値と、この後紹介する Undraggable.vue を組み合わせることで、パネル内での操作を可能としています。

// デフォルトはtrue
capture: false,

例えば、フローティングパネル内にinput要素があった場合、inputのテキストの選択を試みてそのままドラッグすると、フローティングパネルごと動いてしまいますが、それを回避するためにこのオプションを使用します。

capture は、JavaScriptにおけるイベントフェーズのcaptureパラメータを設定するオプションですが、true / false の設定による違いは以下の通りです。

capture: true の場合

  • イベントがキャプチャリングフェーズで処理される
  • イベントは最上位の親要素から対象の要素に向かって伝播する

capture: false の場合

  • イベントがバブリングフェーズで処理される
  • イベントは対象の要素から親要素に向かって伝播する

各イベントフェーズとcaptureについては、詳しくは以下の記事が参考になると思います。

バブリング と キャプチャリング

しかし、capture: false にしても、イベントの伝播の順番が 「対象の要素 → 親要素」 になっただけで親要素のイベント自体は発火してしまうため、まだ正常に動きません。

そこで、加えて以下のようなUndraggableコンポーネントを実装しました。

<template>
  <!-- FloatingPanel用のコンポーネント。`.stop` で親へのイベント伝播を防いでいるため、このコンポーネントで囲んだ要素はFloatingPanelのドラッグ対象から外れる -->
  <div
    ref="undraggableRef"
    v-bind="attrs"
    class="Undraggable"
    @pointerdown.stop
    @pointerup.stop
    @pointermove.stop
  >
    <slot />
  </div>
</template>

このコンポーネントでは、vueの @XX.stop を使うことで、親へのイベントの伝播を防いでいます。

Undraggable.vue はフローティングパネル内での利用を前提としたコンポーネントですが、やっていることはシンプルにイベント伝播の制御のみになります。

両者を組み合わせることにより、Undraggableコンポーネントで囲んだ要素はフローティングパネルのドラッグイベントが発生しなくなるため、パネル内でクリックしたり操作したい要素に関しては、都度このコンポーネントで囲って使用するような流れです。

FloatingPanel.vue 側では、別で用意した「フローティングパネルのスタイルを生成する関数」に setupPanelDraggable の戻り値を渡し、その値を基に最終的なスタイルを生成しています。

script

const { draggableStyle } = createDraggableStyle(
  floatingPanelX,
  floatingPanelY,
}

const floatingPanelStyle = computed<StyleValue>(() => ({
  ...draggableStyle.value,
  cursor: isDragging.value ? 'grabbing' : 'grab',
}));

template

<div
  :id="floatingPanelId"
  :key="floatingPanelId"
  ref="floatingPanelRef"
  class="CmsFloatingPanel"
  :style="floatingPanelStyle" // ここ
  @mousedown="reorderFloatingPanels(floatingPanelId)"
>

ドラッグ機能の重要なポイントは概ね上記の点です。

使用側では必要に応じてUndraggableを都度importしますが、逆に言えば 「ドラッグさせたくない要素は <Undraggable /> で囲む」 と脳筋思考で実装できるので、個人的には推しポイントですね。

複数表示と重なり順の状態管理

こちらに関する処理は useFloatingPanel.ts にまとめました。

フローティングパネルの状態管理の実装イメージは以下の通りです。

const INITIAL_FLOATING_PANEL_Z_INDEX = 0;

floatingPanels: [],
currentHighestFloatingPanelZIndex: INITIAL_FLOATING_PANEL_Z_INDEX,

floatingPanels は複数のフローティングパネルを一元的に管理するための配列で、currentHighestFloatingPanelZIndex はその配列の要素の最も高い z-index を保存します。

解説のため初期値は 0 としていますが、任意の値で問題ありません。

※ デモの方は provide / inject で擬似的にstoreを作成しています。

まず、トリガー要素をクリックした際にフローティングパネルを生成し、配列に追加します。

/**
 * FloatingPanelを生成する関数
 * @param floatingPanelId 生成するFloatingPanelのID
 * @returns 生成されたFloatingPanel
 */
const generateFloatingPanel = (floatingPanelId: string): FloatingPanel => {
  const newZIndex = currentHighestFloatingPanelZIndex.value + 1;

  const createNewPanel = () => {
    const newFloatingPanel: FloatingPanel = {
      canCloseOutside: canCloseOutside ?? true,
      floatingPanelId: uuidv4(),
      zIndex: newZIndex,
    };
    store.dispatch('example/path/addFloatingPanel', {
      floatingPanel: newFloatingPanel,
    });
    return newFloatingPanel;
  };

  // floatingPanelIdが存在する場合、生成せずに既存のFloatingPanelを返却
  if (floatingPanelId && existsFloatingPanel(floatingPanelId)) {
    return {
      canCloseOutside: canCloseOutside ?? true,
      floatingPanelId,
      zIndex: newZIndex,
    };
  }

  // floatingPanelIdが空文字または存在しない場合、新しいFloatingPanelを生成
  return createNewPanel();
};
const show = () => {
  const { floatingPanelId: _floatingPanelId } = generateFloatingPanel(
    floatingPanelId.value
  );
  floatingPanelId.value = _floatingPanelId;
  visible.value = true;
};

詳細は割愛しますが、既に floatingPanelId.value がstoreの配列に存在していた場合を考慮しています。

フローティングパネルから別のフローティングパネルを生成した場合も同様に処理を行います。

これにより、storeの配列は2つのフローティングパネルオブジェクトが管理されます。

floatingPanels: [
  {
    canCloseOutside: true,
    floatingPanelId: 'a1b2c3d4', // 例
    zIndex: 1,
  },
  {
    canCloseOutside: true,
    floatingPanelId: 'e5f6g7h8', // 例
    zIndex: 2,
  },
]

重なり順に関しては、Teleportの直下要素の @mousedown イベントに対して、並び替えを行う関数を設定します。

script

/**
 * FloatingPanelの順序を変更する関数
 * @param floatingPanelId 順序を変更するFloatingPanelのID
 * @returns void
 */
const reorderFloatingPanels = (floatingPanelId: string) => {
  if (!existsFloatingPanel(floatingPanelId)) return;

  const currentPanel = getCurrentFloatingPanel(floatingPanelId);

  const otherPanels = floatingPanels.value.filter(
    panel => panel.floatingPanelId !== floatingPanelId
  );

  // 現在のパネルを先頭に配置し、z-indexを振り直す
  const reorderedPanels = [...otherPanels, currentPanel].map(
    (panel, index) => ({
      ...panel,
      zIndex: INITIAL_FLOATING_PANEL_Z_INDEX + index + 1,
    })
  );

  store.dispatch('example/path/replaceFloatingPanels', {
    floatingPanels: reorderedPanels,
  });
};

template

<div
  :id="floatingPanelId"
  :key="floatingPanelId"
  ref="floatingPanelRef"
  class="CmsFloatingPanel"
  :style="floatingPanelStyle"
  @mousedown="reorderFloatingPanels(floatingPanelId)" // ここ
>

ここまでの処理の流れは以下の通りです。

  1. トリガー要素をクリックして1枚目のフローティングパネルを生成(z-index: 1
  2. パネル内で別のトリガー要素をクリックして2枚目のフローティングパネルを生成(z-index: 2
  3. 1枚目のパネルをクリックすると、並び替え関数が発火して
    1枚目:z-index: 2・2枚目:z-index: 1 に振り直される

実際にはもっと複雑な処理を行なっていますが、核となる実装はこの重なり順の振り直しにあります。

この機構により、PortalVueの order に依存しなくて済むため、スムーズにTeleportに書き換えることが可能でした。

初期表示位置・配置可能領域の制御

こちらに関する処理は usePosition.ts にまとめました。

正直ここの実装が一番トリッキーで難しかったです。処理も複雑なため全体の実装は割愛しますが、このコンポーザブルは以下の引数を受け取ります。

/**
 * FloatingPanelの表示位置(x, y)を取得するcomposables関数
 * @param triggerRef FloatingPanelの基準となる要素
 * @param elementRef FloatingPanel
 * @param options FloatingPanelの表示位置を調整するオプション
 * @returns \{ x: number, y: number \} FloatingPanelの表示位置
 */
export const usePosition = (
  triggerRef: MaybeComputedElementRef<MaybeElement>,
  elementRef: MaybeComputedElementRef<MaybeElement>,
  options: Options
) => {
  // ...
  return {
    x: computedX,
    y: computedY,
  };
}

第一引数にフローティングパネル表示のためのトリガーエレメントを指定し、第二引数に表示させるフローティングパネル自体のエレメントを指定します。

このコンポーザブルがやりたいことは表示位置の生成・取得なので、トリガー要素とフローティングパネルの座標・サイズの情報を基に、最終的に表示すべき x, y を返します。

実際の実装では、第三引数のoptionsは以下のような値を受け取っています。

/**
 * floatingPanelの表示位置を指定するためのオプション
 * @param placement FloatingPanelの表示位置
 * @param addX placementに対する横方向のoffset
 * @param addY placementに対する縦方向のoffset
 * @param offsetTop FloatingPanelの表示位置を調整するためのオフセット(上部)
 * @param offsetBottom FloatingPanelの表示位置を調整するためのオフセット(下部)
 */
export type Options = {
  placement: FloatingPanelPlacement;
  addX: number;
  addY: number;
  offsetTop: ComputedRef<number>;
  offsetBottom: ComputedRef<number>;
};

デモでは要点を解説するために、placement のみに絞り、placement 自体もよりシンプルにしています。

このコンポーザブルの中では、指定された placement ごとに、以下のように位置を細かく計算しています。

const getPosition = ({
  placement,
  elementWidth,
  elementHeight,
  targetBoundings,
}: GetPositionProps) => {
  const { targetX, targetY, targetWidth, targetHeight } = targetBoundings;

  // FloatingPanelの表示位置を管理するオブジェクト
  const positionMap = {
    left: {
      x: targetX - elementWidth,
      y: targetY + targetHeight / 2 - elementHeight / 2,
    },
    top: {
      x: targetX + targetWidth / 2 - elementWidth / 2,
      y: targetY - elementHeight,
    },
    right: {
      x: targetX + targetWidth,
      y: targetY + targetHeight / 2 - elementHeight / 2,
    },
    bottom: {
      x: targetX + targetWidth / 2 - elementWidth / 2,
      y: targetY + targetHeight,
    },
  };

このコンポーザブルの流れは大体以下のイメージです。

  1. 値を代入するための変数を事前に定義しておく
  const x = ref(0);
  const y = ref(0);
  const computedX = computed(() => x.value);
  const computedY = computed(() => y.value);
  const targetX = ref(0);
  const targetY = ref(0);
  const targetWidth = ref(0);
  const targetHeight = ref(0);
  const elementX = ref(0);
  const elementY = ref(0);
  const elementWidth = ref(0);
  const elementHeight = ref(0);
  1. フローティングパネルの表示位置を更新するupdate関数を作成
const update = () => {
  // 1で定義した各ref変数に対して計算結果を代入する処理
  
  // ...

  const position = getPosition({
    // ...
  });

  // FloatingPanelの表示位置を更新
  x.value = position.x;
  y.value = position.y;
}
  1. VueUseの tryOnMounted を用いてupdate関数を実行する
  // 安全なonMounted内でupdate関数を呼び出して初期値を設定する
  tryOnMounted(() => {
    update();
  });

FloatingPanel.vue では、このコンポーザブルを以下のように使用しています。

// 初期位置に戻すためのキャッシュ
const initialPositionX = ref(0);
const initialPositionY = ref(0);

const setPosition = async () => {
  await nextTick();
  const { x: positionX, y: positionY } = usePosition(
    triggerRef,
    floatingPanelRef,
    { placement: props.placement }
  );
  initialPositionX.value = positionX.value;
  initialPositionY.value = positionY.value;
  floatingPanelX.value = positionX.value;
  floatingPanelY.value = positionY.value;
};

const setInitialPosition = () => {
  floatingPanelX.value = initialPositionX.value;
  floatingPanelY.value = initialPositionY.value;
};

// パネルが表示された際に位置を計算
watch(
  () => visible.value,
  async value => {
    if (value) {
      await setPosition();
    } else {
      // パネルが閉じられた際に位置をキャッシュで初期化
      setInitialPosition();
    }
  }
);

なんだかよくわからないコードがたくさん出てきたと思ったそこのあなた、安心してください。やっていることは非常にシンプルです。

  1. トリガー要素をクリックすると、visibletrue になる
  2. watch が発火して、新しいvisible の値が true であれば先ほどのコンポーザブルの実行結果をフローティングパネルの位置にし、false であれば初期値の位置に戻す

initialXX 系の初期位置変数に関しては、フローティングパネルを閉じたら最初の指定位置に戻すための、キャッシュの役割を果たします。

要は、 「フローティングパネルが描画されるタイミングで表示位置をいい感じに生成して、その後はドラッグして好きなところに移動できるけど、閉じて再度開いたらまた指定した位置に戻す」 ということをやっているだけです。

おわりに

業務で実装したフローティングパネルはもっと複雑かつ多機能ですが、核となる実装はこんな感じになります。

何より、機能ごとにコンポーザブルを分けることで、フローティングパネルコンポーネントそのものは最小限の実装で完成したので、VueUseも相まってVue3の書き味は個人的にかなり開発者体験が良いです。

Vueでフローティングパネルを作る時に色々と調べましたが、中々参考となる記事がなかったため、今回このような技術的知見を共有しました。

みなさんの参考になれば幸いです。

もしこの記事を見てヤプリにご興味を持っていただけたり、フロントエンドをゴリゴリ開発したいと思った方は、是非カジュアル面談でお話しましょう!

*1:当社比。異論は認めます。