ぷるぷるの雑記

低レイヤーがんばるぞいなブログ. 記事のご利用は自己責任で.

Vueでドラッグ&ドロップできるリストを作る

Vueでドラッグ&ドロップ (以下D&D)で動かせるリストを作ってみようと思います.

Vueのバージョン

今回はVue3をCDN経由で利用します.

https://unpkg.com/vue@3/dist/vue.global.js

ステップ1. 静的なリスト

算出プロパティとArray.prototype.filter()を組み合わせるといい感じにリストを作ることが出来ます.

ポイントとしては算出プロパティに番号を渡し、その番号を親に持つ子要素のみを表示します. 算出プロパティに引数を渡す方法は以下の記事を参考にしました.

qiita.com

<!--  
     ./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')

ステップ1. 静的なリスト

なお、参考記事ではアロー関数を2回使って算出プロパティに引数を渡していますが、今回は1回だけにしています. アロー関数内ではthisが束縛されてしまうためです.

qiita.com

ステップ2. D&Dできるようにする

Vueに限らず、D&Dできるようになる最低限の手順は以下になります

  1. ドラッグしたい要素のdraggable属性の属性値をtrueにする.
  2. ドロップされる領域の要素に dropイベントリスナを登録する. ただし、 既定のアクションをキャンセルする

実用的にはdropイベント以外も実装したいところなので、次の手順も追加しましょう

  1. ドラッグしたい要素にdragstartイベントリスナを登録する.
  2. ドロップされる領域の要素に 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')

できあがり

参考

developer.mozilla.org

developer.mozilla.org

developer.mozilla.org

developer.mozilla.org

www.code-magagine.com