본문 바로가기
source-code/React

Drag&Drop 컴포넌트에서 클릭 이벤트와 드래그 이벤트 분리하기

by mattew4483 2024. 8. 1.
728x90
반응형

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)는 마우스 버튼이 떼어질 때 발생합니다.

 

실행 순서는 아래와 같습니다.

  1. mousedown 이벤트 발생
  2. (마우스를 움직이는 경우) mousemove 이벤트 발생
  3. mouseup 이벤트 발생
  4. (마우스를 움직이지 않고 같은 요소에서 버튼을 떼면) 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 콜백을 넘겨주면 되겠습니다.

728x90
반응형