ツクログネット

【React】要素のドラッグした方向を得る

eye catch

ドラッグする要素を表示する

はじめにドラッグする要素を表示します。

App.js
import React from 'react';
import styled, { createGlobalStyle } from 'styled-components'

const GlobalStyle = createGlobalStyle`
  body {
  	height: 100vh;
	  width: 100vw;
  }
`

const StyledApp = styled.div`
	& .drag-element {
    background-color: red;
		height: 100px;
    width: 100px;
	}
`

const App = () => {
  return (
    <>
			<GlobalStyle />
      <StyledApp>
        <div className="drag-element"></div>
      </StyledApp>
      </>
  )
}

export default App

.drag-elementがドラッグする要素です。

要素をドラッグ可能にする

続いて、ドラッグで要素が動くようにします。

はじめに、要素.drag-elementを押したときに、その要素の領域の左上からのカーソル座標を取得します。

App.js
import React, {
  useRef,
  useCallback,
  useEffect
} from 'react';

// 省略

const App = () => {
	const startPoint = useRef({ x: 0, y: 0 })
	const draggingElement = useRef(null)

  const handleDown = useCallback((e) => {
		draggingElement.current = e.currentTarget

    const { top, left } = draggingElement.current.getBoundingClientRect()

		const x = e.clientX - left
		const y = e.clientY - top

		startPoint.current = { x, y }
	})

  return (
    <>
			<GlobalStyle />
    <StyledApp>
      <div
        classname="drag-element"
        onMouseDown={handleDown}
      ></div>
    </StyledApp>
    </>
  )
}

export default App

handleDown関数を定義後、それをドラッグする要素のonMouseDown属性に設定します。

要素の領域の左上(0, 0)からのカーソル座標startPoint.currentは、handleDown関数内で取得します。

この値は、ブラウザの表示領域の左上からのカーソル座標e.clientX(e.clientY)とブラウザの表示領域の左上(0, 0)からの要素の相対座標left(top)の差で求めます。

const { top, left } = draggingElement.current.getBoundingClientRect()

const x = e.clientX - left
const y = e.clientY - top

startPoint.current = { x, y }

次に、要素を押したままカーソルを動かしたときに、その方向へ要素が移動するようにします。これがドラッグです。

ではまず、handleMove関数を定義し、startPoint.currentからの相対的なカーソル座標を取得後、それをCSSのtransformプロパティの値に設定します。

また、この座標はアプリ内で使う可能性が高いため、ドラッグ量としてステートdragAmountに保存します。

App.js
// 省略

const App = () => {
	// 省略

  const [dragAmount, setDragAmount] = useState({ x: 0, y: 0 });

  const handleMove = useCallback((e) => {
		if (!draggingElement.current) return;

		const x = e.clientX - startPoint.current.x;
		const y = e.clientY - startPoint.current.y;

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

		setDragAmount({ x, y });
	});

  return (
    // 省略
  )
}

export default App

そして、addEventListenerメソッドでhandleMove関数をbody要素のmousemoveイベントに紐づけます。

これにより、body要素の領域でカーソルが動くたびにmousemoveイベントが発生してhandleMove関数が実行されるようになります。

App.js
// 省略

const App = () => {
	// 省略

  useEffect(() => {
		document.body.addEventListener("mousemove", handleMove);

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

  return (
    // 省略
  )
}

export default App

draggingElement.currentではなくbody要素のmousemoveイベントにhandleMove関数を紐づけている理由は、要素を素早くドラッグするなどしてカーソルが要素の領域からはみ出るとmousemoveイベントが発生しなくなり、そこでドラッグが中断してしまうからです。

body要素であれば、サイズがブラウザの表示領域いっぱいになっていることが多いため、ドラッグ中にカーソルが要素の領域からはみ出ても、body要素の領域で動いている間はドラッグが継続するのです。

で、他にもう一つ注意しなければいけないことがあります。mousemoveイベントは、カーソルが対象の要素の領域上で動いている間は発生し続けるため、今のままでは要素を押していなくてもhandleMove関数の処理が行われてしまいます。

なので、押している要素が存在するときのみhandleMove関数の処理が行われるようにします。

App.js
// 省略

const App = () => {
	// 省略

  const handleMove = useCallback((e) => {
		if (!draggingElement.current) return;

		// 省略
	});

  // 省略

  return (
    // 省略
  )
}

export default App

これで、要素を押したままカーソルを動かすと、それと同時に要素がその方向へ動くようになりました。これがドラッグということです。

最後に、要素を離したときに要素の位置を最新の値に更新します。また、離した時点でドラッグが終了したということなので、draggingElement.currentを空にします。

App.js
// 省略

const App = () => {
	// 省略

  const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 });

  const handleUp = useCallback((e) => {    
    draggingElement.current.style.top = `${dragAmount.y}px`
		draggingElement.current.style.left = `${dragAmount.x}px`

		setElementPosition((p) => ({
			top: dragAmount.y,
			left: dragAmount.x
		}));

		draggingElement.current = null;
	});

  useEffect(() => {
		// 省略

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

		return () => {
      // 省略

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

  return (
    // 省略
  )
}

export default App

これで完了ではありません。ドラッグ中にカーソルがbody要素の領域からはみ出ているときにマウスの押し込みを止めると、mouseupイベントが発生しません(mouseupイベントはbody要素の領域で離したときのみ発生する)。

mouseupイベントが発生しないとhandleUp関数が実行されないため、draggingElement.currentが存在したままとなり、離した後もhandleMove関数の処理が行われ続けてしまいドラッグは正常に終了しなくなります。

これを防ぐために、ドラッグ中にカーソルがbody要素の領域からはみ出たときにもhandleUp関数を実行させてドラッグを終了させます。

App.js
// 省略

const App = () => {
	// 省略

  useEffect(() => {
		// 省略

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

		return () => {
      // 省略

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

  return (
    // 省略
  )
}

export default App

mouseleaveイベントはカーソルがターゲット要素の領域から出たときに発生するため、このタイミングでhandleUp関数を実行させます。

尚、handleUp関数もhandleMove関数と同様にbody要素のmouseupとmouseleaveイベントに紐づいているため、ドラッグ対象外の要素をクリックしたときもmouseupイベントが発生してhandleUp関数の処理が行われてしまいます。

なので、handleMove関数と同様に、ドラッグしている要素が存在するときのみhandleUp関数の処理が行われるようにします。

App.js
// 省略

const App = () => {
	// 省略

  const handleUp = useCallback((e) => {
		if (!draggingElement.current) return;

		// 省略
	});

  // 省略

  return (
    // 省略
  )
}

export default App

ドラッグした方向を得る

最後に、本題である要素をドラッグした方向を得ます。

App.js
// 省略

const App = () => {
  // 省略

  const verticalDirection = useRef(null)
	const horizontalDirection = useRef(null)
	const prevDragAmount = useRef({ x: 0, y: 0 })

  // 省略

  const handleMove = useCallback((e) => {
		// 省略

		if (x > prevDragAmount.current.x) {
			horizontalDirection.current = "right";
		} 
		if(x < prevDragAmount.current.x) {
			horizontalDirection.current = "left";
		}

		if (y > prevDragAmount.current.y) {
			verticalDirection.current = "bottom";
		}
		if (y < prevDragAmount.current.y) {
			verticalDirection.current = "top";
		}

		// 省略

		prevDragAmount.current = {
			x,
			y
		}
	})

  // 省略

  useEffect(() => {
		console.log(horizontalDirection.current)
		console.log(verticalDirection.current)
	}, [dragAmount.x, dragAmount.y]);

  return (
    // 省略
  )
}

export default App

要素を上下左右のうち、どの方向へドラッグしたのかを得るには、ドラッグ中に現在のドラッグ量dragAmountと直前のドラッグ量prevDragAmount.currentを取得してそれらを比較します。

で、例えば水平方向のドラッグで言えば、現在のドラッグ量が直前のドラッグ量よりも大きいときは正の方向であるため、右へドラッグしていることになります。なので、

if (x > prevDragAmount.current.x) {
			horizontalDirection.current = "right";
		} 

とします。

逆であれば左へドラッグ中ということであるため、

if(x < prevDragAmount.current.x) {
			horizontalDirection.current = "left";
		}

とします。垂直方向も同様に行います。

ビューとロジックを分離する

これまでに書いてきたロジックによってAppコンポーネントが肥大化したため、 Appコンポーネントからロジックを抽出し、それをカスタムフックとして定義します。

hooks/useDragElement.js
import { useState, useCallback, useRef, useEffect } from 'react'

const useDragElement = () => {
	const [dragAmount, setDragAmount] = useState({ x: 0, y: 0 });
	const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 });
	const startPoint = useRef({ x: 0, y: 0 });
	const draggingElement = useRef(null);

	const verticalDirection = useRef(null);
	const horizontalDirection = useRef(null);
	const prevPosition = useRef({ x: 0, y: 0 });

	const handleDown = useCallback((e) => {
		draggingElement.current = e.currentTarget;

		const rect = draggingElement.current.getBoundingClientRect();

		const top = rect.top;
		const left = rect.left;

		const x = e.clientX - left;
		const y = e.clientY - top;

		startPoint.current = { x, y };
	});

	const handleMove = useCallback((e) => {
		if (!draggingElement.current) return;

		const x = e.clientX - startPoint.current.x;
		const y = e.clientY - startPoint.current.y;

		if (x > prevPosition.current.x) {
			horizontalDirection.current = "right";
		} 
		if(x < prevPosition.current.x) {
			horizontalDirection.current = "left";
		}

		if (y > prevPosition.current.y) {
			verticalDirection.current = "bottom";
		}
		if (y < prevPosition.current.y) {
			verticalDirection.current = "top";
		}

		setDragAmount({ x, y });

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

		prevPosition.current = {
			x,
			y
		};
	});

	const handleUp = useCallback((e) => {
		if (!draggingElement.current) return;

		setElementPosition((p) => ({
			top: dragAmount.y,
			left: dragAmount.x
		}));

		draggingElement.current.style.top = `${dragAmount.y}px`
		draggingElement.current.style.left = `${dragAmount.x}px`

		draggingElement.current = null;
		console.log('up')
	});

	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);
		};
	}, []);

	useEffect(() => {
		console.log(horizontalDirection.current)
		console.log(verticalDirection.current)
	}, [dragAmount.x, dragAmount.y]);

	return [
		draggingElement.current,
		dragAmount,
		elementPosition,
		verticalDirection.current,
		horizontalDirection.current,
		handleDown
	]
}

export default useDragElement

で、このカスタムフックuseDragElementをAppコンポーネント内で呼び出します。

App.js
// 省略

import useDragElement from './hooks/useDragElement'

const App = () => {
  const [
		draggingElement,
		dragAmount,
		elementPosition,
		verticalDirection,
		horizontalDirection,
		handleDown
	] = useDragElement()

  return (
    // 省略
  )
}

export default App

これで完成です。

DEMO

ブラウザのコンソールを開いて赤い要素をドラッグすると、コンソールに現在のドラッグしている方向が出力されます。