[React] 마우스 드래그 앤 드랍으로 요소 옮기기 (feat. react-use-gesture)

Cho Jeongmin·2023년 11월 13일

졸업 프로젝트

목록 보기
8/8

텍스트 박스를 마우스로 드래그 앤 드랍해서 옮겨봅시다!

🍋 프로젝트 개요

현재 졸업 프로젝트에서 저희 팀은 아이들이 쓴 일기를 그림 동화책으로 만들어주는 웹 서비스를 개발하고 있습니다. 저는 프론트엔드를 맡아서 React JS로 웹사이트를 구현하였는데요.

부끄럽지만 아래는 스타트 시절 저희 웹사이트 디자인입니다...
참고로 동화 삽화와 이야기는 저희가 만든 게 아니라 일단 더미데이터를 넣어둔 것입니다.
동화책처럼 보이나요? 우리가 어릴 때 읽던 동화책과는 뭔가 다른 느낌이 들고 어색하죠.

그럼 실제 아이들이 읽는 일반적인 종이 동화책은 어떨까요? 다른 점이 보이시나요?

바로 삽화의 빈 부분에 이야기 텍스트를 배치하고 있는데요. 때문에 글과 그림이 잘 어우러지고 좀 더 다채로운 느낌을 줍니다. 반면에 저희 디자인을 보시면 동화 삽화와 이야기가 완전히 분리되어 있습니다. 삽화 밑에 이야기를 따로 두어서 둘이 서로 어우러지지 못하는 걸 볼 수 있습니다.

그래서 저희는 사용자가 드래그 앤 드랍을 통해 직접 원하는 위치에 텍스트를 놓을 수 있게 하기로 결정했습니다!

🍋 react-use-gesture 패키지

어떻게 하면 드래그 앤 드랍을 구현할까 열심히 서치해본 결과, react-use-gesture라는 패키지를 발견하였습니다.

npm install react-use-gesture

먼저 npm 패키지 관리자를 이용하여 react-use-gesture 패키지를 설치해줍니다.


import {useDrag} from 'react-use-gesture';

저희는 이 패키지의 useDrag라는 hook을 사용할 것이므로 상단에서 import 해줍니다.

const bind = useDrag((state) => doSomethingWith(state), config)

공식문서에 나와있는 useDrag 기본 사용법입니다.
여기서 첫번째 인자로는 콜백 함수를, 두번째 인자로는 config를 넘겨주고 있는데요.
콜백 함수의 인자인 state는 제스쳐에 대한 모든 속성들을 담은 객체입니다.
두번째 인자인 config로는 제스쳐에 대한 옵션을 설정할 수 있습니다.

<div {...bind()} />

그리고 이렇게 spread 연산자를 이용하여 bind 함수를 컴포넌트의 props로 넘겨줄 수 있는데요, 그러면 실제로는 onPointerDown, onPointerMove, onPointerUp 이벤트 핸들러가 컴포넌트에 등록된다고 합니다.

🍋 드래그 앤 드랍 구현

그럼 useDrag를 사용하여 드래그 앤 드랍을 구현해보도록 하겠습니다.

const [textPos, setTextPos] = useState({ x: 0, y: 0 });

우선 useState를 사용하여 textPos라는 상태를 선언했습니다. textPos는 텍스트 박스의 x, y 위치 좌표를 저장하게 될거에요. 초깃값으로는 좌표 (0, 0)을 주었습니다. 여기까지는 React 기본 내용이니까 어렵지 않을 거에요.

 const bindTextPos = useDrag((state) => {
   setTextPos({ x: state.offset[0], y: state.offset[1] });
 });

그리고 useDrag가 반환하는 함수를 bindTextPos에 저장하였습니다. 콜백 함수의 인자인 state는 제스쳐에 대한 모든 속성들을 담고 있다고 얘기했었죠? 그 속성들 중 offset이라는 배열이 있는데요, 초기 좌표로부터 떨어진 정도를 저장한 배열이라고 보시면 됩니다. offset[0]은 x좌표, offset[1]은 y좌표입니다. 이 offset 정보를 아까 만들어둔 textPos에 바인딩 시켜줍시다.

<TextArea
  {...bindTextPos()}
  $x={textPos.x}
  $y={textPos.y}
/>

그리고 TextArea 컴포넌트에 {...bindTextPos()}로 드래그와 관련된 이벤트 핸들러를 등록해줍니다. 그리고 textPos.xtextPos.y$x$y라는 styled-components props로 넘겨줍시다. 이름은 다르게 지어도 상관없는데, 일반 props와 구분하기 위해서 styled-components props앞에는 $를 붙이면 좋다고 해요.

const TextArea = styled.div.attrs(({ $x, $y }) => ({
  style: {
    top: $y < 0 ? "0px" : ($y <=250 ? $y + "px" : "250px"),
    left: $x < 0 ? "0px" : ($x <=250 ? $x + "px" : "250px"),
  },
}))`
  position: absolute;
  ...

TextArea 컴포넌트를 styled-components로 스타일링한 코드입니다.

기본적으로 부모 컴포넌트(동화 삽화)에는 position: relative;를 설정해두었고, TextArea에는 position: absolute;를 설정해두어서, TextArea가 삽화 위에 겹쳐지도록 한 상태입니다.

그리고 styled-components props로 넘겨받은 $x$y 좌표값을 top, left 속성으로 설정해줍니다.

텍스트가 배경 삽화 밖으로 넘어가지 않도록 약간의 분기처리를 했는데요. 삽화의 가로 세로 길이가 300px(픽셀)이라는 가정하에, 좌표값이 0보다 작으면 0px로, 0보다 크고 250보다 작으면 좌표값 그대로, 250보다 크면 250px로 지정했습니다.

그 결과입니다! 텍스트 박스가 잘 움직이죠?
❗ 근데 문제가 있었습니다...! (두둥)

보시다시피 빠르게 드래그할 경우, 순간적으로 텍스트가 파랗게 선택되는 현상이 발생했습니다. 드래그 이벤트와 텍스트 선택 이벤트가 중복으로 발생한 것입니다.
이는 텍스트 박스 자체를 잡고 드래그 했기 때문인데요.
이를 방지하기 위해 드래그 전용 손잡이를 두면 좋겠다는 생각을 했습니다.

🍋 손잡이를 만들자!

이렇게 왼쪽 위에 드래그 전용 손잡이를 만들었습니다.
드래그 이벤트는 저 손잡이에만 먹히게 하되, 텍스트와 손잡이가 다 함께 움직이도록 해야합니다. 손잡이 혼자만 움직이면 안되니까요.

<TextWrapper>
    <DragHandle/>
    <TextArea/>
</TextWrapper>

우선 TextWrapper 컴포넌트로 DragHandle(손잡이)과 TextArea(텍스트)를 감싸주었습니다.

<TextWrapper $x={textPos.x} $y={textPos.y}>
  <DragHandle {...bindTextPos()}/>
  <TextArea/>
</TextWrapper>

그리고 DragHandle에 {...bindTextPos()}를 해주어 손잡이에만 드래그 이벤트가 먹히도록 하였습니다.
x, y 좌표 바인딩은 TextWrapper에 해주어 텍스트와 손잡이 전체가 함께 움직이도록 하였습니다.
(참고로 세 컴포넌트 모두 position: absolute; 속성을 주었습니다.)

이제 텍스트 선택과 드래그가 따로따로 잘 되는 것을 볼 수 있습니다. (뿌듯)

❗ 그런데 또 다른 문제가 있었습니다...! (두둥)

배경 삽화의 크기를 300px로 고정시키지 않고 사용자의 화면 크기에 따라 반응적으로 변하도록 설계해두었는데요. 현재는 텍스트의 x, y 좌표를 고정 단위(px)로 주고 있기 때문에 화면 크기가 바뀜에 따라 텍스트 박스의 위치가 이상해보입니다.

예) 삽화의 크기가 400px에서 200px로 더 작아졌음에도 불구하고 텍스트의 좌표는 (100px, 150px)로 고정되어 있음.

반응형 웹을 포기하고 싶지도 않고, 그렇다고 저대로 두자니 좀 이상합니다.
그럼 어떻게 해야 될까요?

🍋 상대 단위(%)로 바꾸자!

삽화의 크기에 따라 좌표도 반응적으로 변하도록 상대 단위 %를 사용하면 됩니다.
즉, 좌표를 삽화 크기에 대한 비율로써 나타내는 것입니다.

예) 만약 삽화의 가로 길이가 400픽셀이고, x좌표는 100픽셀이라면, 상대 단위로는 25%가 됨. 더 작은 화면에서 봤을 때 삽화의 가로 길이가 200픽셀이라면, x좌표는 200픽셀의 25%인 50픽셀이 됨.

이런 식으로 퍼센트(%) 단위를 계산하려면 우선 삽화의 크기를 알아내야 하는데요, 저희 삽화는 정사각형이므로 가로 세로 길이가 같습니다. 그래서 가로 길이만 알아내도록 하겠습니다.

const container = useRef(); //삽화 컴포넌트를 참조

//삽화의 가로 길이를 알아내는 함수
const getWidth = () =>
    container.current && //current가 존재하면
    Number(
      window
        .getComputedStyle(container.current) //스타일 속성을 가져와서
        .getPropertyValue("width") //width를 가져온 뒤
        .replace("px", "") //px 단위 없앰
    );

...

{/* 삽화 컴포넌트 */}
<Container ref={container}>
  
...

삽화의 가로 길이를 알아내는 로직입니다.
우선 useRef hook을 사용하여 container 변수가 삽화를 참조하도록 합니다.
만약 container.current가 존재한다면, 스타일 속성 중 width를 가져와서 px이라는 단위를 없애고 숫자만 얻어냅니다.

const [percentX, setPercentX] = useState(0); //퍼센트 x좌표
const [percentY, setPercentY] = useState(0); //퍼센트 y좌표

...

useEffect(() => {
    const width = getWidth(); //삽화 가로길이 가져오기

    if (container.current && !isNaN(width)) { 
      const x = (textPos.x / width) * 100; //삽화에 대한 x좌표의 비율 구하기
      const y = (textPos.y / width) * 100; //삽화에 대한 y좌표의 비율 구하기
      setPercentX(x < 0 ? 0 : (x <= 90 ? x : 90); //x 좌표가 삽화 밖으로 벗어나지 않도록
      setPercentY(y < 0 ? 0 : (y <= 90 ? y : 90); //y 좌표가 삽화 밖으로 벗어나지 않도록
    }
  }, [textPos]);

퍼센트 좌표를 계산하는 로직입니다.
useEffect hook을 사용하여 textPos(픽셀 좌표)에 변화가 생길 때마다 실행되도록 합니다.
그리고 픽셀좌표를 삽화길이로 나누어서 퍼센트 좌표를 구합니다.
또한 텍스트가 삽화 밖으로 벗어나지 않도록 퍼센트 좌표를 0% ~ 90%로 제한하여 percentX와 percentY에 저장합니다.

<TextWrapper $x={percentX} $y={percentY}>
  <DragHandle {...bindTextPos()}/>
  <TextArea/>
</TextWrapper>
...

그리고 픽셀 좌표인 textPos.x와 textPos.y 대신, 퍼센트 좌표인 percentX와 percentY를 컴포넌트에 바인딩 시켜줬습니다.

const TextWrapper = styled.div.attrs(({ $x, $y }) => ({
  style: {
    top: $y + "%",
    left: $x + "%",
  },
}))`

...

스타일링 코드에서도 px대신 %를 사용하였습니다.

짜잔! 결과입니다.
보시다시피 삽화 크기가 변함에 따라 텍스트의 좌표도 일정한 비율로 변하고 있습니다.
결과적으로 삽화의 일정한 위치에 텍스트가 위치하게 되죠.

최종 결과입니다.
이 작업에 공을 꽤 많이 들여서 아주 뿌듯합니다.^^
(디자인도 점점 나아져서 다행.. 이제보니 초기 디자인 정말 별로였네여..ㅎ)

그럼 20000.

0개의 댓글