Vueでドラッグ&ドロップ (以下D&D)で動かせるリストを作ってみようと思います.
- Vueのバージョン
- ステップ1. 静的なリスト
- ステップ2. D&Dできるようにする
- ステップ3. D&Dでイベントを渡す
- ステップ4. DataTransferクラスを利用する
- ステップ5. 要素を入れ替える
- 参考
Vueのバージョン
今回はVue3をCDN経由で利用します.
https://unpkg.com/vue@3/dist/vue.global.js
ステップ1. 静的なリスト
算出プロパティとArray.prototype.filter()を組み合わせるといい感じにリストを作ることが出来ます.
ポイントとしては算出プロパティに番号を渡し、その番号を親に持つ子要素のみを表示します. 算出プロパティに引数を渡す方法は以下の記事を参考にしました.
<!-- ./index.html --> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div v-for="parent in parents"> {{parent.group}} <ul> <li v-for="child in groupPassFilter(parent.number)"> {{child.name}} </li> </ul> </div> </div> <script type="module" src="js/main.js"> </script>
/* ./js/main.js */ const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', parents:[ { number:1, group:'Fruit', }, { number:2, group:'Meat', }, { number:3, group:'Animal', }, ], children:[ { parentNumber:1, name:'Lemon', }, { parentNumber:2, name:'Beef', }, { parentNumber:3, name:'Cat', }, { parentNumber:1, name:'Banana', }, { parentNumber:2, name:'Chicken', }, { parentNumber:3, name:'Dog', }, { parentNumber:1, name:'Grape', }, ] } }, computed: { groupPassFilter:function(){ return function(parentNumber){ return this.children.filter( (child) => child.parentNumber==parentNumber ) } } }, }).mount('#app')
なお、参考記事ではアロー関数を2回使って算出プロパティに引数を渡していますが、今回は1回だけにしています. アロー関数内ではthisが束縛されてしまうためです.
ステップ2. D&Dできるようにする
Vueに限らず、D&Dできるようになる最低限の手順は以下になります
- ドラッグしたい要素のdraggable属性の属性値をtrueにする.
- ドロップされる領域の要素に dropイベントリスナを登録する. ただし、 既定のアクションをキャンセルする
実用的にはdropイベント以外も実装したいところなので、次の手順も追加しましょう
- ドラッグしたい要素にdragstartイベントリスナを登録する.
- ドロップされる領域の要素に dragoverイベントリスナを登録する. ただし、 既定のアクションをキャンセルする
draggable属性の変更はhtml側で行います(Vueだからと言って特別なやり方があるわけではない). また、Vueでは イベント修飾子 を使うことで簡単に既定のアクションをキャンセルすることができます. 単に既定のイベントをキャンセルしたい場合は @click.prevent のように記述します. 既定のイベントをキャンセルしたうえでリスナをオーバーライドしたいときは @click.prevent="overrideOnClick" のように記述します.
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div v-for="parent in parents"> {{parent.group}} <ul> <li v-for="child in groupPassFilter(parent.number)" draggable="true" @dragstart="dragStart" > {{child.name}} </li> </ul> </div> <div id="dropZone" @dragover.prevent="onDragOver" @drop.prevent="onDrop" > Drop Here </div> </div> <script type="module" src="js/main.js"> </script>
const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', parents:[ { number:1, group:'Fruit', }, { number:2, group:'Meat', }, { number:3, group:'Animal', }, ], children:[ { parentNumber:1, name:'Lemon', }, { parentNumber:2, name:'Beef', }, { parentNumber:3, name:'Cat', }, { parentNumber:1, name:'Banana', }, { parentNumber:2, name:'Chicken', }, { parentNumber:3, name:'Dog', }, { parentNumber:1, name:'Grape', }, ] } }, computed: { groupPassFilter:function(){ return function(parentNumber){ return this.children.filter( (child) => child.parentNumber==parentNumber ) } } }, methods:{ dragStart(){ console.log('drag starts') }, onDragOver(){ console.log('drag over') }, onDrop(){ console.log('dropped') }, } }).mount('#app')
以上でli要素をdiv要素にD&Dできるようになりました.
ステップ3. D&Dでイベントを渡す
専用のドロップゾーンを用意するのではなく、リスト自身にドロップが出来るようにしましょう. 方法は単純で、リストを持っているdiv要素に@drop.preventと@dragover.preventを指定するだけです. 後で使うのでイベントリスナにイベントを渡せるようにしておきましょう. 関数の指定時に$eventを指定すれば、イベントリスナにイベントを渡すことが可能です.
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div v-for="parent in parents" :data-group-number="parent.number" @dragover.prevent="onDragOver($event)" @drop.prevent="onDrop($event)" > {{parent.group}} <ul> <li v-for="child in groupPassFilter(parent.number)" draggable="true" @dragstart="dragStart($event)" > {{child.name}} </li> </ul> </div> </div> <script type="module" src="js/main.js"> </script>
const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', parents:[ { number:1, group:'Fruit', }, { number:2, group:'Meat', }, { number:3, group:'Animal', }, ], children:[ { parentNumber:1, name:'Lemon', }, { parentNumber:2, name:'Beef', }, { parentNumber:3, name:'Cat', }, { parentNumber:1, name:'Banana', }, { parentNumber:2, name:'Chicken', }, { parentNumber:3, name:'Dog', }, { parentNumber:1, name:'Grape', }, ] } }, computed: { groupPassFilter:function(){ return function(parentNumber){ return this.children.filter( (child) => child.parentNumber==parentNumber ) } } }, methods:{ dragStart(e){ console.log('drag starts') }, onDragOver(e){ console.log('drag over') }, onDrop(e){ console.log(e) }, } }).mount('#app')
ステップ4. DataTransferクラスを利用する
D&Dに関するイベントには、ドラッグする要素の情報を保持するためにDataTransferクラスのインスタンスが含まれています. DataTransferクラスには次のようなメンバがあります.
メンバ名 | 種類 | 説明 |
---|---|---|
effectAllowed | プロパティ | dragstartイベント内で設定する. 対応したdropEffectの領域でのみdropイベントが発生するようになる. |
dropEffect | プロパティ | dragenterまたはdragoverイベント内で設定する. 対応したeffectAllowedプロパティをもつ要素しかdropイベントが発生しなくなる |
setData() | メソッド | データ型を指定して値をセットする| |
getData() | メソッド | setData()でセットした値をゲットする |
setDragImage() | メソッド | ドラッグ中に表示される半透明の画像をオーバーライドする |
今回はD&Dする要素は1種類しかなくドロップする領域も1か所しかないため、明示的にeffectAllowedプロパティとdropEffectプロパティをいじる必要はありませんが、せっかくなので使ってみましょう. setData()とgetData()をつかえばいちいちe.targetから必要な情報をマスクする必要がなくなるので積極的に使っていきましょう.
<!-- htmlは変更なし -->
const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', parents:[ { number:1, group:'Fruit', }, { number:2, group:'Meat', }, { number:3, group:'Animal', }, ], children:[ { parentNumber:1, name:'Lemon', }, { parentNumber:2, name:'Beef', }, { parentNumber:3, name:'Cat', }, { parentNumber:1, name:'Banana', }, { parentNumber:2, name:'Chicken', }, { parentNumber:3, name:'Dog', }, { parentNumber:1, name:'Grape', }, ] } }, computed: { groupPassFilter:function(){ return function(parentNumber){ return this.children.filter( (child) => child.parentNumber==parentNumber ) } } }, methods:{ dragStart(e){ console.log('drag starts') const dataTransfer = e.dataTransfer // drop event happens only in drop area with dropEffect='move' dataTransfer.effectAllowed = 'move' // set additional data dataTransfer.setData('my-additional-data', e.target.innerHTML) }, onDragOver(e){ console.log('drag over') const dataTransfer = e.dataTransfer // drop event happens only in drop area with effectAllowed='move' dataTransfer.dropAllowed = 'move' }, onDrop(e){ console.log(e.target) const dataTransfer = e.dataTransfer // get additional data const additionalData = dataTransfer.getData('my-additional-data') console.log(additionalData) }, } }).mount('#app')
ステップ5. 要素を入れ替える
最後に、D&Dした要素の親番号を変更することで要素を移動させましょう. 繰り返しになりますが、リストの表示には算出プロパティを使っているので、親番号を付け替えた場合自動的に再レンダリングが行われます.残る問題としては、 ドロップされる箇所がdiv要素の上かli要素の上かでe.targetが変わってしまうことがあります. ここではおとなしくElement.closest()メソッドを使いましょう. このメソッドは、 自分自身を含めた セレクタに一致する最も近い祖先の要素を返してくれます . e.currentTargetを使えば呼び出されたイベントリスナを有する要素を取得できます.(2023/4/18追記)
<!-- htmlは変更なし -->
const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', parents:[ { number:1, group:'Fruit', }, { number:2, group:'Meat', }, { number:3, group:'Animal', }, ], children:[ { parentNumber:1, name:'Lemon', }, { parentNumber:2, name:'Beef', }, { parentNumber:3, name:'Cat', }, { parentNumber:1, name:'Banana', }, { parentNumber:2, name:'Chicken', }, { parentNumber:3, name:'Dog', }, { parentNumber:1, name:'Grape', }, ] } }, computed: { groupPassFilter:function(){ return function(parentNumber){ return this.children.filter( (child) => child.parentNumber==parentNumber ) } } }, methods:{ dragStart(e){ console.log('drag starts') const dataTransfer = e.dataTransfer // drop event happens only in drop area with dropEffect='move' dataTransfer.effectAllowed = 'move' // set additional data dataTransfer.setData('my-additional-data', e.target.innerHTML) }, onDragOver(e){ console.log('drag over') const dataTransfer = e.dataTransfer // drop event happens only in drop area with effectAllowed='move' dataTransfer.dropAllowed = 'move' }, onDrop(e){ console.log(e.target) const dataTransfer = e.dataTransfer // get additional data const additionalData = dataTransfer.getData('my-additional-data') console.log(additionalData) // replace parent's number const newParentNumber = e.currentTarget.dataset.groupNumber console.log(newParentNumber) for(const child of this.children.filter(elem=>elem.name==additionalData)){ child.parentNumber = newParentNumber } }, } }).mount('#app')