ツクログ

tsukulognet

tsukulognet

道産子。Reactでなまら面白いものを作りたい。

【React hooks】要素をドラッグ&ドロップで移動させる

eye catch

Reactでページ上の要素をドラッグ&ドロップで動かす方法です。

カスタムフックの作成

コンポーネントのロジック部分を、他のコンポーネントでも使い回せるようにするため、コンポーネントのロジック部分を抽出した関数であるカスタムフックを作成します。

useDragAndDropフック

以下のようにして、DOM要素のドラッグ&ドロップを可能にするuseDragAndDropフックを作成します。 useDragAndDrop.js

import { useState, useCallback, useRef, useEffect } from 'react'

const useDragAndDrop = () => {
    const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 });
    const [elementOffset, setElementOffset] = useState({ x: 0, y: 0 });

    const pointerStartPosition = useRef({ x: null, y: null });
    const pointerMovePosition = useRef({ x: null, y: null });

    const currentDragElement = useRef(null);

    const prevElementOffset = useRef({ x: 0, y: 0 });

    const prevElementOffsetX = useRef(0);
    const prevElementOffsetY = useRef(0);

    const getCurrentPosition = (elem) => {
        const { top, left } = elem.getBoundingClientRect();
        return { top, left };
    };

    const moveDistance = (distance) =>
        setElementOffset({
            x: prevElementOffset.current.x + distance.x,
            y: prevElementOffset.current.y + distance.y
        });

    const resetElementOffset = () => {
        setElementOffset({
            x: 0,
            y: 0
        });

        prevElementOffset.current = {
            x: 0,
            y: 0
        };
    };

    const resetPointerStartPosition = () => {
        if (
            pointerStartPosition.current.x === null ||
            pointerStartPosition.current.y === null
        )
            return;

        pointerStartPosition.current.x = null;
        pointerStartPosition.current.y = null;
    };

    const handleMouseDown = (e) => {
        e.preventDefault();

        pointerStartPosition.current.x = e.clientX;
        pointerStartPosition.current.y = e.clientY;

        currentDragElement.current = e.target;

        const elementCurrentPosition = getCurrentPosition(currentDragElement.current);

        setElementPosition({
            top: elementCurrentPosition.top,
            left: elementCurrentPosition.left
        });
    };

    const handleMouseMove = (e) => {
        e.preventDefault();

        if (!currentDragElement.current) return;
console.log('suga move: ', currentDragElement.current)
        if (
            pointerStartPosition.current.x === null ||
            pointerStartPosition.current.y === null
        )
            return;

        pointerMovePosition.current.x = e.clientX;
        pointerMovePosition.current.y = e.clientY;

        const pointerMoveDistance = {
            x: pointerMovePosition.current.x - pointerStartPosition.current.x,
            y: pointerMovePosition.current.y - pointerStartPosition.current.y
        };

        moveDistance(pointerMoveDistance);
    };

    const handleMouseUp = (e) => {
        e.preventDefault();

        if (!currentDragElement.current) return;

        resetPointerStartPosition();

        const elementCurrentPosition = getCurrentPosition(currentDragElement.current);

        setElementPosition({
            top: elementCurrentPosition.top,
            left: elementCurrentPosition.left
        });

        currentDragElement.current = null;
    };

    const updateElementPosition = (left, top) => {
        setElementPosition(p => ({
            left: left === undefined ? p.left : left,
            top: top === undefined ? p.top : top
        }))
    }

    const updateElementOffset = (x, y) => {
        setElementOffset(p => ({
            x: x === undefined ? p.x : x,
            y: y === undefined ? p.y : y
        }))
    }

    useEffect(() => {
        document.body.addEventListener("mousemove", handleMouseMove);
        document.body.addEventListener("mouseup", handleMouseUp);
        document.body.addEventListener("mouseleave", handleMouseUp);

        return () => {
            document.body.removeEventListener("mousemove", handleMouseMove);
            document.body.removeEventListener("mouseup", handleMouseUp);
            document.body.removeEventListener("mouseleave", handleMouseUp);
        };
    }, []);

    useEffect(() => {
        prevElementOffset.current = {
            x: elementOffset.x,
            y: elementOffset.y
        };
    }, [elementPosition.left, elementPosition.top]);

    return [
        {
            currentDragElement,
            elementPosition,
            elementOffset,
        },
      {
            pointerStartPosition,
            pointerMovePosition
        },
        handleMouseDown,
        updateElementOffset,
        resetElementOffset,
        updateElementPosition
    ];
};

export default useDragAndDrop

useDragAndDropフックを呼び出すと、以下のものが返ります。

返り値 説明
draggableElementオブジェクト ドラッグ対象のDOM要素の情報で構成されたオブジェクト
pointerオブジェクト ポインターの位置情報で構成されたオブジェクト
handleMouseDown DOM要素を押下したときに実行する関数
setElementOffset ドラッグ対象のDOM要素のオフセット値を更新する関数
resetElementOffset ドラッグ対象のDOM要素のオフセット値をリセットする関数
setElementPosition ドラッグ開始・終了時のDOM要素の位置

resetElementOffset関数は、何らかの理由でドロップ毎にオフセット値をリセットしたいときのために用意しています。

useDragAndDropフックを呼び出すと、useEffectフックが一度だけ呼ばれ、body要素の各イベントに対して処理が登録されます。

useEffect(() => {
        document.body.addEventListener("mousemove", handleMouseMove);
        document.body.addEventListener("mouseup", handleMouseUp);
        document.body.addEventListener("mouseleave", handleMouseUp);
      ...
    (省略)
    ...
    }, []);

また、以下のように、useEffectフック内で関数を返すことで、クリーンアップ関数を登録できます。これにより、コンポーネントが消えたあとに、クリーンアップ関数が実行され、body要素の各イベントに登録した処理が削除されます(クリーンアップされる)。クリーンアップをしないと、コンポーネントが消えたあともbody要素の各イベントに処理が登録されたままになってしまいます。

useEffect(() => {
        ...
    (省略)
    ...
        return () => {
            document.body.removeEventListener("mousemove", handleMouseMove);
            document.body.removeEventListener("mouseup", handleMouseUp);
            document.body.removeEventListener("mouseleave", handleMouseUp);
        };
    }, []);

尚、上記でhandleMouseMove関数とhandleMouseUp関数を、handleMouseDown関数と同様に、DOM要素の属性に設定していない理由は、押下したまま素早く大きく動かして、カーソルがDOM要素から離れると、次のmousemoveイベントが発生せずにその場でDOM要素が止まってしまうためです。

あとは、handleMouseDown関数をドラッグ&ドロップしたいDOM要素のonMouseDown属性に設定し、オフセット値を対象要素のCSSのtranslate3dのxとyに指定することで、DOM要素のドラッグ&ドロップが可能になります。

なぜ、上記のようなことをするとドラッグ&ドロップ可能になるのか。その仕組みは次の通りです。

  1. マウスの左ボタンでDOM要素を押下したときのカーソル座標を取得する
  2. マウスの左ボタンを押下しながらマウスを動かしたときの、1.の座標からの相対的なカーソル座標を取得する
  3. 2.の座標を、対象のDOM要素のドロップ時のオフセット値に加えてオフセット値を更新する
  4. ドロップ時にオフセット値を上書きする

1. マウスの左ボタンでDOM要素を押下したときのカーソル座標を取得する

onMouseDown属性にhandleMouseDown関数を設定したDOM要素を、マウスの左ボタンで押下すると、mousedownイベントが発生し、handleMouseDown関数が実行されます。

この関数では、押下時のカーソル座標pointerStartPositionと押下しているDOM要素currentDragElementを取得しています。

pointerStartPosition.current.x = e.clientX;
        pointerStartPosition.current.y = e.clientY;

        currentDragElement.current = e.target;

currentDragElementは、handleMouseMove関数とhandleMouseUp関数内で、DOM要素がドラッグ中であるかどうかを判別するために取得します。

2. マウスの左ボタンを押下しながらマウスを動かしたときの、1.の座標からの相対的なカーソル座標を取得する

DOM要素を押下したままマウスを動かすと、body要素のmousemoveイベントが発生し、handleMouseMove関数が実行されます。

この関数では、まず、

if(!currentDragElement.current) return

としており、先ほど取得したcurrentDragElementが存在しない、すなわちマウスボタンを押下していないときに、以降の処理が行われるのを防いでいます。

その後、押下したところからの相対的な移動中のカーソル座標を取得しています。

pointerMovePosition.current.x = e.clientX;
        pointerMovePosition.current.y = e.clientY;

        const pointerMoveDistance = {
            x: pointerMovePosition.current.x - pointerStartPosition.current.x,
            y: pointerMovePosition.current.y - pointerStartPosition.current.y
        };

3. 2.の座標を、対象のDOM要素のドロップ時のオフセット値に加えてオフセット値を更新する

2.で取得したカーソル座標を引数として与え、moveDistance関数を実行します。

moveDistance(pointerMoveDistance);

moveDistance関数では、オフセット値を更新しています。尚、更新時に、

setElementOffset({
    x: prevElementOffset.current.x + distance.x,
    y: prevElementOffset.current.y + distance.y
})

のようにして、ドロップ時のオフセットprevElementOffsetにpointerMoveDistanceを加えています。これはtransformのtranslate3dでDOM要素を移動させると、次のドラッグ時にDOM要素が一瞬ページの左上(0, 0)に戻ってしまうため、ドロップ時のオフセット値に加えることで続きから移動が始まるようにするためです。

4. ドロップ時にオフセット値を上書きする

押下を止めると、body要素のmouseupイベントが発生し、handleMouseUp関数が実行されます。

handleMouseUp関数では、まずhandleMouseMoveと同様に、押下していないときに以降の処理が行われるのを防いでいます。

続けて、ドラッグしていた要素currentDragElementのリセットを行い、次のドラッグに備えます。

これら一連の処理が行われることによって、ドラッグ&ドロップが可能になるのです。

動かしてみる

それでは実際に、DOM要素をドラッグ&ドロップさせてみます。

App.js

const App = () => {
    const [
        element,
        pointer,
        handleMouseDown,
        updateElementOffset,
        resetElementOffset,
        updateElementPosition
    ] = useDragAndDrop()

    const { 
        currentDragElement,
        elementPosition,
        elementOffset
    } = element

    const style = {
        backgroundColor: "red",
        height: "40vmin",
        transform: `translate3d(${elementOffset.x}px, ${elementOffset.y}px, 0)`,
        width: "40vmin"
    }

    return (
        <div className="App">
            <div
                style={style}
                onMouseDown={handleMouseDown}
            ></div>
        </div>
    )
}

上記では、Appコンポーネント内でuseDragAndDropフックを呼び出し、そこから展開したelementOffsetをtranslate3dに、handleMouseDown関数をドラッグしたい要素のonMouseDown属性にそれぞれセットしています。

下記のデモで、実際に要素のドラッグ&ドロップができます。絶対やってみましょう。