[FE] React Bottom Sheet 애니메이션 구현하기

쭈리·2023년 12월 6일

FE

목록 보기
1/10

React + Typescript + Styled Component로 아래서 위로 올라오는 뷰 만들기

✋ 들어가기 전에...


이 두 화면의 차이점이 보이시나요? (내용 빼고..)

간단하게 생각하면 두 화면 상단에 있는 버튼의 모양이 다릅니다.
왼쪽 이미지는 뒤로가기 버튼, 오른쪽 이미지는 닫기 버튼임을 알 수 있죠.
더 나아가면 뒤로가기와 닫기를 누를 때 일어나는 화면 전환이 있습니다.

보편적으로 뒤로가기 버튼은 옆으로 슬라이드하는 애니메이션을,
닫기 버튼은 밑으로 내려가는 애니메이션을 많이 사용합니다.

👧 : 그걸 그림만 보고 어떻게 아나요??


저는 평소에 잘 된 UI나 micro interaction, 애니메이션을 보면 못참고 캡처를 해두는 습관이 있습니다. 그렇다보니 페이지 전환 애니메이션을 꼭 구현해서 프로젝트의 퀄리티를 높이고 싶었습니다.

서론이 너무 길었나요?


🎨 구현 화면

버튼을 누르면 위로 올라오고, 닫기를 누르면 아래로 내려가는 애니메이션이 잘 구현된 모습입니다. 하지만 생각보다 쉽게 구현되지 않았기 때문에 이 글을 쓰게 되었습니다.

😓 발생한 문제

// 실제 오류가 났던 코드와는 다를 수 있습니다.
function BottomPage({ isopen, onCloseRequest, children }: BottomPageProps) {
  return (
    <PageContainer isopen={isopen}>
      <Top>
        <XMarkIcon onClick={onCloseRequest} />
      </Top>
      <ChildrenContainer>{children}</ChildrenContainer>
    </PageContainer>
  );
}

props로 넘어오는 isopen의 boolean 상태 값을 이용해서 페이지의 visible을 관리하려고 했습니다.
그래서 styled-component의 keyframes를 사용해서 애니메이션을 만들었는데,
열릴 때는 잘 적용되지만 닫을 때 애니메이션이 적용되지 않고 바로 닫히는 문제가 있었습니다.

🤔 첫 번째 해결 방안

애니메이션이 적용될 시간을 주지 않고 너무 빨리 닫히는 건 아닐까?
그래서 setTimeout 을 이용해서 닫는 시간을 늦춰주었습니다.

useEffect(() => {
	// setTimeout 을 활용해서 애니메이션이 동작하는 시간만큼 닫는 시간을 늦춤
    let timeoutId: NodeJS.Timeout;

    if (isOpen) {
      setVisible(() => true);
    } else {
      timeoutId = setTimeout(() => setVisible(() => false), 350);
    }

	// 언마운트 시 에러를 방지하기 위해 clearTimeout 사용
    return () => {
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
    };
  }, [isOpen]);

나 : ㅎㅎ 이제 되겠지? 실행~
페이지 : 어림도 없지~ 바로 닫아버리기!!

애니메이션 실행시간만큼 늦춰줬지만 페이지는 가차없이 사라졌습니다.
그리고 그 이유는 boolean에서 찾을 수 있었습니다.

🤩 두 번째 해결 방안

기존에는 props로 받아 온 isopen 값을 바로 사용하였는데, isopen의 boolean 값이 변경되면 바로 페이지가 사라지기 때문에 이 페이지에 있는 useEffect 가 힘을 쓰지 못하고 있었습니다.

그래서 새로운 boolean 값을 useState로 생성해서 이 페이지의 상태를 따로 관리해주게 되었습니다.

function BottomPage({ isopen, onCloseRequest, children }: BottomPageProps) {
  const [visible, setVisible] = useState<boolean>(false);

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;
    if (isopen) {
      setVisible(() => true);
    } else {
      timeoutId = setTimeout(() => setVisible(() => false), 350);
    }
    return () => {
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
    };
  }, [isopen]);

  // 페이지의 열고 닫음은 visible로 관리
  if (!visible) {
    return null;
  }

  return (
    <PageContainer isopen={isopen}>
      <Top>
        <XMarkIcon onClick={onCloseRequest} />
      </Top>
      <ChildrenContainer>{children}</ChildrenContainer>
    </PageContainer>
  );
}

이렇게 나눠서 관리를 하니까, isopen 이 false로 변경되어도 setTimeout 실행 완료가 되어야 visible 이 false 가 되고 완전히 페이지가 사라지기 때문에 그 사이에 애니메이션이 작동하는 모습을 확인할 수 있었습니다.

😎 전체 코드 (핵심만 보려면 여기로)

import { XMarkIcon } from '@heroicons/react/24/outline';
import React, { useEffect, useState } from 'react';
import styled, { keyframes } from 'styled-components';

interface BottomPageProps {
  isopen: boolean;
  onCloseRequest: () => void;
  children?: React.ReactNode;
}

function BottomPage({ isopen, onCloseRequest, children }: BottomPageProps) {
  const [visible, setVisible] = useState<boolean>(false);

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;
    if (isopen) {
      setVisible(() => true);
    } else {
      timeoutId = setTimeout(() => setVisible(() => false), 350);
    }
    return () => {
      if (timeoutId !== undefined) {
        clearTimeout(timeoutId);
      }
    };
  }, [isopen]);

  if (!visible) {
    return null;
  }

  return (
    <PageContainer isopen={isopen}>
      <Top>
        <XMarkIcon onClick={onCloseRequest} />
      </Top>
      <ChildrenContainer>{children}</ChildrenContainer>
    </PageContainer>
  );
}

const SlideUp = keyframes`
  from {
    transform: translateY(100%);
  }
  to {
    transform: none;
  }
`;

const SlideDown = keyframes`
  from {
    transform: none;
  }
  to {
    transform: translateY(100%);
  }
`;

const PageContainer = styled.div<{ isopen: boolean }>`
  position: fixed;
  top: 0;
  bottom: 0;
  background: var(--MR_WHITE);
  z-index: 20;
  width: min(100%, 430px);
  animation: ${({ isopen }) => (isopen ? SlideUp : SlideDown)} 0.35s ease-in-out forwards;
`;

const Top = styled.div`
  width: min(100%, 430px);
  top: 0;
  height: 4rem;
  position: fixed;
  display: flex;
  align-items: center;
  > svg {
    width: 1.4rem;
    height: 1.4rem;
    padding: 1rem;
  }
`;

const ChildrenContainer = styled.div``;

export default BottomPage;

📚 배운 내용

컴포넌트의 visible 변경 시에 애니메이션을 추가할 때, props로 받아 온 state 값을 바로 적용하면 setTimeout 이 적용 될 시간이 없다. 그러므로 따로 상태를 관리하는 boolean 을 만들어서 적용해야한다.

profile
화면 아래에 논리를 펼치는 프론트엔드 엔지니어 🐥

0개의 댓글