Long Press Menu (w. framer motion)

김 주현·2023년 12월 13일
0

UI Component 개발

목록 보기
7/11
post-thumbnail

이런 식의 UI Component를 무어라 부르는지 모르겠지만,, 찾아보니 Long Press (Context) Menu가 제일 비슷한 것 같고, 짧게는 Pressable Component라고 하는 것 같다! (React-native 에선 그러던데)

그러므로 구현해보는 Long Press Menu.


동작

기본 동작

  • 포인터를 꾸욱 누르면 0.3초 뒤에 Context Menu가 나타난다.
  • 포인터를 놓지 않은 채 이동하면 아이템 선택을 할 수 있다.
  • 포인터를 놓았을 때의 Y좌표에 해당하는 아이템이 선택된다.
  • 해당 동작은 메뉴 안에서뿐만 아니라 바깥에서도 동작한다 (Capturing)
    • 보통은 해당 메뉴를 벗어나는 건 Cancel 의도지만~ 내가 쓰려는 프로젝트 특성상 캡처되게 구현했다.

애니메이션

  • Context Meun가 나타나고 사라질 때, 버튼을 기준으로 scale이 바뀌고, opacity가 바뀐다.
    • Context Menu의 가운데가 아니라, 트리거 버튼이 anchor이다.
  • Context Menu의 위치는 트리거 버튼이 기준이다.
  • 눌렀을 때 아이템은 pressed feedback으로 (1) 배경이 바뀌고 (2) 콘텐츠가 작아진다.
  • 호버 선택이 되면 배경이 스르르 바뀐다.
  • 선택된 아이템의 호버는 배경은 바뀌지 않고 작아지기만 한다.

Usage

App.tsx

const contextMenus: ContextMenu[] = [
  {
    id: 0,
    icon: "🙉",
    text: "원숭이",
  },
  {
    id: 1,
    icon: "🦊",
    text: "여우",
  },
  {
    id: 2,
    icon: "🐹",
    text: "햄스터",
  },
  {
    id: 3,
    icon: "🐸",
    text: "개구리",
  },
  {
    id: 4,
    icon: "🐰",
    text: "토끼",
  },
];

const App = () => {
  const [selectedMenuId, setSelectedMenuId] = useState(0);

  const handleSelect = (selectedId: number) => {
    setSelectedMenuId(selectedId);
  };

  return (
    <Container>
      <LongPressMenu
        contextMenus={contextMenus}
        selectedMenuId={selectedMenuId}
        onSelect={handleSelect}
      />
    </Container>
  );
};

개인적으로 Component를 만들 때는 이게 어떻게 쓰일지를 먼저 고민하는 타입이다. 이 컴포넌트의 역할에 따라 어떤 게 넘어가야 하고, 어떤 걸 받아오고 싶은지를 구분해두면 조금 더 설계가 명확해진다.

나는 이 컴포넌트를 (1) 현재 아이템이 무엇인지 볼 수 있고 (2) 어떤 목록이 있는지 확인할 수 있고 (3) 어떤 게 선택됐는지 알려줄 수 있어야 한다고 생각했다.

그러면 여기에서 넘겨줄 것과 받아올 것이 구분된다.

넘겨줄 것

  • 메뉴 목록
  • 현재 선택된 ID

받아올 것

  • 선택 Event

물론 이것도~ 전역상태로 넘기면 필요없지만 이건 나중의 일이니깐~_~ 고려하지 않겠다!


구조

LongPressMenu

LongPressMenu.tsx

export const LongPressMenu = ({
  contextMenus,
  selectedMenuId,
  onSelect,
}: LongPressMenuProp) => {
  const [isPointerDown, setIsPointerDown] = useState(false);
  const [hoverIndex, setHoverIndex] = useState(0);

  const selectedMenu = contextMenus[selectedMenuId];

  const handlePointerUp = () => {
    setIsPointerDown(false);
    onSelect && onSelect(hoverIndex);
  };

  const handleHoverSelect = (hoverId: number) => {
    if (0 <= hoverId && hoverId < contextMenus.length) {
      setHoverIndex(hoverId);
    }
  };

  return (
    <div css={relativeStyle}>
      <C.TriggerButton
        contextMenu={selectedMenu}
        onPointerDown={() =/> setIsPointerDown(true)}
        onPointerUp={handlePointerUp}
        onHoverSelect={handleHoverSelect}
      />

      <C.ContextMenuBox
        contextMenus={contextMenus}
        isPointerDown={isPointerDown}
        selectedMenuId={selectedMenuId}
        hoverIndex={hoverIndex}
      />
    </div>
  );
};

TriggerButton과 ContextMenuBox

이 PressLongMenu는 크게 두 개의 구조로 나뉜다. 첫 번째로 메뉴 목록의 가시성(Visibility)을 제어할 TriggerButton과 메뉴 목록을 보여줄 ContextMenuBox이다.

이게 좀 재밌는 구조인게, 계속 Pressed인 상태에서 메뉴를 골라야하는 거다보니까, 포인터의 Capturing은 TriggerButton에서 담당하고, 그 상태들을 ContextMenuBox에서 받아와서 표현해주는 구조이다.

그래서 각종 상태들을 올려받아서 다시 ContextMenuBox로 내려주는 것. Context API를 쓸까 했는데 에잉 뭐 그렇게까지야

TriggerButton

먼저 트리거 버튼부터 살펴보자.

TriggerButton

export const TriggerButton = ({
  contextMenu,
  onPointerDown,
  onPointerUp,
  onHoverSelect,
}: TriggerButtonProp) => {
  const {
    buttonRef,
    isStartLongMenuAction,
    startLongMenu,
    moveLongMenu,
    cleanUpLongMenu,
  } = useLongPressMenu();

  const buttonAnimationState = isStartLongMenuAction ? "pushed" : "normal";

  const handlePointerDown = (event: PointerEvent<HTMLDivElement\>) => {
    startLongMenu(event);
    onPointerDown();
  };

  const handlePointerMove = (event: PointerEvent<HTMLDivElement\>) => {
    moveLongMenu(event, { onHover: onHoverSelect });
  };

  const handlePointerUp = () => {
    cleanUpLongMenu();
    onPointerUp();
  };

  return (
    <motion.div
      css={S.menuBorderStyle}
      ref={buttonRef}
      variants={buttonVariant}
      initial={false}
      animate={buttonAnimationState}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}

      <ButtonPresenter {...contextMenu} />
    </motion.div>
  );
};

useLongPressMenu

  const {
    buttonRef,
    isStartLongMenuAction,
    startLongMenu,
    moveLongMenu,
    cleanUpLongMenu,
  } = useLongPressMenu();

현재 Custom Hook에서 내뱉는 것들로 구동되고 있는 걸 볼 수 있다. 생각보다 코드가 넘 길어지길래 실제 로직은 훅으로 분리시켜주었다. 일단 이것부터 살펴보자고!

useLongPressMenu - startLongMenu

코드가 꽤 길어서, 동작 흐름에 따라 차근차근 하나씩 살펴보자

useLongPressMenu - 포인터 정보를 저장하는 startLongMenu

  const startLongMenu = (event: PointerEvent<HTMLDivElement\>) => {
    buttonRef.current?.setPointerCapture(event.pointerId);

    const { top: buttonTop } = buttonBounds.current;

    oldY.current = event.clientY;
    interpolationY.current = event.clientY - buttonTop;

    setIsPointerDown(true);
  };

onPointerDown 이벤트일 때 작동하는 메서드이다. 요놈은 포인터가 눌릴 시점의 정보를 저장하는 역할이다.

buttonRef

const buttonRef = useRef<HTMLDivElement\>(null);

먼저,, buttonRef라는 RefObject를 만들어주고 있다. 요놈의 정체는 실제 Trigger Button이 될 Container이다. 이름은 buttonRef지만 DivElement인 건 넘어가자...

이 Ref Object를 바깥에서 넘겨받을까 했었는데, 오히려 안쪽에서 생성해서 넘겨주는 게 더 로직이 자연스럽길래 안쪽에서 생성해주었다. 이 Ref 객체에 대한 설명은 아래에서 좀 더 이어서!

setPointerCapture

상위 컴포넌트에서 ref를 장착시키고 나면 요녀석은 DivElement의 정보가 들어온다. 요 객체에는 setPointerCapture라는 게 있는데, 원래 Pointer Event들은 자기 객체를 벗어나면 이벤트를 받아오지 못한다. 이를 가능하게 한다.

예를 들어 onPointerDown과 onPointerUp 이벤트를 등록해놨다고 해보자. Down은 객체 안에서 일어날 수 밖에 없지만, 사용자가 Down 후 끌고 와서 객체 바깥에서 Up을 했다고 하자. 그러면 onPointerUp 이벤트는 발생하지 않는다.

하지만~ setPointerCapture를 쓰게 되면 본인의 바운더리를 넘어가도 Pointer Event를 받아온다. 이걸 더 빨리 알았다면...!

Release에 대한 동작이 따로 없는 건, 이 메서드는 onPointerUp이 일어나면 바로 릴리즈되기 때문이다. 자세한 건 공식 홈페이지 참고!

현재 포인터 값 저장하기

    const { top: buttonTop } = buttonBounds.current;

    oldY.current = event.clientY;
    interpolationY.current = event.clientY - buttonTop;

oldY와 interpolationY에 값을 저장하고 있다. oldY에는 현재 눌린 포인터의 Y좌표를 저장하고 있고, interpolationY에는 현재 포인터의 Y좌표에 TriggerButton의 Top를 뺀 값을 저장하고 있다.

oldY는 알겠다만,, 보간값(interpolation)을 왜 저장하는 걸까? 그 이유는 ... 후술!(ㅋㅋ)

buttonBounds

그리고 생각보다 골때렸던 게 요 button의 top과 height를 구하는 거였다. 원래는 그냥 event.target의 getBounding~ 으로 구했었는데, 생각해보니 애니메이션으로 인해서 Button의 Top과 Height가 바뀌더라요(!)

그보다 근본적인 건,, 왜 button의 top과 height를 구하나요? 그 이유는.. 역시 후술(머하는 놈이야 이거)

useLongPressMenu - moveLongMenu

현재 Y값에 따른 Item Index를 구하는 moveLongMenu

  const moveLongMenu = (
    event: PointerEvent<HTMLDivElement\>,
    callbacks?: {
      onFail?: () => void;
      onHover?: (hoverId: number) => void;
    },
  ) => {
    if (oldY.current === null || interpolationY.current === null) {
      return callbacks?.onFail && callbacks.onFail();
    }

    const offset = event.clientY - oldY.current;

    if (lastOffset.current === offset) {
      return callbacks?.onFail && callbacks.onFail();
    }

    const { height: buttonHeight } = buttonBounds.current;
    const currentY = offset + interpolationY.current;
    const hoverId = Math.floor(currentY / buttonHeight);

    callbacks?.onHover && callbacks.onHover(hoverId);

    lastOffset.current = offset;
  };

Y값에 따라 Item Index 구하기

바로 여기를 위해서 계속 후술로 미뤘던 것. Item Index를 구하기 위해선 button의 height와 top과 보간값이 필요했다.

좀 개열받지만 암튼 이렇다.

기본적으로 전체 높이에서 한 아이템의 크기로 나누면 이것이 index가 된다. 다만 문제는~ 이 경우는 포인터가 아이템의 최상단을 클릭했을 때만 적용된다.

포인터는 중간에서부터 눌릴 수 있으니, 처음 누른 포인터 Y를 기준으로 나누게 되면 버튼의 최상단에서 포인터 위치까지의 오차가 생기게 된다. 이를 맞춰주기 위해서 보간해준 것.

보간을 하기 위해서 top이 필요한 이유는,, 저 pointer의 clientY가 viewport 기준이기 때문이다. 버튼이 기준이 아니라 뷰포트가 기준이라, 현재 버튼이 위치한 top을 구해줘야 한다. 이 top역시 viewport 기준이라, clientY 에서 top을 빼주면 이게 바로 보간값이 되겠다.

button Top, Height구하기

그런데 문제가~ 현재 애니메이션이 먹혀있다. 이게 왜 문제가 되냐면,, 애니메이션이 되면서 top과 height가 변하기 때문이다. 그래서 최초로 렌더링 됐을 때의 위치와 크기가 필요했다.

useLongPressMenu의 선언부

  const buttonRef = useRef<HTMLDivElement\>(null);
  const buttonBounds = useRef<DOMRect\>({ top: 0, height: 0 } as DOMRect);

  const oldY = useRef<number | null>(null);
  const lastOffset = useRef<number | null>(null);
  const interpolationY = useRef<number | null>(null);

  const [isPointerDown, setIsPointerDown] = useState(false);

  useEffect(() => {
    const getButtonBounds = () => {
      buttonBounds.current =
        buttonRef.current?.getBoundingClientRect() ||
        ({
          top: 0,
          height: 0,
        } as DOMRect);
    };

    const handleResize = () => getButtonBounds();

    getButtonBounds();

    window.addEventListener("resize", handleResize);

    return () => window.removeEventListener("reisze", handleResize);
  }, []);

그걸 위해서~ 이 선언부가 필요했다. useRef로 선언한 것에 주목하자. 왜 useRef로 선언했냐면~ 불필요한 렌더링을 줄이고 싶었기 때문. 어차피 UI State가 아니어서.

그리고, 조금 잘못 생각해서 헤맸던 게 처음에 쓰기 좋을려고 다음과 같이 선언했었다.

const buttonBounds = useRef<DOMRect\>({ top: 0, height: 0 } as DOMRect);
const {top: buttonTop, height: buttonHeight} = buttonBounds.current

이 코드가 날 삽질로 이끌게 만들게 될 줄 누가 알았음....

ㅋㅋ이 코드가 왜 문제가 되냐면, buttonBounds는 Ref 객체라서요. Ref 객체는 값이 업데이트 되도 리렌더링을 진행하지 않아서 buttonTop과 buttonHeight의 값은 바뀌지 않아서요!!!!!!!!!!! 진짜 웃긴 게 똑같은 특성으로 위에는 잘만 써먹었으면서

피곤한 상태에서 코드를 작성해서 그런지 당연히 값을 업데이트 해줬으니 바뀌겠지~ 했던 나 반성해...

useLongPressMenu - cleanLongMenu

정리해주는 cleanLongMenu

  const cleanUpLongMenu = () => {
    setIsPointerDown(false);

    oldY.current = null;
    lastOffset.current = null;
    interpolationY.current = null;
  };

그리고 이제 필요 없으니 정리해준다. 아참, setIsPointerDown는 state로 선언해준 이유는 animate의 상태가 변해야하기 때문이다.


이제 나머지 코드는 애니메이션에 관련된 거라,,~~ 패스!


후기

포인터 이벤트 캡처링에 대해서 아주 확실하게 배웠다.

기본 동작 뼈대는 그렇게 어렵진 않았는데, 자잘한 디자인과 애니메이션을 신경쓰다 보니(...) 생각보다 품이 커졌다. 그렇지만 예쁜 건 포기할 수 없다구요!

그리고,, 은근 상태에 따른 디자인하고 애니메이션을 고려하면 구조가 복잡해지는 경우가 많아서, 나중에 가서 추가하려고 하면 생각보다 뜯어고칠 게 많아서 애초부터 고려하는 게 좀 더 효율이 있는 것 같다.


profile
FE개발자 가보자고🥳

0개의 댓글