ツクログネット

【React + TypeScript】要素をドラッグ&ドロップ可能にする

eye catch

React HooksとTypeScriptでページ上の要素をドラッグ&ドロップ可能にする方法です。

やろうとしていること

ドラッグしたい要素のclassName属性に.draggableクラスを追加、onMouseDown属性にイベントハンドラーを指定することでその要素がドラッグ&ドロップ可能になるような実装をします。

App.tsx
const App = () => {
    /* 省略 */

    return (
                <div>
                        <Draggable
                            className="draggable"
                            onMouseDown={handleDown}
                        />
                        <div
                            className="draggable"
                            onMouseDown={handleDown}
                        ></div>
                </div>
    );
};

カスタムフックを作成する

はじめに、カスタムフックを作成します。フック名はuseDraggableElementsとします。useDraggableElementsフックでは、要素をドラッグ&ドロップ可能にするロジックを書きます。

hooks/useDraggableElements.ts
import {
    useState,
    useRef,
    useEffect,
    useLayoutEffect,
    useCallback
} from "react";

type Point = {
    x: number;
    y: number;
};

type Axis = 'x' | 'y';

type MouseStatus = {
    isDown: boolean;
    isMove: boolean;
    isUp: boolean;
};

type DraggingElementStatus = {
    translate: Point;
    mouseStatus: MouseStatus;
    draggingElement: EventTarget & Element | null;
};

type Handler = (e: React.MouseEvent<EventTarget & HTMLElement>) => void;

const useDraggableElements = (isStyleTransform: boolean = true): [
    DraggingElementStatus,
    Handler
] => {
    // ドラッグしている要素の移動量
    const [translate, setTranslate] = useState<Point>({
        x: 0,
        y: 0
    });

    // 現在のマウスイベントの状態
    const [mouseStatus, setMouseStatus] = useState<MouseStatus>({
        isDown: false,
        isMove: false,
        isUp: false
    });

    // マウスを押し込んだときのカーソルの座標
    const startPoint = useRef<Point>({ x: 0, y: 0 });

    // 前回のtranslate
    const prevTranslate = useRef<Point>({ x: 0, y: 0 });

    // ドラッグしている要素
    const draggingElement = useRef<EventTarget & HTMLElement | null>(null);

    // .draggableが追加されていない要素がドラッグされないようにする
    const isDraggable = (): boolean => draggingElement.current ? draggingElement.current.classList.contains('draggable') : false;

    // mousedownが発生したときに実行する関数
    const handleDown = useCallback((e: React.MouseEvent<EventTarget & HTMLElement>): void => {
        draggingElement.current = e.currentTarget;
        if(!isDraggable()) return;

        const matrix = new DOMMatrix(getComputedStyle(draggingElement.current).transform);

        prevTranslate.current = {
            x: matrix.translateSelf().e,
            y: matrix.translateSelf().f
        };

        const draggableElements = document.getElementsByClassName("draggable") as HTMLCollectionOf<HTMLElement>;

        for(let i = 0; i < draggableElements.length; i++) {
            draggableElements[i].style.zIndex = `1000`;
        }

        draggingElement.current.style.position = 'relative';
        draggingElement.current.style.zIndex = `1001`;

        const x = e.pageX;
        const y = e.pageY;

        startPoint.current = { x, y };

        setMouseStatus(prevMouseStatus => ({
            ...prevMouseStatus,
            isUp: false,
            isDown: true
        }));
    }, []);

    // mousemoveが発生したときに実行する関数
    const handleMove = (e: MouseEvent): void => {
        e.preventDefault();
        if(!draggingElement.current) return;
        if(!isDraggable()) return;

        const differenceX = e.pageX - startPoint.current.x;
        const differenceY = e.pageY - startPoint.current.y;

        setTranslate({
            x: prevTranslate.current.x + differenceX,
            y: prevTranslate.current.y + differenceY
        });

        setMouseStatus(prevMouseStatus => ({
            ...prevMouseStatus,
            isMove: true
        }));
    };

    // mouseupが発生したときに実行する関数
    const handleUp = (e: MouseEvent): void => {
        if(!draggingElement.current) return;
        if(!isDraggable()) return;

        draggingElement.current = null;

        setMouseStatus(prevMouseStatus => ({
            ...prevMouseStatus,
            isDown: false,
            isMove: false,
            isUp: true
        }));
    };

    // 要素を動かす
    useLayoutEffect(() => {
        if(!isStyleTransform) return;
        if(!draggingElement.current) return

        draggingElement.current.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
    }, [translate]);

    // mousemove, mouseup, mouseleaveイベントが発生したときに実行されるようにする
    useEffect(() => {
        document.body.addEventListener("mousemove", handleMove);
        document.body.addEventListener("mouseup", handleUp);
        document.body.addEventListener("mouseleave", handleUp);

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

    return [
        {
            translate,
            mouseStatus,
            draggingElement: draggingElement.current
        },
        handleDown
    ];
};

export default useDraggableElements;

基本的にはコメント文の通りですが、すべてを解説すると長~くなって読みにくいため、以下の要点のみ解説します。

引数について

useDraggableElementsフックは、引数として真偽値isStyleTransformを受け取ります。引数を渡さなければデフォルト値のtrueが適用されます。trueであれば要素をドラッグしたときに、その要素のstyle属性にtransform: translate(x, y)が追加されて動くようになります。反対にfalseであればtransform: translate(x, y)は追加されません。falseは、直接style属性に追加したくない場合に渡します。

ドラッグからドロップまでの過程

ここからは、最も重要なドラッグ&ドロップに直接関わる部分のコードについて解説します。

useDraggableElementsフックを使ったドラッグ&ドロップの実現では、ドラッグからドロップまでの過程で次の3つのことを行います。

  1. 要素を押し込んだときにhandleDown関数を実行する
  2. 要素を押し込んだままマウスを動かしたときにhandleMove関数を実行する
  3. 押し込みを止めたときにhandleUp関数を実行する

では、順に見ていきます。

1. 要素を押し込んだときにhandleDown関数を実行する

まず、要素が押し込こまれたとき(mousedownイベントが発生したとき)にhandleDown関数を実行させます。handleDown関数はドラッグ&ドロップのトリガー(きっかけ)となる関数です。

handleDown関数では、まず押し込んだ要素e.currentTargetをドラッグ中の要素draggingElement.currentとして取得します。

// mousedownが発生したときに実行する関数
    const handleDown = useCallback((e: React.MouseEvent<EventTarget & HTMLElement>): void => {
        draggingElement.current = e.currentTarget;

        /* 省略 */
    };

draggingElement.currentは、押し込んだまま動かしたかどうか、離したかどうかを判定するのに必要となります。

続いて、要素の現在のtranslate()の値prevTranslate.currentと、押し込んだ時のカーソル座標e.pageX(e.pageY)を ドラッグの開始点startPoint.currentとして取得します。

// mousedownが発生したときに実行する関数
    const handleDown = useCallback((e: React.MouseEvent<EventTarget & HTMLElement>): void => {
        /* 省略 */

        const matrix = new DOMMatrix(getComputedStyle(draggingElement.current).transform);    
        prevTranslate.current = {
            x: matrix.translateSelf().e,
            y: matrix.translateSelf().f
        };

        const x = e.pageX;
        const y = e.pageY;
        startPoint.current = { x, y };

        /* 省略 */
    }, []);

このhandleDown関数をドラッグしたい要素のonMouseDown属性に指定すれば、その要素が押し込まれたときに実行されるようになります。

<div onMouseDown={handleDown}></div>

2. 要素を押し込んだままマウスを動かしたときにhandleMove関数を実行する

続いて、要素を押し込んだままマウスを動かしたときにhandleMove関数が実行されるようにします。

まず、body要素内でカーソルが動いたときにhandleMove関数が実行されるようにします。

useEffect(() => {
        document.body.addEventListener("mousemove", handleMove);
        /* 省略 */

        return () => {
            document.body.removeEventListener("mousemove", handleMove);
            /* 省略 */
        };
    }, []);

で、要素を押し込んでいるとき(mousedownイベントが発生しているとき)のみhandleMove関数の処理が行われるようにします。

const handleMove = (e: MouseEvent): void => {
    /* 省略 */

    if(!draggingElement.current) return;

    /* 省略 */
};

押し込んだ要素が入るdraggingElement.currentの中身が空であれば押し込んでいないということであるため、上記のような判定をしています。

handleMove関数では、押し込んだところstartPoint.currentからのカーソルの移動距離differenceX(differenceY)を取得後、その値をprevTranslate.currentに加えてsetTranslate()関数でtranslateステートを更新します。

const handleMove = (e: MouseEvent): void => {
    /* 省略 */

    const differenceX = e.pageX - startPoint.current.x;
    const differenceY = e.pageY - startPoint.current.y;

    setTranslate({
        x: prevTranslate.current.x + differenceX,
        y: prevTranslate.current.y + differenceY
    });
};

そして、更新されたtranslateステートをドラッグしている要素のtransform: translate()x, y値に当てはめることで、ドラッグに連動してその要素が動くようになります。

useLayoutEffect(() => {
    /* 省略 */

    if(!draggingElement.current) return

    draggingElement.current.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
}, [translate]);

useLayoutEffectを使う理由

上記のコードでuseEffectではなくuseLayoutEffectを使っている理由は、ドラッグする度に一瞬要素が元の位置(初期値)に戻るのを防いでいます。なぜuseEffectでは一瞬元の位置に戻ってしまうのかというと、useEffectは、Reactが初回レンダリングやsetState()による再レンダリングとしてコンポーネントをレンダリング(計算)したあと、その結果が前回と違っていたとき(propsやstateが変わったことによる差分があったとき)にDOMを変更したあと、その変更されたDOMをブラウザが画面に反映(ブラウザによるレンダリング)した後に実行されるため、先に更新前のステート(初期値)がtranslate()のx, y値にセットされて画面に反映されたあとにuseEffectによって更新後の値がセットされるため、一瞬初めの位置に戻るということです。

一方のuseLayoutEffectは、ReactがDOMを変更したあとにブラウザがReactが変更したDOMを画面に反映する前に実行されるため、一瞬変更前の状態が見えることなく、変更後のステート値が反映されるのです。

3. 押し込みを止めたときにhandleUp関数を実行する

最後に、ドロップされたときにhandleUp関数が実行されるようにします。

まず、body要素のmouseupmouseleaveイベントが発生したときにhandleUp関数が実行されるようにします。

// 初回のレンダー後に一度だけ実行
    useEffect(() => {
        /* 省略 */

        document.body.addEventListener("mouseup", handleUp);
        document.body.addEventListener("mouseleave", handleUp);

        return () => {
            /* 省略 */

            document.body.removeEventListener("mouseup", handleUp);
            document.body.removeEventListener("mouseleave", handleUp);
        };
    }, []);

handleUp関数をmouseupイベントだけでなくmouseleaveイベントにも紐づけている理由は、ドラッグしているときにカーソルが画面から外れたとき(mouseleaveイベントが発生したとき)は強制的にドロップさせるためです。

続いて、マウスを押し込んでいるとき(ドラッグしているとき)のみhandleUp関数の処理が行われるようにします。

// mouseupが発生したときに実行する関数
    const handleUp = (e: MouseEvent): void => {
        if(!draggingElement.current) return;

        /* 省略 */
    };

handleUp関数ではdraggingElement.currentを空の状態に戻しています。ドロップ=要素の押し込みをやめるということであるためです。

// mouseupが発生したときに実行する関数
    const handleUp = (e: MouseEvent): void => {    
        /* 省略 */

        draggingElement.current = null;

        /* 省略 */
    };

これらの過程を経て要素のドラッグ&ドロップが実現します。

要素をドラッグ&ドロップ可能にしてみる

作成したuseDraggableElementsフックを使って要素をドラッグ&ドロップ可能にしてみます。

App.tsxを下記のようにします。

App.tsx
import React from 'react';
import useDraggableElements from './hooks/useDraggableElements';

const App = () => {
    const [draggingElementStatus, handleDown] = useDraggableElements();    

    return (
        <>
            <GlobalStyle />
            <StyledApp>
                <div className="container">
                    <div className="draggables">
                        <Draggable
                            id="element-1"
                            className="element-1 draggable"
                            onMouseDown={handleDown}
                        />
                        <Draggable
                            id="element-2"
                            className="element-2 draggable"
                            onMouseDown={handleDown}
                        />
                    </div>
                </div>
            </StyledApp>
        </>
    );
};

export default App;

useDraggableElementsフックを呼び出し、ドラッグ&ドロップを可能にしたい要素のclassName属性に.draggableクラス、onMouseDown属性に戻り値のhandleDown関数を指定することで、ページ上の要素がドラッグ&ドロップ可能になります。

draggable elements post 1

Demo

下記のデモで実際の動作を確認できます。 尚、 下記のデモでは上記の例では出番がなかったdraggingElementStatusの各値を画面の上部に出力しています。

この記事をシェアする

関連する記事