[React] IOS 페이지 트랜지션 구현해보기 :: 마이구미
이 글은 React 에서 페이지 트랜지션을 구현해본다.
페이지 트랜지션은 IOS 처럼 리스트 페이지간 좌우 슬라이드 애니메이션으로 진행한다.
전체 코드 및 실제로 동작하는 예제를 제공할 예정이니 글을 끝까지 읽어보길 바란다.
구현하고자하는 페이지 트랜지션은 무엇인가?
이 글에서 구현해보는 애니메이션을 먼저 확인해보자.
첨부 파일을 기반으로 요약하면 다음과 같다.
- 하단 네비게이션바 간의 이동에서는 애니메이션이 없는 상태이다.
- 리스트 페이지에서 아이템을 클릭하면 상세 페이지로 이동하는 경우에 상세 페이지는 Slide 애니메이션이 적용된다.
- 리스트 페이지에서 아이템을 클릭하면 상세 페이지로 이동하는 경우에 리스트 페이지는 FadeInOut 애니메이션이 적용된다.
1, 2번이 주요 내용이고, 3번의 경우에는 응용이라고 볼 수 있다.
슬라이드 애니메이션은 보다시피 기존 페이지가 유지된 상태에서 다음 페이지가 진입하거나 퇴장하는 방식으로 진행된다.
이것을 구현하는 것이 이 글의 메인 주제이다.
구현에 필요한 것들은 무엇인가?
위 페이지 트랜지션 구현의 주요 라이브러리는 다음과 같다.
framer-motion 라이브러리는 실질적으로 애니메이션을 위한 라이브러리이다.
react-router 는 페이지간의 이동을 위한 라이브러리이다.
요소들간의 애니메이션을 구현하는 방법은 framer-motion 를 활용하여 쉽게 만들 수 있다.
하지만 페이지 간의 애니메이션은 일반 요소가 아닌 라우터 개념이 들어가기에 비교적 단순하지는 않다.
우선, 구현을 위해 고민해야하는 것들을 리스트업해보자.
- 현재 페이지와 이동하는 다음 페이지에 대한 컴포넌트가 공존해야한다.
- 상세 페이지로 진입하는 경우에는 오른쪽에서 왼쪽으로 이동하는 슬라이드 장면이 연출되어야하고, 반대로 상세 페이지를 벗어나는 경우에는 왼쪽에서 오른쪽으로 이동하는 장면을 연출해야한다.
- 상세 페이지의 경우에만 Slide 애니메이션이 적용되어야한다.
- 리스트 페이지의 경우에는 상세 페이지에서 벗어나는 경우에만 FadeInOut 애니메이션이 적용되어야한다.
리스트업된 것들을 하나씩 해결해보자.
현재 페이지와 이동하는 다음 페이지에 대한 컴포넌트가 공존해야한다.
이해를 위해 다음 페이지로 이동하는 것을 "진입", 다음 페이지를 위해 기존 페이지가 벗어나는 것을 "퇴장" 이라고 표현하겠다.
기본적으로 페이지가 퇴장하는 시점에는 즉시 퇴장하는 페이지 컴포넌트는 unmount 되고, 진입하는 페이지 컴포넌트가 mount 된다.
우리는 퇴장하는 페이지의 unmount 시점을 지연시키는 것이 필요하다.
라우터를 우선 framer-motion 에서 제공해주는 컴포넌트로 감싸주어야한다.
<AnimatePresence mode={'popLayout'} initial={false}>
<Route ... />
</AnimatePresence>
mode 에 "popLayout" 을 지정해주면, 애니메이션의 대상이 되는 전후 요소들은 모두 공존할 수 있게 된다.
2개의 컴포넌트가 공존하더라도, 한 가지 문제가 존재한다.
전후 요소 모두 같은 컴포넌트를 노출한다는 것이다.
즉, 동일한 페이지 컴포넌트(진입하는 페이지) 2개가 연출되는 것이다.
이건 Routes 컴포넌트를 활용할 수 있다.
참고로 Routes 는 현재 권장하지 않는 방식인데, 다른 방안을 찾지 못하였다.
6.4 버전에서 제공해주는 data API 들을 사용할 수 없게 된다.
<Routes location={location} key={location.pathname}>
<Route path="/" element={<HomePage />} />
<Route path="/my" element={<MyPage />} />
<Route path="/pet" element={<PetListPage />} />
<Route path="/pet/:id" element={<PetDetailPage />} />
</Routes>
Routes 컴포넌트의 key 속성을 통해 우리의 요구사항을 충족할 수 있게 된다.
key 속성에 location.pathname 을 지정해주어, 경로가 변경될때마다 Routes 가 고유하게 랜더링되게 하는것이다.
이렇게 하면 페이지 전환 시 퇴장 페이지와 진입 페이지가 랜더링된다.
상세 페이지로 진입하는 경우에는 오른쪽에서 왼쪽으로 이동하는 슬라이드 장면이 연출되어야하고, 반대로 상세 페이지를 벗어나는 경우에는 왼쪽에서 오른쪽으로 이동하는 장면을 연출해야한다.
좌우 출발점이 어디인지를 알아야한다.
상세 페이지로 진입하는 것인지? 상세 페이지를 벗어나는 것인지를 알 수 있어야한다.
이건 react-router 에서 제공해주는 훅을 사용하면 된다.
const action = useNavigationType()
해당 훅은 라우터 변경시 반환값으로 PUSH, POP, REPLACE 를 반환해준다.
우리는 이 값을 framer-motion 쪽에서 활용해주면 된다.
const MOTION_VARIANTS_SLIDE_X = {
initial: ({ action }: { action: Action }) => ({
x: action === 'POP' ? '-100%' : '100%',
}),
enter: () => ({
x: 0,
}),
exit: ({ action }: { action: Action }) => ({
x: action === 'POP' ? '100%' : '-100%',
}),
}
<motion.main
initial="initial"
animate="enter"
exit="exit"
custom={{ action }}
variants={MOTION_VARIANTS_SLIDE_X}
>...</motion.main>
상세 페이지의 경우에만 슬라이드 애니메이션이 적용되어야한다.
해당 페이지가 상세 유무를 판단할 수 있으면 된다.
각 페이지 컴포넌트는 동일한 컴포넌트를 가지고 있으면 쉽게 해결할 수 있다.
type PageProps = PropsWithChildren & {
type: 'home' | 'detail';
};
const Page = ({ type, children }: PageProps) => {
return (
<motion.main
variants={
isHome
? {}
: MOTION_VARIANTS_SLIDE_X
}
>
{children}
</motion.main>
)
}
const HomePage = () => {
return (
<Page type='home'>...</Page>
)
}
const DetailPage = () => {
return (
<Page type='detail'>...</Page>
)
}
리스트 페이지의 경우에는 상세 페이지에서 벗어나는 경우에만 FadeInOut 애니메이션이 적용되어야한다.
해당 트랜지션은 일반 페이지와 상세 페이지간의 진입, 퇴장을 기반으로 진행해본다.
상세 페이지의 진입의 경우에는 퇴장하는 페이지에 FadeInOut 연출되어야한다.
반대로, 상세 페이지를 퇴장하는 경우에는 진입하는 페이지에 FadeInOut 동작되어야한다.
페이지 트랜지션이 동작하면 2개의 페이지 컴포넌트가 공존한다는 것은 이미 잘 알고 있다.
각각 컴포넌트에서는 어디에서 어디로 진입인지 퇴장인지를 파악해야한다.
즉, 우리는 진입하는 곳이 홈이고 퇴장하는 곳이 상세인 경우와 퇴장하는 곳이 홈이고 진입하는 곳이 상세인 경우를 판단해야한다.
const noSlideRoutes = [{ path: '/' }, { path: '/pet' }, { path: '/my' }];
const previousLocation = usePrevious(location) ?? location;
const [matchEnterRoute] = matchRoutes(noSlideRoutes, location) || [];
const [matchExitRoute] = matchRoutes(noSlideRoutes, previousLocation) || [];
const isFadeMotion =
(!!matchEnterRoute && !matchExitRoute) ||
(!!matchExitRoute && !matchEnterRoute);
슬라이드 애니메이션을 적용하지 않는 라우터 경로들을 리스트화한다.
진입, 퇴장 모두 파악하기 위해 이전 location 정보를 별도로 기록한다.
그리고 2가지를 기반으로 react-router 에서 제공해주는 matchRoutes 함수를 활용할 수 있다.
더 좋은 방법이 있다면, 공유를 부탁드린다.