npm install motion
AnimatePresence
는 직계 자식이 DOM에서 사라질 때 exit 애니메이션을 실행하도록 감시하는 컴포넌트이다.
AnimatedPresence
를 import하고, 애니메이션을 적용하고자 하는 컴포넌트의 직계 부모 컴포넌트로 지정해준다.
AnimatePresence
의 직계 자식들은 각각 고유한 key 속성을 가져야 한다.
그래야 AnimatePresence
가 DOM 트리에서 어떤 요소가 존재하는지, 사라졌는지 추적할 수 있다.
const Header: React.FC = () => {
return (
<div ref={headerRef} className={styles["header-extended"]}>
<header className={styles.header}>
<Logo />
<AnimatePresence>
{expanded ? (
<NavBar key="nav" />
) : (
<FilterSmall
key="small"
selected={selectedField}
onFieldClick={handleUserHeaderExpand}
/>
)}
</AnimatePresence>
<Auth />
</header>
{expanded && (
<FilterBig
key="big"
selectedField={selectedField}
fieldContent={fieldContent}
setSelectedField={setSelectedField}
setFieldContent={setFieldContent}
/>
)}
</div>
);
};
애니메이션을 적용하고자 하는 컴포넌트의 최상위 html 요소를 motion.<태그명>
으로 설정한다.
const NavBar: React.FC = () => {
return (
<motion.div
initial={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
className={styles.nav}
>
<div className={styles.nav__container}>
<div className={styles.nav__wrapper}>
<img className={styles.icon} src={rankingIcon} alt="" />
<span className={styles.text}>공연 랭킹</span>
<img src={drodownArrow} alt="" />
</div>
</div>
<div className={styles.nav__container}>
<img className={styles.icon} src={bookmarkIcon} alt="" />
<div className={styles.nav__wrapper}>
<span className={styles.text}>내가 찜한 공연</span>
</div>
</div>
</motion.div>
);
};
export default NavBar;
motion.div
의 주요 속성들에 대해 살펴보자.
컴포넌트가 처음 렌더링될 때의 상태를 정의한다.
initial={{ opacity: 0, y: 0 }}
이라면 컴포넌트가 화면에 나타나기 전, 투명(opacity 0) 상태에서 시작하며 초기 위치(y)도 설정된다.
컴포넌트가 나타난 후 최종 상태를 정의한다. animate={{ opacity: 1, y: 0 }}
이라면 opacity를 1로 바꾸어 완전히 보이도록 만들고, y축 위치도 0으로 유지한다. initial
에서 animate
로 자연스럽게 이동하는 등장 애니메이션이 만들어진다.
컴포넌트가 사라질 때(언마운트 시) 적용되는 상태를 정의한다.
exit={{ opacity: 0, y: -50 }}
라면 화면에서 사라질 때 위로 50px 이동하며 점점 투명해진다.
AnimatePresence
와 함께 사용할 때 종료 애니메이션으로 동작한다.
<AnimatePresence>
{expanded ? (
<NavBar key="nav" />
) : (
<FilterSmall
key="small"
selected={selectedField}
onFieldClick={handleUserHeaderExpand}
/>
)}
</AnimatePresence>
위처럼 삼항 연산자로 한 요소가 마운트되고, 한 요소가 동시에 언마운트되도록 하면 다음과 같이 애니메이션 오류가 발생한다.
AnimatePresence
는 자식 컴포넌트가 사라질 때 바로 DOM에서 제거하지 않고, exit
애니메이션이 끝날 때까지 DOM을 유지한다. 애니메이션이 완료되어야 React가 해당 컴포넌트를 완전히 언마운트하기 때문에 이럴 경우에는 wait mode
를 사용하면 좋다.
AnimatePresence
하위에 motion.div를 실수로 설정하지 않았는데, 다음과 같이 무한 렌더링되는 듯한 오류가 발생했다.
이유는 AnimatePresence
의 React와의 충돌 때문이었다.
스크롤 이벤트로 expanded
가 false
로 바뀌면 React는 FilterBig
을 제거하려고 한다.
AnimatePresence
는 exit
애니메이션을 기다리지만, 직계 자식이 motion
이 아니므로 exit
를 처리하지 못한다.
AnimtaePresence
는 컴포넌트를 제거하지 못한다고 판단하고, 리액트는 계속 제거를 시도하기 때문에 두 라이브러리 간의 역할이 충돌하여 여러번 반복해서 렌더링되었던 것이다.
따라서 AnimatePresence
컴포넌트와 motion.div
, exit
는 무조건 같이 존재해야 한다.
무수히 많이 렌더링되는 원인은 알아냈지만, 이번에도 스크롤을 미세하게 내렸을 때 다시 여러 번 축소되었다가 확장되는 현상이 발생했다.
스크롤을 내려보면서 콘솔에 스크롤의 위치를 찍어보니 순간 198로 튀었는데, 헤더가 축소되는 높이가 정확히 196 정도여서 헤더가 축소됨으로 인해 스크롤이 튀는 것을 발견했다.
이처럼 헤더가 축소되다가 확장된 경우도, 컴포넌트가 리렌더링 됨에 따라 스크롤에 영향을 주면서 헤더가 확장된 상태로 돌아가게 된 것이다.
이렇게 컴포넌트가 리렌더링되면, 스크롤 값에도 영향을 준다. 이를 해결하기 위해
스크롤 이벤트에 스로틀링을 걸어주어 불필요한 이벤트 핸들러가 여러 번 실행되는 걸 방지하였다.
스로틀링은 lodash
의 throttle
을 import하여 사용할 수 있다.
useEffect(() => {
// 스크롤 내리기 -> 헤더 축소 -> 레이아웃 변경 -> 다시 스크롤 유발 -> ... 의 루프를 스로틀링으로 해결
const handleScroll = throttle(() => {
if (window.scrollY > 0) {
if (!userExpanded) {
setExpanded(false);
setSelectedField("");
} else {
setExpanded(true);
}
} else {
// 스크롤이 맨 꼭대기인 상태일 경우 초기화
setExpanded(true);
setUserExpanded(false);
}
}, 200);
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [userExpanded]);