이전 포스트에서 중첩된 상태에서의 컴포넌트 드래그를 구현했었는데, 막상 사용하려고 보니 한 케이스를 생각하지 못했다. 바로 같은 축을 가지는 중첩 상태가 있는 것. 그러니까, 부모도 Y 드래그고 자식도 Y 드래그인 상태를 생각하지 못했다(...)

하지만 뭐 그렇다고 구조를 다시 짜야한다거나 그런 상황은 아니다. 타겟 컴포넌트를 찾는 부분에 코드를 추가하면 되는 거라! 흐흐


Expected UX

먼저 부모와 자식이 같은 축일 때 어떻게 처리할지를 생각해보았다. 먼저 자식이 스크롤할 수 있는 상황이라면 그 자식을 드래그해주는 게 맞고, 만약 자식의 위치가 0 or 끝이라면 그 위의 부모를 드래그 시켜주는 게 맞다고 생각했다. 만약 그 부모의 위치도 0 or 끝이라면 또 그 부모를 찾고. 이런 체이닝 형식으로 타고 가다가 해당 축의 마지막 부모를 만났고, 그 부모마저 0 or 끝이라면 그냥 그 부모를 드래그 시켜주는 게 맞다고 생각했다.

정리하자면,,

Target을 정하는 방법

  1. 자식이 드래그할 수 있는 상황이면 드래그
  2. 할 수 없는 상황이면 드래그를 할 수 있는 부모를 찾아 드래그
  3. 최상위 부모까지 올라갔다면 최상위 부모를 드래그

이어 발생하는 상황을 구체적으로 따져보았다.

Y축 Target 찾았고, to Down일 때

  • 해당 Target의 Y좌표가 0이라면 다음 Y축 Target 찾아서 드래그
  • 계속 Y축이 0이라면, 마지막에 찾은 Y축 Target을 Drag(얘가 제일 최상위 Target)

Y축 Target 찾았고, to Up일 때

  • 해당 Target의 Y좌표가 끝이라면 다음 Y축 Target 찾아서 드래그
  • 계속 Y축이 끝이라면, 마지막에 찾은 Y축 Target을 Drag(얘가 제일 최상위 Target)

X축 Target 찾았고, to Left일 때

  • 해당 Target의 X좌표가 끝이라면 다음 X축 Target 찾아서 드래그
  • 계속 X축이 끝이라면, 마지막에 찾은 X축 Target을 Drag(얘가 제일 최상위 Target)

X축 Target 찾았고, to Right일 때

  • 해당 Target의 X좌표가 0이라면 다음 X축 Target 찾아서 드래그
  • 계속 X축이 0이라면, 마지막에 찾은 X축 Target을 Drag(얘가 제일 최상위 Target)

각 축과 각 방향마다 검사하는 패턴이 같으니, 최적화를 위해 일반화를 시켜볼까 했는데 막상 해보려고 하니 선언적으로 코드를 짜는 게 어렵겠다 싶었다. 묶어서 로직화할 수는 있는데,, 알아보기가 힘들 것 같은 느낌. 일단 해봐!


구현

변수처리

본격적으로 구현하기 전에 앞서, 요 DraggableComponent에 필요한 변수들을 outer scope로 관리해보기로 했다.

Outer Scope에 선언된 Drag Variable

let dragControls: DragControls | null = null;
let thingsCouldBeTarget: ThingCouldBeTarget[] = [];

let dragVariable: DragVariable = {
  isDragging: false,
  isPointerDown: false,
  havntAxisTarget: false,
  scopeOut: false,
  clickedEvent: {} as PointerEvent<HTMLDivElement>,
};

const mutateDragVariable = (newValue: Partial<DragVariable>) => {
  dragVariable = { ...dragVariable, ...newValue };
};

const cleanUpDragVariable = () => {
  thingsCouldBeTarget = [];

  mutateDragVariable({
    isDragging: false,
    isPointerDown: false,
    havntAxisTarget: false,
    scopeOut: false,
    clickedEvent: {} as PointerEvent<HTMLDivElement>,
  });
};

const DraggableComponent = ({
  dragId,
  axis,
  dragConstraints,
  onDragEnd,
  children,
  ...rest
}: DraggableComponentProp) => {

DraggableComponent은 여러번 쓰이는 녀석! 만약 여기 내부에서 Ref 등으로 관리를 해주게 된다면 이 Ref라는 녀석이 컴포넌트 인스턴스마다 생기게 된다. 하지만 나는 드래그 상태를 하나에서 통합 관리하고 싶기에~(그래야 하기도 하고) 따로 전역으로 관리해주었다.

따로 Context API 라든가, Redux 같은 전역 상태 공유를 쓰지 않은 이유는, 불필요한 렌더를 일으키고 싶지 않았기 때문이다. 애초에 Framer Motion가 자체적으로 따로 DOM을 관리 해주는 와중에 이걸 써먹는 구조이기도 하고, UI에 관련된 상태가 아니기 때문이다. 다른 말로는 Render를 일으키지 않아도 되는 상태! 그래서 outer scope에 선언해주었다.

드래그 구현

먼저 시작하는 포인트는 onPointerMove이다. 이동하려는 축과 방향을 알아내고 Drag를 시작해야하기 때문. 그러므로 움직임이 시작된 이후에 축과 방향을 알아낸다.

축과 방향 알아내기 및 해당 타겟 목록 얻기

const { axis, direction } = getAxisAndDirection(currentX, currentY, oldX, oldY);
const axisTargets = getAxisTargets(axis);
const getAxisTargets = useCallback((axis: DragAxis) => {
  const axisTargets = [];

  for (let i = 0; i < thingsCouldBeTarget.length; i++) {
    const { target: currentTarget } = thingsCouldBeTarget[i];

    if (currentTarget.visualElement.props.drag === axis) {
      axisTargets.push(currentTarget);
    }
  }

  return axisTargets;
}, []); ```

getAxisAndDirection는 이름 그대로 축이랑 방향을 얻는 함수이고, getAxisTargets를 좀 더 살펴보자. onPointerDown에서 얻어낸 중첩 컴포넌트들을 돌면서 drag 방향을 체크하는 방식이다.

타겟 찾아서 드래그하기

for (let i = 0; i < axisTargets.length; i++) {
  const currentTarget = axisTargets[i];

  const minValue = currentTarget.constraints[axis]?.min || 0;
  const curValue = currentTarget.visualElement.values.get(axis)?.current || 0;

  const dragConditionStart = curValue !== 0 && curValue < 0;
  const dragConditionEnd = curValue !== minValue && curValue > minValue;

  const axisStartDirection = axis === DragAxis.Y ? DragDirection.Down : DragDirection.Right;
  const axisEndDirection = axis === DragAxis.Y ? DragDirection.Up : DragDirection.Left;

  const couldDragToAxisStart = direction === axisStartDirection && dragConditionStart;
  const couldDragToAxisEnd = direction === axisEndDirection && dragConditionEnd;

  const isLastTarget = i === axisTargets.length - 1;

  const couldDrag = couldDragToAxisStart || couldDragToAxisEnd || isLastTarget;

  if (couldDrag) {
    console.log(currentTarget.visualElement.props.id, 'Drag 시작');
    currentTarget.start(dragVariable.clickedEvent);
    return afterStart();
  }
}

그 다음 타겟들을 돌면서 드래그를 해도 되는지 판단하는 부분인데~ 여기가 본문의 앞에서 말했던 케이스를 구현한 부분이다. 어거지로 일반화시킨 느낌이... 있긴 한데(ㅋㅋ)

minValue는 해당 축의 최소값, 즉, Constraint 값이다. 예를 들면 Y축이라면 최대로 올라갈 수 있는 Y좌표이다. 음수의 값이다.

그다음,, Start에 관련된 것들은 해당 축에서 일반적으로 기대하는 방향에 대한 조건이다. X축이면 오른쪽이, Y축이면 아래쪽이 일반적으로 기대하는 방향이고, 이 방향 조건들은 시작 위치가 0이 아니면 만족한다. curValue가 0보다 커야 하는 이유는, 만약 이전의 drag의 reducedMotion이 필요하다면 Y좌표는 천천히 줄어들며 0이 될 텐데, 그 상태에서 다시 Drag를 시도할 수도 있기 때문이다. 이를 처리하기 위한 것.

End에 관련된 것들은 일반적으로 기대하는 방향의 반대이다. X축이면 왼쪽, Y축이면 왼쪽. 시작 위치가 최소값이 아니라면, 그러니까 Contraint 값이 아니라면 만족한다. 여기도 마찬가지로 reducedMotion을 처리하기 위해 curValue가 minValue를 초과해야 한다.

마지막 조건으로~ 해당 축의 마지막 타겟인지 판단한다. 이 세 가지 중 하나라도 만족하면 드래그를 할 수 있으므로 이 상태를 나타내는 게 couldDrag 이다. true라면 드래그를 시작한다.


예외처리

테스트를 하다보니까 여러 예외 케이스가 있더라.

해당 축으로 가능한 타겟이 없을 때

const axisTargets = getAxisTargets(axis);

if (axisTargets.length === 0) {
  mutateDragVariable({ havntAxisTarget: true });
  console.log('해당 방향으로 움직일 수 있는 Target 없음!');
  return;
}```

만약 axisTargets의 크기가 없다면 해당 축을 가진 타겟이 없다는 것이므로 flag 처리해준다. 이 flag는 onPointerMove시에 필요없는 이벤트가 발생하지 않기 위해서 사용된다.

해당 축으로 Target이 없을 때 Pointer Out 처리

  const handleDragEnd = useCallback(
    (event: globalThis.PointerEvent, panInfo: PanInfo) => {
      const { scopeOut } = dragVariable;

      if (scopeOut) {
        cleanUpDragVariable();
      }

      if (onDragEnd) {
        onDragEnd(event, panInfo);
      }
    },
    [onDragEnd]
  );

  const handlePointerUp = useCallback(() => cleanUpDragVariable(), []);

  const handlePointerEnter = useCallback(() => {
    const { scopeOut, havntAxisTarget } = dragVariable;

    if (scopeOut && havntAxisTarget) {
      cleanUpDragVariable();
    }

    mutateDragVariable({ scopeOut: false });
  }, []);

  const handlePointerLeave = useCallback(() => mutateDragVariable({ scopeOut: true }), []);

사용자가 드래그를 끝내면 onDragEnd로 알아낼 수 있다. 컴포넌트 밖에서 끝내도 이 이벤트는 잘 작동해서, 이 이벤트에 Drag 관련 변수들을 초기화해주면 된다. 하지만,, 해당 축에 대한 타겟이 없고, 사용자가 드래그를 컴포넌트 바깥에서 끝냈다면 이를 알아낼 방법이 없다. onDragEnd는 드래그를 시작해야만 발생하는 이벤트니까.

그래서 현재 포인터가 바깥으로 나갔는지 알려주는 scopeOut Flag를 만들었다. onPointerEnter와 onPointerLeave를 활용해서 컴포넌트에 들어오면 scopeOut: false 처리를, 컴포넌트에서 나가면 scopeOut: true 처리를 해주었다.

바깥에서 끝내고 컴포넌트 안으로 들어오게 되면, onPointerEnter에서 scopeOut과 havntAxisTarget은 True인 걸 감지하여 드래그를 컴포넌트 바깥에서 끝냈구나를 판단할 수 있다. 그 경우에는 Drag 관련 변수를 초기화시켜주었다.

Typescript 및 ESLint 처리

// @ts-expect-error: Framer Motion의 DragControl 안쪽 Private Variable을 직접 꺼냄
const { componentControls } = dragControls!;

// @ts-expect-error: Framer Motion의 DragControl 안쪽 Private Variable을 직접 꺼냄
componentControls.forEach((entry) => {
   
/* eslint-disable-next-line  @typescript-eslint/no-explicit-any
  --
  Framer Motion의 DragControl 안쪽 Private Variable을 직접 꺼낸 객체를 저장함 \*/
(axisTargets: any[], axis: DragAxis, direction: DragDirection, afterStart: () => void) => {```

그리고 이 dragControls에서 componentControls를 꺼내는 건.. 사실 안 된다. 요놈은 내부적으로 private 멤버이기 때문이다. 그러나 얼렁뚱땅 Javascript의 특성 덕분에 Object Chaing으로 꺼낼 수 있는 것(ㅋㅋ)

이를 TS에서 감지하기 때문에, 무시해주기 위해 @ts-expect-error를 사용하였다. 마찬가지로 그 금지된 걸 꺼낸 거라 componentControls에서 꺼내는 entry 역시 any로 추론된다. 그래서 요것도 처리해주고~
axisTargets 역시 그런 객체들을 저장하는 녀석이므로 무시해준다. 참고로 ESLint에서는 rule를 무시할 경우 왜 무시했는지에 대한 설명을 적어주는 것을 권장한다. 설명은 '-'를 두 개 이상 적고 작성하면 된다.


후기

프로젝트를 수정하면서 구현하기엔 복잡할 것 같아 따로 데모를 만들었던 건데, 이 데모가 생각보다 규모가 있다는 건 안 비밀(ㅋㅋ)

그래도 덕분에 만족스러운 UX를 만들었다. 아쉬운 건 그래도 여전히 DraggableComponent에 등록한 onPointer~ 이벤트들이 동시에 일어난다는 건데, 이건 어쩔 수 없는 거 아닌가 싶기도...!

Pointer 이벤트를 받는 최상위 핸들러를 만들고, 거기에서 클릭한 마우스 포인터 좌표를 기준으로 어떤 객체가 있는지 판단하는 메서드로(elementsFromPoint) 만들면 이벤트가 한 번만 일어나고, 또 많은 최적화가 일어날 수도 있을 것 같은데, 이건 나중에 한번 도전해봐야겠다!


예제

profile
FE개발자 가보자고🥳

0개의 댓글