본문 바로가기

프론트엔드

리액트에서 무한 영역 UI 만들기(feat. requestAnimationFrame, useRef)

  • 프로젝트: 채식 지도(가명)
  • 키워드: infinite UI, requestAnimationFrame, mouse event, touch event, useRef
  • 상황
    1. 피드백 페이지는 포스트잇이 무한히 나열된 UI로 구현하고자 한다.
      기대하는 화면
    2. 마우스나 모바일기기의 터치를 이용하여 원하는 방향으로 움직일 수 있어야한다. 또한, 손가락을 움직이다가 뗐을 때 속도가 점점 감속되면서 멈춰야한다.
  • 해결 과정
    1.  무한한 것처럼 보이기 위해선 어떤 장치가 필요할까? 를 먼저 고민해보았다. 내가 생각한 답은 자연스럽게 특정 영역을 계속 순환하는 것이었다.
      복사 영역 복사 영역 복사 영역
      복사 영역 메인 영역 복사 영역
      복사 영역 복사 영역 복사 영역
      위의 표와 같이 실제로 유의미한 '메인 영역'이 중앙에 위치하고 그 주변은 복사 영역으로 감싼다. 사용자가 메인 영역 밖으로 벗어나게 되면 화면의 위치를 메인 영역 안으로 자연스럽게 옮겨 무한한 것처럼 보이게 만든다. (예를 들어 메인 영역의 왼쪽 경계선 밖으로 나갔다면, 메인 영역 왼쪽에 있는 복사 영역으로 이동하는 것이 아니라 메인 영역의 오른쪽 경계선으로 순간 이동시키면 된다.)

    2. 먼저 위에서 구상한 방법대로 9개의 영역을 렌더링한다. 이때 해당 페이지의 최초 렌더링 속도를 최적화하기위해서 처음에는 메인 영역만으로 화면을 채웠다가, useEffect로 복사 영역을 나중에 추가하는(isShowingClones) 방식을 고안했다.
      (React Profiler로 측정한 결과, 이 방식을 적용했을 때가 적용하지 않았을 때보다 렌더링 속도가 5배 빨랐다.)
        // NOTE: FCP 최적화를 위해 보이지 않는 영역은 최초 렌더링 이후 show 한다.
      const [isShowingClones, setIsShowingClones] = useState(false);
      useEffect(() => {
        setIsShowingClones(true);
      }, []);
      
      ...
      return (
        ...
        <FeedbackBoardContainer isShowingClones={isShowingClones}>
          <>
            <FakeFeedbackBoards isShowingClones={isShowingClones} ... /> // 복사 영역 4개
            <FeedbackBoard ... /> // 메인 영역
            <FakeFeedbackBoards isShowingClones={isShowingClones} ... /> // 복사 영역 4개
          </>
        </FeedbackBoardContainer>
        ...
      );
      
      ...
      
      // 무한한 Board 공간을 구현하기 위한 Fake Component
      const FakeFeedbackBoards = ({ ..., isShowingClones }: FakeFeedbackBoardProps) =>
        isShowingClones ? (
          <>
            <FeedbackBoard ... />
            <FeedbackBoard ... />
            <FeedbackBoard ... />
            <FeedbackBoard ... />
          </>
        ) : null;
      이제 9개의 영역을 만드는 것은 끝냈으니 이 영역을 감싸고 있는 'FeedbackBoardContainer' 컴포넌트에서 마우스/터치 이벤트에 따른 동작을 구현하면 된다.

    3. 먼저 FeedbackBoardContainer div tag의 스타일을 정의한다.
      div 영역의 중앙과 디스플레이 화면의 중앙이 일치해야하고, width와 height는 메인 영역의 3배 크기이다(3*3 영역이므로). 이때 2번에서 설명한 isShowingClones의 값에 따라 최초 렌더링때는 (메인 영역의 크기 * 1), 복사 영역이 모두 렌더링 되었을 때는 (메인 영역의 크기 * 3)으로 크기를 설정한다.
        const FeedbackBoardContainer = styled.div<{ isShowingClones: boolean }>`
        width: ${({ isShowingClones }) => (isShowingClones ? 3 : 1) * 메인요소의 크기}px;
        height: ${({ isShowingClones }) => (isShowingClones ? 3 : 1) * 메인요소의 크기}px;
        overflow: hidden;
      
        // grid로도 구현 가능할 것으로 보임
        display: flex;
        flex-wrap: wrap;
      
        cursor: grab;
        user-select: none;
      
        // 중앙에 위치하도록 함
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
      `;


    4. 이제 이벤트에 따라 transform값만 동적으로 변경해주면 된다. 먼저 mouse와 touch 이벤트 핸들러를 정의한다.
        return (
        <FeedbackBoardContainer
          onMouseUp={onUp}
          onMouseDown={onDown}
          onMouseMove={onMove}
          onTouchStart={onDown}
          onTouchEnd={onUp}
          onTouchMove={onMove}
          ref={feedbackBoardRef}
          isShowingClones={isShowingClones}
        >
          {children}
        </FeedbackBoardContainer>
      );​
    5. 각 이벤트에 대한 함수를 작성한다. 이때 관련된 상태값은 useRef를 사용하여 React DOM 렌더링과 관계없이 변경되는 상태임을 명시한다.
       // 상태값
      const feedbackBoardRef = useRef<HTMLDivElement>(null);
      const isDown = useRef(false); // 손가락을 누르고 있는 경우
      const position = useRef({ x: 0, y: 0 }); // 최근 이벤트가 일어난 clientX, clientY 좌표
      const offset = useRef({ x: 0, y: 0 }); // 원점으로부터의 translate offset
      const speed = useRef({ x: 0, y: 0 }); // translate speed. 최근 position과 새로운 position과의 차이값
      
      // 이벤트 핸들러
      // isDown을 true로 바꾸고, 최근 position 값을 업데이트
      const onDown = useCallback((e) => {
        isDown.current = true;
        const { clientX, clientY } = e.type === 'mousedown' ? e : e.touches[0];
        position.current = { x: clientX, y: clientY };
      }, []);
      
      const onUp = useCallback(() => {
        isDown.current = false;
      }, []);
      
      // 최근 position과 새로운 position의 차이를 speed로 설정하고, 최근 position 값을 업데이트
      const onMove = useCallback((e) => {
        if (isDown.current) {
          const { clientX, clientY } = e.type === 'mousemove' ? e : e.touches[0];
          speed.current = {
            x: position.current.x - clientX,
            y: position.current.y - clientY,
          };
          position.current = { x: clientX, y: clientY };
        }
      }, []);


    6. 이제 speed에 따라 실제로 feedbackBoardRef의 transform style을 변경하면 된다. 이때, speed값을 계속해서 감속시켜야하고, 부드럽게 translate 값이 바뀌어야하지만 브라우저가 감당하지 못할 정도로 과도하게 렌더링되면 안된다. 따라서 requestAnimationFrame을 이용하여 해당 기능을 완성하고자 한다.
        useEffect(() => {
        let timer: number;
        timer = requestAnimationFrame(function slowDown() {
          let newOffsetX = offset.current.x + speed.current.x;
          if (newOffsetX > BOARD_WIDTH / 2) newOffsetX -= BOARD_WIDTH;
          if (newOffsetX < -BOARD_WIDTH / 2) newOffsetX += BOARD_WIDTH;
      
          let newOffsetY = offset.current.y + speed.current.y;
          if (newOffsetY > BOARD_WIDTH / 2) newOffsetY -= BOARD_WIDTH;
          if (newOffsetY < -BOARD_WIDTH / 2) newOffsetY += BOARD_WIDTH;
      
          offset.current = {
            x: newOffsetX,
            y: newOffsetY,
          };
      
          if (feedbackBoardRef && feedbackBoardRef.current) {
            feedbackBoardRef.current.style.transform = `translate(calc(-50% - ${offset.current.x}px), calc(-50% - ${offset.current.y}px))`;
          }
      
          speed.current = {
            x: speed.current.x * 0.86,
            y: speed.current.y * 0.86,
          };
          timer = requestAnimationFrame(slowDown);
        });
        return () => cancelAnimationFrame(timer);
      }, []);​
      requestAnimationFrame을 이용하여 무한히 transform을 변경하고, speed를 감속한다.
      새로운 offset 값은 '최근 offset'과 '현재 speed'를 더한 값으로 하되, 메인 영역을 벗어났을 경우 BOARD_WIDTH만큼 더하거나 빼주어 메인 영역 밖으로 나가지 않도록 한다.
      requestAnimationFrame을 이용했기 때문에 실행 횟수가 디스플레이 주사율과 일치하게 되어 애니메이션이 끊기지 않고 실행될 수 있다.

    7. 이벤트 핸들러 함수와 requestAnimationFrame을 이용하여 무한한 보드 UI를 구현했다!