1. Home
  2. JavaScript
  3. Vue.js
  4. Vue.jsでドラッグアンドドロップによる要素の並べ替えと移動を実装する

Vue.jsでドラッグアンドドロップによる要素の並べ替えと移動を実装する

  • 公開日
  • カテゴリ:Vue.js
  • タグ:JavaScript,Vue,VueCLI,dnd
Vue.jsでドラッグアンドドロップによる要素の並べ替えと移動を実装する

Vue.js を用いてアプリケーションを構築するというのは、サスティナブルかつ拡張性に優れたコードを実現しながらも、 ユーザーに快適な動作や体験を提供するという目的もあると思います。

そんな優良なユーザー体験を提供する為のフロントエンド実装において、 ドラッグアンドドロップ(以下、dnd)を用いた要素移動インタラクションは有効なアプローチの1つです。

今回は、Vue.Draggable というパッケージを用いて、 時間をかけず簡単に dnd を実装していきます。

Contents

  1. 開発環境
  2. Vue.Draggable
  3. インストール
  4. 基本的な dnd
  5. 異なるリスト間の移動・並べ替え
  6. 異なるコンポーネント間での移動・並べ替え
  7. イベント

開発環境

今回の開発環境は以下の通りです。

  • Vue.js 2.x
  • Vue CLI 3.x
  • Node.js 12.x
  • npm 6.5

今回は SFC(単一ファイルコンポーネント)を単体で動かすので、 インスタントプロトタイピングの為のグローバルアドオン「cli-service-global 」 をインストールしておいてください。

尚、Vue CLI を使った開発環境構築の詳細については以下からも確認できます
Vue CLI で Vue.js の開発環境をサクッと構築する

Vue.Draggable

Vue.Draggable は、dnd およびビューモデル配列との同期を可能にする Vue コンポーネントです。
https://github.com/SortableJS/Vue.Draggable

  • Sortable.js 機能のフルサポート
    • タッチデバイスをサポート
    • ドラッグハンドルと選択可能なテキストをサポート
    • スマート自動スクロール
    • 異なるリスト間のドラッグアンドドロップをサポート
    • jQuery に依存しない
  • HTML を同期してモデルリストを表示する
  • Vue.js 2.0遷移グループと互換性あり
  • キャンセルサポート
  • 完全な制御が必要なときに変更を報告するイベント
  • 既存の UI ライブラリコンポーネントを再利用し、tag と componentData の prop を使用してそれらをドラッグ可能にする

もともとはSortable.js を Vue 用に移植したもので、FW を用いない実装や jQuery を使っていた時などでもお世話になった人も多いかと思います。

インストール

Vue.Draggable をインストールします。プロジェクトルートにて npm でインストールします。

npm i vuedraggable

基本的な dnd

まずは最もベーシックなリストの並び替えを行います。

dnd1.vue
<template>
  <div>
    <draggable :options="options">
      <div class="item" v-for="item in items" :key="item.id">{{item.name}}</div>
    </draggable>
  </div>
</template>

<script>
  import draggable from 'vuedraggable'

  export default {
      name: "dnd",
      components: { draggable },
      data () {
          return {
              options: {
                  animation: 200
              },
              items: [
                  { id: 1, name: 'name01' },
                  { id: 2, name: 'name02' },
                  { id: 3, name: 'name03' },
                  { id: 4, name: 'name04' },
                  { id: 5, name: 'name05' }
              ]
          }
      }
  }
</script>

<style scoped>
  .item {
    display: inline-block;
    margin: 10px;
    padding: 10px;
    border: 1px solid #7f7f7f;
    border-radius: 10px;
    background-color: #ffffff;
  }
  .item:hover {
    cursor: grab;
  }
  .item:active {
    cursor: grabbing;
  }
</style>

script - Vue.Draggable をインポートし、利用コンポーネントに登録しています。

  • data オプションにて option プロパティを設定する事で、オプションの挙動を指定できます。ここでは、要素の並べ替えや移動が行われる際にアニメーション(滑らかに移動)を付与しています。
  • items プロパティでは、並び替えを行うアイテムリストを定義しています。

template - インポートした draggable コンポーネントを展開しています。

  • options カスタムディレクティブ(bind)を設定する事で、scripts 側で定義したオプションが反映されます。
  • div要素でループを設定し、アイテムのリストを描画します。

実装したら、以下の vue コマンドを叩いて、プレビューしてみてください。

vue serve dnd1.vue --open

スムーズなドラッグアンドドロップが実現できたと思います。
サンプルページ

異なるリスト間の移動・並べ替え

今度は、複数のリストでの並べ替えや移動を行ってみます。 これも、少し追加するだけで実現可能です。

dnd2.vue
<template>
  <div>
    <div>
      <h3>グループA</h3>
      <draggable v-model="itemsA" group="myGroup" @start="drag=true" @end="drag=false" :options="options">
        <div class="item" v-for="item in itemsA" :key="item.id">{{item.name}}</div>
      </draggable>
    </div>
    <div>
      <h3>グループB</h3>
      <draggable v-model="itemsB" group="myGroup" @start="drag=true" @end="drag=false" :options="options">
        <div class="item" v-for="item in itemsB" :key="item.id">{{item.name}}</div>
      </draggable>
    </div>
  </div>
</template>

<script>
  import draggable from 'vuedraggable'

  export default {
      name: "dnd",

      components: { draggable },

      data () {
          return {
              options: {
                  group: "myGroup",
                  animation: 200
              },
              itemsA: [
                  { id: 1, name: 'name01' },
                  { id: 2, name: 'name02' },
                  { id: 3, name: 'name03' },
                  { id: 4, name: 'name04' },
                  { id: 5, name: 'name05' }
              ],
              itemsB: [
                  { id: 6, name: 'name06' },
                  { id: 7, name: 'name07' },
                  { id: 8, name: 'name08' },
                  { id: 9, name: 'name09' },
                  { id: 10, name: 'name10' }
              ]
          }
      }
  }
</script>

<style scoped>
  .item {
    display: inline-block;
    margin: 10px;
    padding: 10px;
    border: 1px solid #7f7f7f;
    border-radius: 10px;
    background-color: #ffffff;
  }
  .item:hover {
    cursor: grab;
  }
  .item:active {
    cursor: grabbing;
  }
</style>
script
  • 2つ目のアイテムリストを追加しています
  • data オプションの options プロパティで group: "myGroup" を追加しています
template
  • 2つ目のリストを追加しています
  • 2つのリストにそれぞれ group="myGroup" を追加しています

これだけです。以下の vue コマンドを叩いて、プレビューしてみてください。

vue serve dnd2.vue --open

グループ間での要素移動が行える事が確認できると思います。
サンプルページ

異なるコンポーネント間での移動・並べ替え

これまでは同一コンポーネント無いでの処理でしたが、別々のコンポーネント間ではどうでしょうか。 実装してみます。

apA.vue
<template>
  <div>
    <h3>グループA</h3>
    <draggable v-model="itemsA" group="myGroup" @start="drag=true" @end="drag=false" :options="options">
      <div class="item" v-for="item in itemsA" :key="item.id">{{item.name}}</div>
    </draggable>
  </div>
</template>

<script>
    import draggable from 'vuedraggable'

    export default {
        name: "gpA",

        components: { draggable },

        data () {
            return {
                options: {
                    group: "myGroup",
                    animation: 200
                },
                itemsA: [
                    { id: 1, name: 'name01' },
                    { id: 2, name: 'name02' },
                    { id: 3, name: 'name03' },
                    { id: 4, name: 'name04' },
                    { id: 5, name: 'name05' }
                ]
            }
        }
    }
</script>
apB.vue
<template>
  <div>
    <h3>グループB</h3>
    <draggable v-model="itemsB" group="myGroup" @start="drag=true" @end="drag=false" :options="options">
      <div class="item" v-for="item in itemsB" :key="item.id">{{item.name}}</div>
    </draggable>
  </div>
</template>

<script>
    import draggable from 'vuedraggable'

    export default {
        name: "gpB",

        components: { draggable },

        data () {
            return {
                options: {
                    group: "myGroup",
                    animation: 200
                },
                itemsB: [
                    { id: 6, name: 'name06' },
                    { id: 7, name: 'name07' },
                    { id: 8, name: 'name08' },
                    { id: 9, name: 'name09' },
                    { id: 10, name: 'name10' }
                ]
            }
        }
    }
</script>
root.vue
<template>
  <div>
    <gp-a></gp-a>
    <gp-b></gp-b>
  </div>
</template>

<script>
  import gpA from './gpA'
  import gpB from './gpB'

  export default {
      name: "root",
      components: { gpA, gpB }
  }
</script>

<style>
  .item {
    display: inline-block;
    margin: 10px;
    padding: 10px;
    border: 1px solid #7f7f7f;
    border-radius: 10px;
    background-color: #ffffff;
  }
  .item:hover {
    cursor: grab;
  }
  .item:active {
    cursor: grabbing;
  }
</style>

root を定義した以外は、これまでのコードを単純に2つのコンポーネントに分けただけです。 実装したら、以下の vue コマンドを叩いて、プレビューしてみてください。

vue serve root.vue --open

問題なく動作する事が確認できると思います。
サンプルページ

イベント

Vue.Draggable では、以下の動作に対してイベントを発行しています。

  • filter イベント
    • フィルタされている要素を選択した時
  • choose イベント
    • 要素が選択された時
  • start イベント
    • 動作が始まった時
  • clone イベント
    • 動作が開始され要素のコピーが行われた時
  • update イベント
    • リストの更新が行われた時
  • add イベント
    • リストに要素が加えられた時
  • remove イベント
    • リストから要素が除去された時
  • sort イベント
    • 並び替えが行われた時
  • end イベント
    • 動作が終わった時

これらのイベントを起点にして、自由に処理を定義できます。

dnd4.vue
<template>
  <div>
    <div id="status">
      <span v-show="status.moving">移動中</span>
      <span v-show="status.fixed">そのアイテムは操作できません</span>
    </div>
    <div>
      <h3>グループA({{itemsA.length}})</h3>
      <draggable
        v-model="itemsA"
        group="myGroup"
        :options="options"

        @choose="onChoose"
        @start="onStart"
        @clone="onClone"
        @add="onAdd"
        @remove="onRemove"
        @update="onUpdate"
        @sort="onSort"
        @filter="onFilter"
        @end="onEnd"
      >
        <div class="item" v-for="item in itemsA" :key="item.id" :class="isFixed(item.fixed)">{{item.name}}</div>
      </draggable>
    </div>
    <div>
      <h3>グループB ({{itemsB.length}})</h3>
      <draggable
        v-model="itemsB"
        group="myGroup"
        :options="options"

        @choose="onChoose"
        @start="onStart"
        @clone="onClone"
        @add="onAdd"
        @remove="onRemove"
        @update="onUpdate"
        @sort="onSort"
        @filter="onFilter"
        @end="onEnd"
      >
        <div class="item" v-for="item in itemsB" :key="item.id" :class="isFixed(item.fixed)">{{item.name}}</div>
      </draggable>
    </div>
    <div>
      <h3>グループC ({{itemsC.length}})</h3>
      <draggable
        v-model="itemsC"
        group="myGroup"
        :options="options"

        @choose="onChoose"
        @start="onStart"
        @clone="onClone"
        @add="onAdd"
        @remove="onRemove"
        @update="onUpdate"
        @sort="onSort"
        @filter="onFilter"
        @end="onEnd"
      >
        <div class="item" v-for="item in itemsC" :key="item.id" :class="isFixed(item.fixed)">{{item.name}}</div>
      </draggable>
    </div>
  </div>
</template>

<script>
  import draggable from 'vuedraggable'

  export default {
      name: "dnd",

      components: { draggable },

      data () {
          return {
              options: {
                  group: "myGroup",
                  animation: 200,
                  filter: '.fixed'
              },
              itemsA: [
                  { id: 1, name: 'name01', fixed: true },
                  { id: 2, name: 'name02', fixed: false },
                  { id: 3, name: 'name03', fixed: false },
                  { id: 4, name: 'name04', fixed: false },
                  { id: 5, name: 'name05', fixed: false }
              ],
              itemsB: [
                  { id: 6, name: 'name06', fixed: true },
                  { id: 7, name: 'name07', fixed: false },
                  { id: 8, name: 'name08', fixed: false },
                  { id: 9, name: 'name09', fixed: false },
                  { id: 10, name: 'name10', fixed: false }
              ],
              itemsC: [
                  { id: 11, name: 'name11', fixed: true },
                  { id: 12, name: 'name12', fixed: false },
                  { id: 13, name: 'name13', fixed: false },
                  { id: 14, name: 'name14', fixed: false },
                  { id: 15, name: 'name15', fixed: false }
              ],
              status: {
                  moving: false,
                  fixed: false,
              }
          }
      },

      computed: {
          isFixed () {
              return (fixed) => {
                  return {
                      'fixed': fixed === true
                  }
              }
          },
      },

      methods: {
          // フィルタされている要素を選択した時(filterイベント)
          onFilter () {
              console.log('onFilter')
              this.status.fixed = true
              setTimeout(() => {
                  this.status.fixed = false
              }, 1000)

          },
          // 選択された時(chooseイベント)
          onChoose (e) {
              console.log('onChoose')
          },
          // 動作が始まった時(startイベント)
          onStart () {
              console.log('onStart')
              this.status.moving = true;
          },
          // 動作が開始され要素のコピーが行われた時(cloneイベント)
          onClone () {
              console.log('onClone')
          },
          // リストの更新が行われた時(updateイベント)
          onUpdate () {
              console.log('onUpdate')
          },
          // リストに要素が加えられた時(addイベント)
          onAdd () {
              console.log('onAdd')
          },
          // リストから要素が除去された時(removeイベント)
          onRemove () {
              console.log('onRemove')
          },
          // 並び替えが行われた時(sortイベント)
          onSort () {
              console.log('onSort')
          },
          // 動作が終わった時(endイベント)
          onEnd (e) {
              console.log('onEnd')
              this.status.moving = false;
          }
      }
  }
</script>

<style scoped>
  #status {
    min-height: 25px;
    border: 1px solid #42b983;
  }
  .item {
    display: inline-block;
    margin: 10px;
    padding: 10px;
    border: 1px solid #7f7f7f;
    border-radius: 10px;
    background-color: #ffffff;
  }
  .item:hover {
    cursor: grab;
  }
  .item:active {
    cursor: grabbing;
  }
  .sortable-chosen {
    background-color: #42b983;
  }
</style>

実装したら、以下の vue コマンドを叩いて、プレビューしてみてください。

vue serve dnd4.vue --open

デベロッパーツールを開きつつ操作を行うと、イベントが発行しているのが確認できます。
サンプルページ

まとめ

以上で作業は完了です。 今回紹介したもの以外でオプションやメソッドが沢山あるのでほしい挙動も見つけられると思います。

Sortable - options

簡単に導入できて便利なので、是非試してみてください。
今回のサンプルコード

Author

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級