본문 바로가기

프론트엔드/React

리액트에서 달팽이 모양으로 움직이는 애니메이션 만들기

  • 프로젝트: 채식 지도(가명)
  • 키워드: snail array, transform translate, algorithm
  • 상황
    1. 리액트에서 무한 영역 UI 만들기(feat. requestAnimationFrame, useRef) 에서 만들었던 피드백 페이지가 있다.
    2. 새로운 피드백을 작성했을 때, 포스트잇 하나가 추가되어야한다.
    3. 기존 포스트잇들이 달팽이 모양으로 움직여서 새로운 포스트잇을 위한 공간을 만든다면 재미있는 UI가 될 것이다.
      기대하는 화면
  • 해결 과정
    1. 먼저 한 변의 길이가 n인 달팽이 배열을 만드는 함수를 작성한다.
      달팽이 배열 문제는 여러 가지 방법으로 풀 수 있을 것인데, 짧지만 어려운 방법 대신 길지만 직관적인 알고리즘으로 작성했다.
      '-1'로 초기화된 배열의 (0, 0)에서 시작하여, for문을 통해 direction(left) 방향으로 계속 이동한다(row와 col을 1씩 더하거나 빼가며). 그러다 다음 이동할 칸이 -1이 아닐 경우(out of range로 인한 undefined이거나 이미 양수로 할당된 칸), direction을 변경한다.
        /**
       * @param n = 3 일 때,
       * 0 1 2
       * 7 8 3
       * 6 5 4
       * 의 달팽이 배열이 나오고,
       * @return 값은 [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 2 }, ... , { row: 2, col: 2 }]
       */
      export function generateSnailPositionArray(n: number): { row: number; col: number }[] {
        const snailPositionArray = Array(n ** 2).fill(null);
        const snailIndexArray = Array(n ** 2).fill(-1);
      
        let direction: 'left' | 'right' | 'up' | 'bottom' = 'right';
        let row = 0;
        let col = 0;
      
        for (let count = 0; count < snailIndexArray.length; count++) {
          snailPositionArray[count] = { row, col };
      
          snailIndexArray[row * n + col] = count;
          switch (direction) {
            case 'right':
              if (col + 1 < n && snailIndexArray[row * n + col + 1] === -1) {
                col += 1;
              } else {
                direction = 'bottom';
                row += 1;
              }
              break;
            case 'bottom':
              if (row + 1 < n && snailIndexArray[(row + 1) * n + col] === -1) {
                row += 1;
              } else {
                direction = 'left';
                col -= 1;
              }
              break;
            case 'left':
              if (col - 1 >= 0 && snailIndexArray[row * n + col - 1] === -1) {
                col -= 1;
              } else {
                direction = 'up';
                row -= 1;
              }
              break;
            case 'up':
              if (row - 1 >= 0 && snailIndexArray[(row - 1) * n + col] === -1) {
                row -= 1;
              } else {
                direction = 'right';
                col += 1;
              }
              break;
          }
        }
      
        return snailPositionArray;
      }​
      달팽이 배열에서 필요로 하는 return값의 형태는
      arr = [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 1, col: 2 }, ... , { row: 2, col: 2 }]
      와 같은 형태이다.
      n번째 포스트잇을 arr[n]의 { row, col } 값 만큼 translate 하면 달팽이 모양대로 포스트잇을 나열할 수 있다.

    2. 먼저 함수를 이용하여 달팽이 배열을 생성한다.
        export const SNAIL_SIDE_LENGTH = 11;
      export const CARD_WIDTH = 200;
      export const BOARD_WIDTH = CARD_WIDTH * SNAIL_SIDE_LENGTH;
      
      export const snailPositionArray = generateSnailPositionArray(SNAIL_SIDE_LENGTH).reverse();​
      이 때, 새로 생긴 피드백이 달팽이 배열의 중심에 있어야 자연스럽기때문에 완성된 배열을 .reverse() 해준다.
    3. 그 후 Card(포스트잇) 컴포넌트를 작성한다.
        ...
      import {
        CARD_WIDTH,
        FEEDBACK_COLOR_SET,
        SNAIL_SIDE_LENGTH,
        snailPositionArray,
      } from '.../variables';
      
      interface Props {
        feedbackList: Feedback[];
      }
      
      const FeedbackList = ({ feedbackList }: Props): React.ReactElement => {
        return (
          <>
            {feedbackList.map((feedback, index) => {
              const isOutOfRange = index >= SNAIL_SIDE_LENGTH ** 2;
              return (
                <Item
                  style={{
                    zIndex: index === 1 ? Z_INDEX.BASE : 0,
                    transform: isOutOfRange
                      ? `translate(${-1 * CARD_WIDTH}px, 0)`
                      : `translate(
                              ${snailPositionArray[index].col * CARD_WIDTH}px,
                              ${snailPositionArray[index].row * CARD_WIDTH}px
                            )`,
                  }}
                  ...
                >
                  <div className="card">
                    <p className="text">{feedback.content}</p>
                    ...
                  </div>
                </Item>
              );
            })}
          </>
        );
      };
      
      export default React.memo(FeedbackList);
      
      export const Item = styled.div`
        position: absolute;
        top: 0;
        left: 0;
        width: ${CARD_WIDTH}px;
        height: ${CARD_WIDTH}px;
      
        padding: 12px;
        transition: transform 1s ease;
      
        .card {
          position: relative;
          width: 100%;
          height: 100%;
          padding: 16px 16px 32px;
          ...
        }
        
        ...
      `;
       핵심 로직은 간단하게 구현할 수 있다.
      const isOutOfRange = index >= SNAIL_SIDE_LENGTH ** 2;
      return (
        <Item
          style={{
            zIndex: index === 1 ? Z_INDEX.BASE : 0,
            transform: isOutOfRange
              ? `translate(${-1 * CARD_WIDTH}px, 0)`
              : `translate(
                      ${snailPositionArray[index].col * CARD_WIDTH}px,
                      ${snailPositionArray[index].row * CARD_WIDTH}px
                    )`,
          }}
      ...
      index번째 포스트잇은 (snailPositionArray[index].col * CARD_WIDTH, snailPositionArray[index].row * CARD_WIDTH) 만큼 translate 해준다.
      새로운 피드백이 생겨 배열이 길어지면(> n^2) 달팽이 배열을 벗어나는(isOutOfRange) index가 발생한다. 이 index들은 'translate(${-1 * CARD_WIDTH}px, 0)'와 'overflow: hidden' 을 통해 사용자에게 보이지 않도록 숨겨준다.
    4. 달팽이 배열 모양으로 움직이는 애니메이션을 쉽게 구현하였다!