RitoLabo

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

  • 公開:
  • カテゴリ: JavaScript Vue.js
  • タグ: JavaScript,Vue,VueCLI,dnd

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

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

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

アジェンダ
  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

ベーシックなdnd動作確認

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

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

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

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

グループ間のdnd動作確認

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

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

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

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

イベント発火を伴うdnd動作確認

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

まとめ

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

Sortable - options
https://github.com/SortableJS/Sortable#options

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