Nested Draggable Components(using framer motion)

김 주현·2023년 11월 7일
1

UI Component 개발

목록 보기
11/11

겹쳐져 있는 상태의 드래그 컴포넌트를 다뤄야 할 상황이 생겼다. 좀 더 구체적으로는 다음과 같은 상황이다.

콘텐츠 영역 안에 가로로 드래그가 가능한 컴포넌트가 존재하고, 그 위로는 세로로 드래그가 가능한 부모가 존재하는 상황이다. 예를 들면 인스타그램과 같은 느낌! 피드는 위아래로 움직이고 사진은 좌우로 움직이는 것.

보기엔 쉬워보이는데, 막상 Framer Motion으로 구현해보면 다음과 같은 현상이 발생한다.


얼씨구ㅋㅋ

사용자의 의도는 Vertial Drag Component 1를 X축 방향으로 움직이고 싶은 건데, 웬걸 Y축으로도 움직여버리니 부자연스러움을 느끼게 된다. 이를 방지하기 위해 해당 컴포넌트에 고정된 방향으로만 움직이게 만들어주는 것이 목표.

만약 이게 scrollable한 객체라면 개고생할 필요없이 스크롤 체이닝(Scroll Chainning)을 적용하면 될 텐데, 드래그를 적용한 컴포넌트라 다른 방법이 필요했다.

구조

예시로 전체적인 구조는 다음과 같이 만들었다.

function App() {
  return (
    <FullPage>
      <Container>
        <h1 style={{ textAlign: "center" }}>Title</h1>
        <ContentSection>
          <DraggableContent
            drag="y"
            dragConstraints={{ top: 0, bottom: 0 }}

            <VerticalScrollContent
              drag="x"
              dragConstraints={{ left: 0, right: 0 }}

              Vertical Drag Component 1
            </VerticalScrollContent>
            <VerticalScrollContent
              drag="x"
              dragConstraints={{ left: 0, right: 0 }}

              Vertical Drag Component 2
            </VerticalScrollContent>
          </DraggableContent>
        </ContentSection>
        <h1 style={{ textAlign: "center" }}>Foot</h1>
      </Container>
    </FullPage>
  );
}```

DraggableContent가 세로로 움직이는 부모이고, VerticalScrollContent가 가로로 움직이는 자식이다.

시도1 - Preventing Event Bubbling

처음에 시도한 방법은 당연히 버블링을 막는 것이었다. 자식에서 발생한 이벤트가 부모로 버블링되지 않도록 stopPropagation()을 해주었다.

<DraggableContent
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  onDragStart={() => console.log("Parent: 응 아니야 받았어~")}

    <VerticalScrollContent
      drag="x"
      onDragStart={(event) => {
        console.log("Child: 전파를 막았어요!");
        event.stopImmediatePropagation();
      }}
      dragConstraints={{ left: 0, right: 0 }}

결과는 ?

전파가 너무 잘 돼서 깜짝ㅋㅋ 5G인줄

그 이유는~ Framer Motion은 React와는 다른 생명주기를 가지고 있기 때문이다. 다른 말로는, React에서 발생하는 Event를 받지 않는다는 것이다. 따로 DOM을 관리하기 때문에, React Event에서 전파를 막아봤자 이벤트는 발생한다.

그러면 아예 전파를 막을 방법은 없는 것인가...! 하는 도중에 framer motion에서 제공하는 on~Capture 이벤트가 생각나서 찾아보았다.

정말 열받는 게 저기에 나온 Capture 이벤트가 Framer Motion 공홈 전체에서 유일하다. 이자식이!

설명을 읽어보면, 부모에게 전파되는 것을 방지하려면 자식에서 Capture이벤트를 받아서 전파를 금지하라는 것이었다.

<DraggableContent
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  onDragStart={() => console.log("Parent: 응 아니야 받았어~")}

    <VerticalScrollContent
      drag="x"
      onPointerDownCapture={(event) => {
        console.log("Child: 전파를 막았어요!");
        event.stopPropagation();
      }}
      dragConstraints={{ left: 0, right: 0 }}

전파가 막혔다! 본인의 드래그도 막은 채. 너어....

그래도 dragControls를 달고 매뉴얼로 동작하게 하면 될 줄 알았는데, 그 역시 안 되더라. 그냥 예상이지만 전파를 막고 나면 Drag의 이벤트를 따로 호출시켜주진 않는 것 같다. 우우...

시도2 - useDragControls

그렇다면,, framer motion에서 자동으로 드래그 이벤트를 처리해준다는 기대를 버리기로 했다. 제공하는 useDragControls를 이용해서 직접 드래그를 동작시키기로 했다.

이를 위해 여러 테스트를 걸친 결과, 다음과 같은 로직으로 구현이 가능할 것 같다는 생각이 들었다.

  1. Draggable Component들의 onPointerDown, onPointerMove, onPointerUp에 각각 동일한 handler를 달아준다.
  2. onPointerDown에서 눌린 Component를 판단하고, onPointerMove시에 X축으로 움직이는지, Y축으로 움직이는지 확인 후에 드래깅

겹친 컴포넌트 알아내기

먼저 포인터가 눌렸을 때 어떤 컴포넌트들이 겹쳐있는지 알아내야 한다. 어떻게 구현해야 할까 싶었는데~ 오히려 전파가 되는 특성을 이용해보기로 했다. 어차피 동일한 핸들러니까!

const couldDraggableElements = useRef<string[]>([])

const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
  couldDraggableElements.current.push(event.currentTarget.id)
  console.log(couldDraggableElements.current)
}

const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
  couldDraggableElements.current = []

() => <DraggableContent
  id="draggableContent"
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  onPointerDown={handlePointerDown}
  onPointerUp={handlePointerUp}
\>
    <VerticalScrollContent
	  id="verical1"
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      onPointerDown={handlePointerDown}
	  onPointerUp={handlePointerUp}

이렇게 하면 버블링...은 아니지만 어쨌든 겹쳐있는 컴포넌트이므로 PointerDown이벤트가 두 번 일어날 것이다. 특징지어주기 위해 id값을 넣어주었다.

그러면 자식 컴포넌트부터 차례로 이벤트가 일어나는 것을 확인할 수 있다(다행이다!) 두 번씩 나오는 건 React의 StrictMode이기 때문.

dragControls 달기

이제 컴포넌트마다 dragControls를 달아줘야 하는데~ 여기에서 잠깐 짚고 넘어가야 할 게 있다. 그건 바로... 공홈 어디에서도 찾아볼 수 없는 활용법이다. 때는 바야흐로 framer motion이 넘 신기해서 이것저것 만져보던 때인데, 이번에 이걸 이렇게 요긴하게 쓸 줄은 ....

사실 요 useDragControls에서 반환되는 녀석은 여러 Class를 상속받은 녀석이다.

그러나 IDE에서 찍어보면 하나의 메서드만 우리를 반겨준다.

그건 나도 알아 이자식아

이에 궁금증을 느낀 나는,,, 이런 저런 연구를 해보다가 재밌는 걸 발견하게 된다.

만약 같은 dragControls을 여러번 달면 어떻게 될까?

와우.

그렇다. Framer Motion에서는 요 dragControls는 애당초 여러개를 관리할 수 있는 녀석으로 구현했던 것이었다. 이런 게 있으면 좀 알려달라고!

고맙게도 여기 들어있는 객체들은 dragControls를 등록한 녀석에 대해서 아주 자세한 정보들을 담고 있었다.

여기에서 내가 활용할 부분은~ visualElement의 props라는 속성이다.

보면 해당 컴포넌트로 넘겨준 모든 prop이 들어가있는 걸 확인할 수 있다. 그렇담 내가 누른 컴포넌트가 어떤 컴포넌트인지 확인할 수 있는, React DOM과 Framer Motion에서 관리하는 DOM을 연결시킬 수 있을 것이다.

또~ 재밌는 건 dragControls는 start뿐만 아니라 다른 메서드도 많이 가지고 있었다.

언젠가 적절하게 활용할 수 있을 것 같지만~ 지금 상황에서는 그다지?.?

여튼, 이 dragControls를 사용해서 이제 이리저리 구현해보면 될 것 같다.

알고리즘

구현 과정을 일일이 설명하기엔 귀찮아서 글이 너무 길어질 것 같아서 최종적으로 구현한 코드만 분석해보겠다.

onPointerDown

const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
  dragControls.componentControls.forEach((entry) => {
    if (entry.visualElement.props.id === event.currentTarget.id) {
      couldDraggableElements.current.push(entry);
    }
  });

  isPointerDown.current = true;
  clickedPos.current = {
    x: Math.trunc(event.clientX),
    y: Math.trunc(event.clientY),
  }; // PointerDown에서는 소수점이, PointerMove에서는 정수가 반환된다(...)
};

onPointerDown이 호출되면 Framer Motion에서 관리하는 DOM Element를 받기위해 현재 구독중인 객체들을 순회하면서 현재 호출된 객체를 찾아 Ref 변수에 넣어준다.

Ref 객체에 넣어준 이유는, 이 과정 자체가 React의 사이클에서 벗어나는 행위기도 하고, 상태로 관리할 필요가 없기 때문.

그 다음 onPointerMove 이벤트를 위해 눌렸는지 판단해주는 isPointerDown Ref도 만들어 주었다. 이게 없으면 터치는 상관 없겠지만 마우스가 위를 움직일 때마다 수많은 악수 요청이...

그리고 후에 움직였는지 판단하기 위해 클릭한 위치를 clickedPos에 저장해놓는다. 최초로 클릭을 하면 Down다음에 움직이지 않아도 바로 Move가 호출되기 때문이기도 하고, 어디 방향으로 움직였는지 확인도 해야 하기 때문이다.

onPoinerMove - 움직임 판단

const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
  if (!isPointerDown.current || isDragging.current) return;

  const { clientX: currentX, clientY: currentY } = event;
  const { x: oldX, y: oldY } = clickedPos.current;
  const isNoMove = currentX == oldX && currentY === oldY;

  if (isNoMove) return;

  // ...
}

그 다음 onPointerMove인데, 코드가 조금 길어서 끊어서 확인해보겠다.

Pointer가 눌리지 않았거나, 이미 드래그가 진행중이라면 이벤트를 빠져나가는 분기처리를 해주었다. 그 다음 최초 Down인지, 즉, 움직임이 없는지를 확인하기 위해 현재 위치과 클릭한 위치를 비교해서 움직임이 없을 때를 예외처리 해준다.

onPointerMove - 방향 판단

  const amountX = Math.abs(currentX - oldX);
  const amountY = Math.abs(currentY - oldY);

  const isAxisX = amountX >= amountY;
  const isAxisY = amountX < amountY;

  // X축 드래그
  if (isAxisX) {
    // ...
  }

  // Y축 드래그
  if (isAxisY) {
    // ...
  }

그 다음 방향 판단인데, 여기에서 케이스가 많이 나뉘는 것 같아서 하나씩 접근해보겠다.

케이스1: X > 0, Y = 0

  • X축으로 움직인 거

케이스2: X = 0, Y > 0

  • Y축으로 움직인 거

케이스3: X > 0, Y > 0, X > Y

  • 둘 다 움직이긴 했는데(대각선 방향), X축으로 더 크게 움직였다고 판단

케이스4: X > 0, Y > 0, X < Y

  • 둘 다 움직이긴 했는데(대각선 방향), Y축으로 더 크게 움직였다고 판단

케이스5: X > 0, Y > 0, X = Y

  • 똑같이 대각선 방향으로 움직인 거면,, 일단 의도 자체가 모호해서 따로 행동을 분기하지 말고 그냥 3, 4번째에 합치는 게 나을 듯

여기까지 케이스를 나누고 생각해보니,, X/Y축으로 움직였는지 확인할 필욘 없을 것 같다. X/Y의 양이 정해지는 순간 방향성은 정해진 거니까, X/Y의 양만 비교하면 될 것 같다고 판단이 들었다.

그러므로, 여기에 대한 코드가 이것인 것.

  const isAxisX = amountX >= amountY;
  const isAxisY = amountX < amountY;

X에 등호가 있고 Y에 없는 이유는, 그냥 내가 그렇게 정했다! 반박시 님 말 맞음

onPointerMove - 드래그 동작

// X축 드래그
if (isAxisX) {
  while (couldDraggableElements.current) {
    const currentElement = couldDraggableElements.current.shift();
    const haveAxisX = currentElement.visualElement.props.drag === "x";

    if (haveAxisX) {
      currentElement.start(event);
      isDragging.current = true;
      return;
    }
  }
}

이제 실제로 드래그를 동작시켜줘야 하는데~ 아까 언급했듯, couldDraggableElements에는 가장 안쪽의 자식부터 채워진다. 그래서 첫 번째부터 계속 꺼내면서 X 방향을 가졌는지 확인하고, 가졌다면 드래그를 시작한다. 아니라면 계속 올라간다.

아마 shift()연산을 썼기 때문에 연산 속도가 좀 느릴 거라 생각한다. 이 부분은 나중에 최적화를 하든지 해야겠지만, 어쨌든 원리는 이렇다.

Y축도 마찬가지로 해주면 된다. 다만 코드가 x랑 y차이일 뿐이라서 따로 함수화해줄 필요성이 있다!

onPointerUp

const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
  couldDraggableElements.current = [];
  isPointerDown.current = false;
  isDragging.current = false;
};

마지막으로 포인터를 떼면 초기화시켜주면 된다. 와! 끝났다!


후기

생각보다 좀 딥하게 파고 구현해서 나름 뿌듯하긴 한데... 이제 다시 프로젝트에 적용할 생각하니 조금 많이 힘들어질지도~ 😇


profile
FE개발자 가보자고🥳

0개의 댓글