JavaScriptを使ってドラッグアンドドロップで要素を入れ替える

UI開発者 板垣

WebアプリではWebサイトよりもドラッグアンドドロップ(以下、D&D)で要素を動かしたり、ピンチイン/アウトで要素の縮尺を変更するなど、比較的直感的な操作が求められます。今回は、それらの1つであるD&Dで要素を入れ替える方法をご紹介します。

デモ

まずはデモをご覧ください(PCのみ動作します)

See the Pen draggable by sho itagaki (@sho_itagaki) on CodePen.

コード解説

今回は、処理の重要箇所である「要素の移動」と「要素の交換」について詳しく解説します。

要素の移動

要素を移動させるには、mousedownmousemovemouseupイベントを使います。

はじめに、mousedownイベントを見ていきましょう。
ここでは、2つ押さえておきたい点があります。1つ目は、マウスカーソルのXY座標から、対象要素のXY座標を引いた数値(以下、diff値)を保存しておくことです。 この数値は要素を移動させる際に、mousedownイベント発生時のマウスカーソル座標と、対象要素の位置関係を正確に保つために使用します。
2つ目は、mousemoveイベントとmouseupイベントを対象要素ではなく、windowに付与することです。
なぜなら、対象要素にmousemoveイベントを付与した場合、マウスを高速で移動させるとマウスカーソルが対象要素から離れてmousemoveイベントが発生しなくなるからです(mouseupも同様)。

ちなみに、target.style.width = `${targetW}px`;という記述がありますが、これは要素の移動時、つまり要素にposition: absolute;が掛かった時に要素の横幅を維持させるために記述されています。

const ev = {
    down(e) {
        const target = e.target;
        const pageX = e.pageX;
        const pageY = e.pageY;
        const targetW = target.offsetWidth;
        const targetRect = target.getBoundingClientRect();
        const targetRectX = targetRect.left;
        const targetRectY = targetRect.top;

        data.target = target;
        data.diffX = pageX - targetRectX;
        data.diffY = pageY - targetRectY;
        data.cloneName = util.insertClone(target, util.index(target));
        target.style.width = `${targetW}px`;
        target.classList.add('onGrab');
        window.addEventListener('mousemove', ev.move);
        window.addEventListener('mouseup', ev.up);
    },
    ...
    ddBoxList.forEach((el) => {
        el.addEventListener('mousedown', ev.down);
    });

続いて、mousemoveイベントの処理を見ていきます。
ここには要素を移動させる処理が書かれていて、マウスカーソルのXY座標からdiff値を引いた数値を対象要素のスタイルに付与させることによって要素を移動させています。

    ...
    move(e) {
        const target = data.target;
        const pageX = e.pageX;
        const pageY = e.pageY;
        const targetPosL = pageX - data.diffX;
        const targetPosT = pageY - data.diffY;

        target.style.left = `${targetPosL}px`;
        target.style.top = `${targetPosT}px`;
        util.swap(target);
    },
    ...

最後に、mouseupイベントです。
ここで、要素やデータの状態の初期化を行って配置を完了させます。

    up() {
        const target = data.target;
        const cloneSelector = `.${data.cloneName}`;
        const clone = document.querySelector(cloneSelector);

        data.cloneName = '';
        clone.remove();
        target.removeAttribute('style');
        target.classList.remove('onGrab');
        target.classList.remove('onDrag');
        window.removeEventListener('mousemove', ev.move);
        window.removeEventListener('mouseup', ev.up);
    }

要素の交換

要素の交換処理はswap()関数にまとめられています。
他と比べるとコードが長いため身を引いてしまいそうになりますが、実際はただ変数が多くあるだけなのでご安心ください。

    ...
    swap(target) {
        const selfIdx = util.index(target);
        const cloneIdx = selfIdx + 1;
        const parent = target.parentElement;
        const siblings = parent.querySelectorAll(`:scope > *:not(.onGrab):not(.${data.cloneName})`);

        for (let thatIdx = 0, len = siblings.length; thatIdx < len; thatIdx++) {
            const targetW = target.offsetWidth;
            const targetH = target.offsetHeight;
            const targetRect = target.getBoundingClientRect();
            const targetRectX = targetRect.left;
            const targetRectY = targetRect.top;
            const that = siblings[thatIdx];
            const thatW = that.offsetWidth;
            const thatH = that.offsetHeight;
            const thatRect = that.getBoundingClientRect();
            const thatRectX = thatRect.left;
            const thatRectY = thatRect.top;
            const thatRectYHalf = thatRectY + (thatH / 2);
            const hitX = thatRectX <= (targetRectX + targetW) && thatRectX + thatW >= targetRectX;
            const hitY = targetRectY <= thatRectYHalf && (targetRectY + targetH) >= thatRectYHalf;
            const isHit = hitX && hitY;

            if (isHit) {
                const siblingsAll = parent.children;
                const clone = siblingsAll[cloneIdx];

                parent.insertBefore(clone, selfIdx > thatIdx ? that : that.nextSibling);
                parent.insertBefore(target, clone);

                break;
            }
        }
    }
    ...

処理の流れとしては、forループの中で要素の当たり判定をし、判定結果によってinsertBefore()で要素の位置を入れ替えるというものになっています。
ここでは当たり判定処理に注意しましょう。isHit変数には、D&Dしている要素の当たり判定が格納されているのですが、これがtrueの場合はそれ以降の処理を走らせないためにforループをbreakで止めなくてはなりません。 これが抜けてしまうと、その次の要素を入れ替える処理で不具合が起きてしまします。

おわりに

今回ご紹介したコードはPCのみしか動作しませんが、これを応用すればスマートフォンにも対応できます。
また、内部で行っている処理は要素の入れ替えだけでなく、ほかの用途でも使えるのでご参考程度にコードを全文見ていただくとおもしろいかもしれません。

※WCAG 2.1のシングルAに準拠する際はキーボードでD&Dと同等の操作ができるインターフェースを用意する必要があります。