ツクログ

tsukulognet

tsukulognet

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

【React hooks】要素のドラッグ&ドロップを可能にするカスタムフックを作成する

eye catch

【React hooks】要素のドラッグ&ドロップを可能にするカスタムフックを作成する

useDragAndDrop

以下のようにして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 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

        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
    }

    useEffect(() => {
        window.addEventListener('mousemove', handleMouseMove)
        window.addEventListener('mouseup', handleMouseUp)

        return () => {
            window.removeEventListener('mousemove', handleMouseMove)
            window.removeEventListener('mouseup', handleMouseUp)
        }
    }, [])

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

    return [
        currentDragElement,
        handleMouseDown,
        elementPosition,
        setElementPosition,
        resetElementOffset,
        pointerStartPosition,
        pointerMovePosition,
        elementOffset,
        setElementOffset
    ]
}

export default useDragAndDrop

要素をマウスボタンで押下するとmousedownイベントが発生してhandleMouseDown関数が呼ばれます。ここでは、mousedown時のマウス座標pointerStartPositionを始め、押下した要素currentDragElementやその要素の座標elementPositionを取得しています。currentDragElementやelementPositionは後に要素がドラッグ中かどうかを示すために取得しています。

更にそのままマウスを動かすとmousemoveイベントが発生し、handleMouseMove関数が呼ばれます。ここではまず、

if(!currentDragElement.current) return

としており、先ほど取得したcurrentDragElementが存在しない、すなわちマウスボタンを押下していない場合は、mousemoveイベントが発生するのを防いでいます。

次に、mousemove中のマウス座標pointerMovePositionを取得し、先ほどのpointerStartPositionから見たpointerMovePositionの相対座標pointerMoveDistanceを取得します。

そして、moveDistance関数を呼び出す際にpointerMoveDistanceを引数に渡します。moveDistance関数は、setElementOffset関数で要素のオフセットelementOffsetを更新します。尚、更新時、

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

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

mousemove中にマウスボタンを離すとmouseupイベントが発生してhandleMouseUp関数が呼ばれます。ここでもまずhandleMouseMoveと同様に、マウスボタンを押下していないときにmouseupイベントが反応するのを防いでいます。

次に、要素の座標elementPositionの更新とドラッグしていた要素currentDragElementのリセットを行い、次のドラッグに備えます。

これら一連の処理によってドラッグ&ドロップを可能にしています。

また、これまで一度も呼ばれていないresetElementOffset関数は、何らかの理由でドロップ毎にオフセットをリセットした場合のために用意しています。

使い方

適当なコンポーネントを作成してuseDragAndDropフックを組み込み、要素のドラッグ&ドロップを可能にします。

const App = () => {
    const [
        currentDragElement,
        handleMouseDown,
        elementPosition,
        setElementPosition,
        resetElementOffset,
        pointerStartPosition,
        pointerMovePosition,
        elementOffset,
        setElementOffset
    ] = useDragAndDrop()

    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>
    )
}

useDragAndDropフックから展開したelementOffsetをtranslate3dに、handleMouseDown関数をドラッグしたい要素のonMouseDown属性にそれぞれセットします。

これにより、要素のドラッグ&ドロップが可能になります。

使用例

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