React HooksとTypeScriptでページ上の要素をドラッグ&ドロップ可能にする方法です。
やろうとしていること
ドラッグしたい要素のclassName
属性に.draggable
クラスを追加、onMouseDown
属性にイベントハンドラーを指定することでその要素がドラッグ&ドロップ可能になるような実装をします。
const App = () => {
/* 省略 */
return (
<div>
<Draggable
className="draggable"
onMouseDown={handleDown}
/>
<div
className="draggable"
onMouseDown={handleDown}
></div>
</div>
);
};
カスタムフックを作成する
はじめに、カスタムフックを作成します。フック名はuseDraggableElementsとします。useDraggableElements
フックでは、要素をドラッグ&ドロップ可能にするロジックを書きます。
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つのことを行います。
- 要素を押し込んだときに
handleDown
関数を実行する - 要素を押し込んだままマウスを動かしたときに
handleMove
関数を実行する - 押し込みを止めたときに
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
要素のmouseup
とmouseleave
イベントが発生したときに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を下記のようにします。
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
関数を指定することで、ページ上の要素がドラッグ&ドロップ可能になります。
Demo
下記のデモで実際の動作を確認できます。 尚、 下記のデモでは上記の例では出番がなかったdraggingElementStatus
の各値を画面の上部に出力しています。