Nuxt3でライブラリ無で並べ替え機能を実装する

掲載日
更新日

はじめに

このサイトを作っている自作CMSでは記事にタグを付ける機能があります。

(※記事の下のこの部分。)

 

そのため、以下の機能を準備しました。

  1. タグ登録・修正・削除機能
  2. タグの並べ替え機能
     

このうち2の方は、sortable.jsのような並べ替え機能を用意しようと思い、対応するライブラリを使ってもよかったのですが、
多くはネストなどに対応している(階層建てられるカテゴライズに使える)リッチな機能となってます。
タグの場合、階層がないフラットな状態になるのでそこまでリッチなライブラリを入れる必要もないかなと思い、自前で準備してみました。

実装

完成イメージ

まず実際の完成イメージがこちらになります。(gif動画)
要件は以下の通り。

  • ドラッグ・ドロップで並べ替えができる。
  • ドラッグ中、重なった位置(並び替え先)にシャドウを表示し、実際に並び替えた状態が確認できる。
  • ドロップすると並び替えが確定する。
     
並び替えイメージGIF動画

 

コード

実際はCMSに組み込むために編集ボタンなども付けてますが、その辺を省いて並び替え機能だけ抜粋したコードがこちら。

<script setup lang="ts">
type Tag = {
    id: number
    label: string
    status: number | undefined
}

const STATUS_GHOST = 1

const masterTags = ref<Tag[]>([
    {
        id: 1,
        label: 'TEST1',
        status: 0,
    },
    {
        id: 2,
        label: 'TEST2',
        status: 0,
    },
    {
        id: 3,
        label: 'TEST3',
        status: 0,
    },
    {
        id: 4,
        label: 'TEST4',
        status: 0,
    },
]) // タグデータのマスタ
const tags = ref<Tag[]>(JSON.parse(JSON.stringify(masterTags.value))) // 表示用データ(ドロップするまで確定させないため、表示用は別に保持しておく。)

const dragFromIndex = ref<number | null>(null)
const dragToIndex = ref<number | null>(null)

const onDragStart = (index: number) => {
    dragFromIndex.value = index // ドラッグしている(並べ替え対象の)アイテムの位置を格納
}

// ドラッグアイテムが別のアイテムの上に重なったら
const onDragEnter = (index: number) => {
    dragToIndex.value = index // 移動先indexを格納
    const array = tags.value
    let to = null
    if (dragFromIndex.value != index && dragFromIndex.value != null) { // 移動元と移動先が一緒 or 移動元情報が無ければ(画面外から別のドラッグをしているなら)処理しない
        to = JSON.parse(JSON.stringify(array[dragFromIndex.value])) // 移動元データをコピーし
        to.status = STATUS_GHOST // ゴースト設定

        if (dragFromIndex.value > index) { // 移動元が移動先より上なら、
            array.splice(dragToIndex.value, 0, to) // ゴーストを下に配置し
            array.splice(dragFromIndex.value + 1, 1) // 元データ削除して入れ替え先を表示
        }
        else {
            array.splice(dragToIndex.value + 1, 0, to) // ゴーストを上に配置
            array.splice(dragFromIndex.value, 1) // 元データ削除して入れ替え先を表示
        }
    }
}

// ドラッグアイテムが別のアイテムから離れたら
const onDragLeave = () => {
    // マスタデータで上書きしてゴーストを除去する
    tags.value = JSON.parse(JSON.stringify(masterTags.value))
}

// ドロップされたら、データを上書きする
const onDrop = () => {
    const array = masterTags.value

    if (
        dragFromIndex.value != dragToIndex.value
        && dragFromIndex.value != null
        && dragToIndex.value != null
    ) {
        if (dragFromIndex.value > dragToIndex.value) { // 移動元が移動先より上なら、
            // toの位置に追加し、fromの位置を削除
            array.splice(dragToIndex.value, 0, array[dragFromIndex.value])
            array.splice(dragFromIndex.value + 1, 1)
        }
        if (dragFromIndex.value < dragToIndex.value) { // 移動元が移動先より下なら、
            // toの位置に追加し、fromの位置を削除
            array.splice(dragToIndex.value + 1, 0, array[dragFromIndex.value])
            array.splice(dragFromIndex.value, 1)
        }

        // 直した配列を表示に反映
        tags.value = JSON.parse(JSON.stringify(masterTags.value))
    }

    onDragEnd()
}

const onDragEnd = () => {
    if (dragFromIndex.value !== null) {
        tags.value[dragFromIndex.value].status = undefined
    }
    if (dragToIndex.value !== null) {
        tags.value[dragToIndex.value].status = undefined
    }
    dragFromIndex.value = null
    dragToIndex.value = null
}

const addClass = (status: number | undefined) => {
    if (typeof status != 'undefined') {
        if (status == STATUS_GHOST) {
            return 'opacity-70'
        }
    }
}

const touchOverIndex = ref<number | null>(null)
// 指の位置にあるindexを取得してdragenterと同じ操作を行う
const onTouchmove = (event: TouchEvent) => {
    const x = event.changedTouches[0].clientX
    const y = event.changedTouches[0].clientY
    let dom = document.elementFromPoint(x, y)

    if (!dom) {
        return
    }

    // 隙間に入ると親要素を掴んでしまうので親要素の場合無視する
    if (dom.tagName == 'UL') {
        return
    }

    const li = dom.closest('li')
    if (li) {
        dom = li
    }
    const ulChildren = dom.closest('ul')?.children
    if (!ulChildren) {
        return
    }

    const lists = Array.from(ulChildren)
    const index = lists.findIndex(li => li == dom)

    if (touchOverIndex.value === index) { // 同じなら処理しない
        return
    }
    else {
        onDragLeave()
        onDragEnter(index)
        touchOverIndex.value = index
    }
}
</script>

<template>
    <ul
        class="mb-8"
        @drop="onDrop"
        @touchend="onDrop"
        @dragenter.prevent
        @dragover.prevent
    >
        <li
            v-for="(tag, index) in tags"
            :key="index"
            class="box-border flex w-full rounded px-4 py-1 bg-blue-500 text-white mt-2 justify-between"
            :class="addClass(tag.status)"
            draggable="true"
            style="cursor: grab"
            @touchstart="onDragStart(index)"
            @dragstart="onDragStart(index)"
            @touchmove="onTouchmove($event)"
            @dragenter="onDragEnter(index)"
            @dragleave="onDragLeave()"
            @dragend="onDragEnd()"
        >
            <div class="flex items-center">
                <i class="material-icons mr-2 text-2xl">drag_indicator</i>
                <span>
                    {{ tag.label }}
                </span>
            </div>
        </li>
    </ul>
</template>

解説

※ 並べ替え機能自体は参考サイトをほぼほぼ真似させて頂いてます、シャドウ(ゴースト)表示部分とタッチ操作部分だけオリジナル(とはいっても、著名ライブラリの車輪の再発明かつ劣化版だと思いますが)で頑張りました。

  1. シャドウ表示を行う(兼、ドロップするまで並び替えを確定させない)ため、タグの配列を2つもちます。
    masterTags :実際のタグの並びを保存する配列
    tags:表示用のタグの並びを保存する配列
  2. dragFromIndex, dragToIndex で、ドラッグ元、ドラッグ先の配列の位置を保存する変数
  3. ドラッグ開始でドラッグ元のタグの位置を取得してdragFromIndexに保存します。
  4. ドラッグしているカーソルがタグの上に重なった(dragenterイベントが発火した)ら、重なっているタグのindexを取得し、dragToIndexに保存します。
    表示用のtags配列で、dragFromIndexのタグをdragToIndexの位置に追加し、元の位置からは削除します。
    (移動先が元データより上か下かで追加位置は調整)
    ドラッグしている間は確定しないことを示すために、タグにstatusプロパティを持たせて、STATUS_GHOST(1)の値を持たせます。(表示時にこのstatusの値を見て、opacityを指定することでシャドウっぽい表示にします。)
  5. ドラッグしているカーソルがタグの上に重なった後、そのタグの位置からカーソルが離れた(dragleaveイベントが発火した)ら、4の処理をリセットするため、masterTagsのデータで上書きます。
  6. ドロップ(drop)されたら、以下を実行します。
    dragFromIndex, dragToIndexの値が一致するなら、並び替えされてないので処理しない。
    dragFromIndex, dragToIndexの値が入ってないなら、ドラッグ操作が正常に行われていないので処理しない。
    上記以外の場合、4と同様の処理をmasterTagsデータに行います。(表示用配列も上書き。)
  7. ドラッグが終了したら、各種データを初期化します。

 

補足解説

スマートフォンには(まだ?)drag系イベントが存在しないため、タッチイベントで代替します。上記1,2までは同じ。

  1. タッチ開始(touchstart)で、3と同じ処理を行います。
  2. dragenter, dragleaveはないため、以下の方法でtouchmoveイベントで代わりの操作を行います。
    enterしたタグのindexを取得できないため、TouchEventからタッチ位置のX,Y座標を取得しelementFromPoint関数で重なっている位置のDOMを取得する。

    findIndex関数でタグの位置を取得してdragToIndexとして扱う。
  3. ---おまけ(ここは作り方次第で不要だと思います。)---
    サンプルの場合、liタグのindex値を取得しないといけないのですが、liタグの下にspanタグを含んでいます。
    文字列の辺りに指をかぶせるとliタグではなくspanタグを取得するので、closestを使って確実にliを掴ませています。
    ------
  4. あとはdragleave, dragenterと同じ処理を実行します。
    ※ touchmoveイベントは移動するごとに発火してしまうので、touchOverIndexにindex値を保存しておき、値が同じ間は処理をしないよう制御する必要があります。
  5. タッチ終了(touchend)で、7と同じ処理を行います。

関連リンク

所感

ドラッグ操作の方は参考サイト様のおかげでそんなに時間がかかりませんでしたが、
タッチ操作の方は指が重なってるのがどのDOMの場所か取得するために座標を使う必要がありトリッキーな感じがして大変でした。

とはいえ、スマホ時代なので、こういった互換操作の作り方は重要なので勉強できてよかったです。

記事の作成者のA.W.のアイコン

この記事を書いた人

A.W.
茨城県在住Webエンジニアです。 PHPなどを業務で使用しています。 趣味ではGoやNuxt、Flutterをやってます。

Comment