이런 식의 UI Component를 무어라 부르는지 모르겠지만,, 찾아보니 Long Press (Context) Menu가 제일 비슷한 것 같고, 짧게는 Pressable Component라고 하는 것 같다! (React-native 에선 그러던데)
그러므로 구현해보는 Long Press Menu.
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) 어떤 게 선택됐는지 알려줄 수 있어야 한다고 생각했다.
그러면 여기에서 넘겨줄 것과 받아올 것이 구분된다.
물론 이것도~ 전역상태로 넘기면 필요없지만 이건 나중의 일이니깐~_~ 고려하지 않겠다!
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> ); };
이 PressLongMenu는 크게 두 개의 구조로 나뉜다. 첫 번째로 메뉴 목록의 가시성(Visibility)을 제어할 TriggerButton과 메뉴 목록을 보여줄 ContextMenuBox이다.
이게 좀 재밌는 구조인게, 계속 Pressed인 상태에서 메뉴를 골라야하는 거다보니까, 포인터의 Capturing은 TriggerButton에서 담당하고, 그 상태들을 ContextMenuBox에서 받아와서 표현해주는 구조이다.
그래서 각종 상태들을 올려받아서 다시 ContextMenuBox로 내려주는 것. Context API를 쓸까 했는데 에잉 뭐 그렇게까지야
먼저 트리거 버튼부터 살펴보자.
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> ); };
const { buttonRef, isStartLongMenuAction, startLongMenu, moveLongMenu, cleanUpLongMenu, } = useLongPressMenu();
현재 Custom Hook에서 내뱉는 것들로 구동되고 있는 걸 볼 수 있다. 생각보다 코드가 넘 길어지길래 실제 로직은 훅으로 분리시켜주었다. 일단 이것부터 살펴보자고!
코드가 꽤 길어서, 동작 흐름에 따라 차근차근 하나씩 살펴보자
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 이벤트일 때 작동하는 메서드이다. 요놈은 포인터가 눌릴 시점의 정보를 저장하는 역할이다.
const buttonRef = useRef<HTMLDivElement\>(null);
먼저,, buttonRef라는 RefObject를 만들어주고 있다. 요놈의 정체는 실제 Trigger Button이 될 Container이다. 이름은 buttonRef지만 DivElement인 건 넘어가자...
이 Ref Object를 바깥에서 넘겨받을까 했었는데, 오히려 안쪽에서 생성해서 넘겨주는 게 더 로직이 자연스럽길래 안쪽에서 생성해주었다. 이 Ref 객체에 대한 설명은 아래에서 좀 더 이어서!
상위 컴포넌트에서 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)을 왜 저장하는 걸까? 그 이유는 ... 후술!(ㅋㅋ)
그리고 생각보다 골때렸던 게 요 button의 top과 height를 구하는 거였다. 원래는 그냥 event.target의 getBounding~ 으로 구했었는데, 생각해보니 애니메이션으로 인해서 Button의 Top과 Height가 바뀌더라요(!)
그보다 근본적인 건,, 왜 button의 top과 height를 구하나요? 그 이유는.. 역시 후술(머하는 놈이야 이거)
현재 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; };
바로 여기를 위해서 계속 후술로 미뤘던 것. Item Index를 구하기 위해선 button의 height와 top과 보간값이 필요했다.
좀 개열받지만 암튼 이렇다.
기본적으로 전체 높이에서 한 아이템의 크기로 나누면 이것이 index가 된다. 다만 문제는~ 이 경우는 포인터가 아이템의 최상단을 클릭했을 때만 적용된다.
포인터는 중간에서부터 눌릴 수 있으니, 처음 누른 포인터 Y를 기준으로 나누게 되면 버튼의 최상단에서 포인터 위치까지의 오차가 생기게 된다. 이를 맞춰주기 위해서 보간해준 것.
보간을 하기 위해서 top이 필요한 이유는,, 저 pointer의 clientY가 viewport 기준이기 때문이다. 버튼이 기준이 아니라 뷰포트가 기준이라, 현재 버튼이 위치한 top을 구해줘야 한다. 이 top역시 viewport 기준이라, clientY 에서 top을 빼주면 이게 바로 보간값이 되겠다.
그런데 문제가~ 현재 애니메이션이 먹혀있다. 이게 왜 문제가 되냐면,, 애니메이션이 되면서 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의 값은 바뀌지 않아서요!!!!!!!!!!! 진짜 웃긴 게 똑같은 특성으로 위에는 잘만 써먹었으면서
피곤한 상태에서 코드를 작성해서 그런지 당연히 값을 업데이트 해줬으니 바뀌겠지~ 했던 나 반성해...
정리해주는 cleanLongMenu
const cleanUpLongMenu = () => { setIsPointerDown(false); oldY.current = null; lastOffset.current = null; interpolationY.current = null; };
그리고 이제 필요 없으니 정리해준다. 아참, setIsPointerDown는 state로 선언해준 이유는 animate의 상태가 변해야하기 때문이다.
이제 나머지 코드는 애니메이션에 관련된 거라,,~~ 패스!
포인터 이벤트 캡처링에 대해서 아주 확실하게 배웠다.
기본 동작 뼈대는 그렇게 어렵진 않았는데, 자잘한 디자인과 애니메이션을 신경쓰다 보니(...) 생각보다 품이 커졌다. 그렇지만 예쁜 건 포기할 수 없다구요!
그리고,, 은근 상태에 따른 디자인하고 애니메이션을 고려하면 구조가 복잡해지는 경우가 많아서, 나중에 가서 추가하려고 하면 생각보다 뜯어고칠 게 많아서 애초부터 고려하는 게 좀 더 효율이 있는 것 같다.