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

간단하게 생각하면 두 화면 상단에 있는 버튼의 모양이 다릅니다.
왼쪽 이미지는 뒤로가기 버튼, 오른쪽 이미지는 닫기 버튼임을 알 수 있죠.
더 나아가면 뒤로가기와 닫기를 누를 때 일어나는 화면 전환이 있습니다.
보편적으로 뒤로가기 버튼은 옆으로 슬라이드하는 애니메이션을,
닫기 버튼은 밑으로 내려가는 애니메이션을 많이 사용합니다.
👧 : 그걸 그림만 보고 어떻게 아나요??

저는 평소에 잘 된 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 을 만들어서 적용해야한다.