이번 프로젝트를 진행하면서 나름 중요한 기능으로 생각했던 것이 바로 드래그가 가능한 창을 만드는 것이었다. 겉만 맥북과 비슷한 환경을 만드는 걸로 끝나게 되면 css 외에 별다른 기능이 없었기 때문에 이런 인터렉티브가 가능한 기능이 있다면 좀 더 재밌는 프로젝트가 아닐까 하는 생각에 추가를 하기로 했다.

어떻게 기능을 만들까

일단 이 기능을 만드는 데 꼭 필요한 좌표는 3개이다.

1) 현재 마우스 위치

2) 클릭이 시작되었을 때 마우스의 위치

3) 클릭이 시작되었을 때 창의 좌상단 위치

1번이야 당연히 현재 마우스 위치를 기반으로 드래그가 작동하기 때문에 당연히 필요했고, 2와 3번은 좀 더 정교한 드래그 효과를 위해서 필요로 하게 되었다.

일반적으로 우리가 윈도우, 맥에서 쓰는 드래그는 창을 클릭하는 순간 창 내부의 마우스의 상대 위치는 고정이 된 상태이다.

창 좌측에서 드래그를 하면 마우스를 놓을 때까지 마우스가 창 좌측에 붙어있는 상태로 창이 따라오고, 오른쪽에서 드래그를 하면 마찬가지로 마우스가 창 우측에 붙은 상태로 창이 따라온다. 이를 구현하려면 단순히 마우스 위치나 창의 너비만 가지고는 구현할 수 없다.(어딜 클릭하나 창 중앙/좌측 기준으로 드래그를 구현하려면 현재 마우스 위치만으로도 가능하다.)

마우스 클릭이 일어난 시점의 위치가 드래그 내내 고정된다.

마우스 클릭이 일어난 시점의 위치가 드래그 내내 고정된다.

때문에 이런 드래그를 구현하기 위해서는 클릭이 시작되었을 때 마우스와 창의 좌상단 위치를 구해 얼마나 거리가 떨어져있는지를 구해놓은 다음 현재 마우스 위치에서 빼주면 창이 있어야할 위치(CSS absolute 상의 left, Top 위치)를 구할 수 있다고 판단하였다.

useState를 이용한 드래그 효과 시도

맨 처음에는 당연하게도 useState를 사용해서 이 기능을 만드려고 했다. 드래그 중일 때 마우스 위치야 마우스 이벤트를 연결하면 바로 알 수 있지만, 2), 3)의 값(이하, 마우스와 창의 간격)은 클릭이 시작되었을 때 구해서 어딘가에 값을 저장해놔야 했기 때문이다.

그래서 위에서 작성한 내용을 기반으로 일단 코드를 짜보았다.

import styled from 'styled-components';
import sideimg from '../image/42memory_folder_side.png';
import titleimg from '../image/42memory_folder_title_option.png';
import fileimg from '../image/42memory_file.png';
import ButtonList from '../common/ButtonList';
import { useState, useCallback, useRef } from 'react';

interface StyledDirectoryProps {
  x: number;
  y: number;
}

const StyledDirectory = styled.div<StyledDirectoryProps>`
  position: absolute;
  left: ${(props) => `${props.x}px`};
  top: ${(props) => `${props.y}px`};
  width: 1000px;
  height: 800px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  border-radius: 8px;
  border: 1px solid #beb5b4;
  background-color: #eeeeee;
  ...중략
`;

interface DirectoryBlockProps {
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}

const DirectoryBlock: React.FC<DirectoryBlockProps> = ({ setVisible }: DirectoryBlockProps) => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);
  const [prevDistanceX, setPrevDistanceX] = useState(0);
  const [prevDistanceY, setPrevDistanceY] = useState(0);
  const headerRef = useRef<any>(null);

  const update = useCallback(
    (e: MouseEvent): void => {
      setX(e.x);
      setY(e.y);
    },
    [setX, setY],
  );
  return (
    <StyledDirectory x={x - prevDistanceX} y={y - prevDistanceY}>
      <div
        className="directory-header"
        ref={headerRef}
        onMouseDown={(e) => {
          setPrevDistanceY(e.pageX - headerRef.current.offsetTop);
          setPrevDistanceX(e.pageY - headerRef.current.offsetLeft);
          window.addEventListener('mousemove', update);
        }}
        onMouseUp={() => {
          setPrevDistanceY(0);
          setPrevDistanceX(0);
          window.removeEventListener('mousemove', update);
        }}
      >
        ...중략
      </div>
      <div className="directory-content">
        ...중략
      </div>
    </StyledDirectory>
  );
};

export default DirectoryBlock;

코드의 진행과정은 다음과 같다.

  1. directory-header 영역에서 마우스를 클릭하여 onMouseDown 이벤트를 발생시킨다. 이 때, 현재 마우스 위치(pageX, pageY)와 창의 좌상단과의 거리를 useState()를 이용해 x, y 따로 저장한다.

  2. 이후 창 전체에 mousemove 이벤트로 update 함수를 연결한다. 이 때부터 현재 마우스의 위치가 x, y 상태로 저장이 되어서 리렌더링이 일어나고, Styled Component에 props를 제공하게 되어 새로 생성된 창의 위치가 변하게 된다. 따라서 창이 마우스를 따라다닐 것이다.

  3. 마우스 드래그를 끝내고 버튼을 떼게 되면 저장해 뒀던 state를 0으로 복구시킨다.

  4. mousemove 이벤트도 다시 해제해서 더 이상 마우스를 따라다니지 않도록 한다.