Background
Drag&Drop이 가능한 div 컴포넌트를 구현해야 했습니다.
Drag&Drop의 기본 로직은 단순합니다.
해당 요소의 위치 상태를 관리하면서
요소를 누르면 Drag가 시작되고, 마우스를 움직이면 위치 상태를 변경하고, 마우스를 떼면 Drag를 멈추면 되겠죠.
import { useState, useEffect, useRef } from 'react';
interface Props {
initialTop: number;
}
const useVerticalDrag = ({ initialTop }: Props) => {
const [isDragging, setIsDragging] = useState(false);
const [top, setTop] = useState(initialTop); // 요소의 위치 상태
const componentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (isDragging && componentRef.current) {
setTop(event.clientY);
}
};
const handleMouseUp = () => {
if (isDragging) {
setIsDragging(false);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const handleMouseDown = () => {
setIsDragging(true);
};
return {
componentRef,
top,
handleMouseDown,
};
};
export default useVerticalDrag;
저의 경우 위아래로만 움직이면 되었기 때문에, 위치 상태를 top만 관리했습니다.
import useVerticalDrag from './hooks';
interface Props {
onClick: () => void;
}
const VerticalDragBox = ({ onClick }: Props) => {
const { top, componentRef, handleMouseDown } = useVerticalDrag(
{ initialTop: 0 },
);
return (
<div
ref={componentRef}
onClick={onClick}
onMouseDown={handleMouseDown}
style={{ top }}
/>
);
};
export default VerticalDragBox;
실제 사용처에서는 위와 같이 작성해 주면 되겠죠.
Problem
물론 위 코드도 Drag&Drop에 따라 요소의 위치가 변경됩니다.
하지만...
callback으로 넘겨받은 onClick함수는 클릭할 때만 실행되어야 하는데,
드래그는 종료하면서 마우스를 뗄 때도 onClick 콜백함수가 실행되는 문제가 발생했습니다.
왜 이런 일이 발생한 것일까요?
Caused
이를 이해하기 위해서는, 클릭 이벤트와 마우스 이벤트의 차이와 실행 순서를 살펴볼 필요가 있습니다.
Click VS MouseDown
클릭 이벤트(click)는 마우스 버튼이 눌리고(mousedown) 떼어지는(mouseup) 동작이 연속적으로 같은 요소에서 발생할 때 발생합니다. 반면, 마우스 업 이벤트(mouseup)는 마우스 버튼이 떼어질 때 발생합니다.
실행 순서는 아래와 같습니다.
- mousedown 이벤트 발생
- (마우스를 움직이는 경우) mousemove 이벤트 발생
- mouseup 이벤트 발생
- (마우스를 움직이지 않고 같은 요소에서 버튼을 떼면) click 이벤트 발생
아하!
즉 위 로직에서 Drag&Drop 후 마우스를 뗄 때
우선적으로 mouseup 이벤트가 발생하고,
해당 이벤트가 mousedown 이벤트가 발생한 요소와 동일한 경우 → click 이벤트가 실행되고 마는 것이죠.
Solution
즉, mouseup 한 요소에서 마우스를 떼었을 때 click 이벤트가 실행되는 것을 막을 수는 없습니다.
그렇다면... Drag&Drop이 실행된 경우, onClick callback 함수의 실행을 막는 방향으로 접근해야 하겠죠.
하지만 여전히 문제는 존재합니다. 어떻게 해야 Drag&Drop이 실행되었다 를 알 수 있을까요?
여러 방법이 있겠지만, 저는 그중
마우스를 떼는 시점의 요소 위치 값을 저장해
click 이벤트가 발생할 때, 현재 요소의 위치와 비교하여, 같을 경우 Drag&Drop이 발생했다 는 논리를 세웠습니다.
// hooks
const mouseUpClientY = useRef<number>(initialTop);
const handleMouseUp = (event: MouseEvent) => {
if (isDragging) {
setIsDragging(false);
mouseUpClientY.current = event.clientY;
}
};
mouseUp 당시의 요소 위치값에 해당하는 ref를 추가한 뒤
mouseUp이 실행되면, 발생한 이벤트의 위치값을 저장했습니다.
const handleOnClick = () => {
/** @description 현재 top과 mouseUp 이벤트 당시 clientY가 동일하다는 건, 드래그가 발생했다는 뜻 */
const isDragged = top !== mouseUpClientY.current;
if (!isDragged) onClick?.();
};
mouseMove 이벤트로, 요소의 위치 상태(여기서는 top)는 계속해서 변경되고 있습니다.
그런데 이때 click 이벤트에서 요소 위치 상태가 mouseUp이 발생한 위치와 동일하다는 건
→ 직전 mouseUp으로 위치가 변경된, 즉 Drag&Drop이 발생했다고 생각할 수 있겠죠!
import { useState, useEffect, useRef } from 'react';
interface Props {
onClick: (() => void) | null;
initialTop: number;
}
const useVerticalDrag = ({ onClick, initialTop }: Props) => {
const [isDragging, setIsDragging] = useState(false);
const [top, setTop] = useState(initialTop);
const componentRef = useRef<HTMLDivElement>(null);
const mouseUpClientY = useRef<number>(initialTop);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (isDragging && componentRef.current) {
setTop(event.clientY);
}
};
const handleMouseUp = (event: MouseEvent) => {
if (isDragging) {
setIsDragging(false);
mouseUpClientY.current = event.clientY;
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const handleMouseDown = () => {
setIsDragging(true);
};
const handleOnClick = () => {
/** @description 현재 top과 mouseUp 이벤트 당시 clientY가 동일하다는 건, 드래그가 발생했다는 뜻 */
const isDragged = top === mouseUpClientY.current;
if (!isDragged) onClick?.();
};
return {
componentRef,
top,
handleMouseDown,
handleOnClick,
};
};
export default useVerticalDrag;
즉 위와 같은 형태로 hooks를 작성할 수 있고
import useVerticalDrag from './hooks';
interface Props {
onClick: () => void;
}
const VerticalDragBox = ({ onClick }: Props) => {
const { top, componentRef, handleMouseDown, handleOnClick } = useVerticalDrag(
{ onClick, initialTop: 291 },
);
return (
<div
ref={componentRef}
onClick={handleOnClick}
onMouseDown={handleMouseDown}
style={{ top }}
/>
);
};
export default VerticalDragBox;
사용처에서는 위와 같이 onClick 콜백을 넘겨주면 되겠습니다.
'source-code > React' 카테고리의 다른 글
You Might Not Need a Dispatch<SetStateAction> Type. (1) | 2024.10.29 |
---|---|
Cannot read properties of null (reading 'useContext') 에러 해결 (0) | 2024.01.16 |
react-dom의 root.render()를 여러 번 호출하면 어떤 일이 생길까? (0) | 2024.01.04 |
useQuery data type 지정하기 (feat. 관심사 분리) (1) | 2023.08.16 |
react-query와 error 전파 handling (0) | 2023.08.16 |