OTT 검색 서비스: Wherewatch - (4)Framer Motion + React Router v6 Nested / Page Transition

김 주현·2023년 7월 22일
1

Wherewatch 개발일지

목록 보기
4/5

구현 사항

React Router v6을 쓰면서 중첩 라우팅을 구현하고, 거기에 페이지 전환 애니메이션도 넣으려고 하는 상황. 이때, 같은 경로를 쓰면서 다른 컴포넌트를 보여주는 페이지도 있다.

같은 경로를 쓰는 이유는 따로 경로로 쓰는 것이 아니라 화면 뷰를 바꾸는 것이 더 낫겠다는 생각이었다. 따라서 다음과 같이 구성하였다.

또한, detail 페이지를 제외한 나머지 화면엔 공통으로 들어가는 Header가 존재한다. 이 Header의 검색박스에 접근하면 화면 뷰가 검색 모드로 변환된다. 이 모드는 경로가 /일 때든, /search 일 때는 둘 다 작동하게끔 한다.

경로 /에선 Outlet에 LandingView가 보여야 하고, isSearchMode가 되면 SearchModeView가 보여야 한다.
경로 /search에선 Outlet에 SearchResultView가 보여야 하고, 마찬가지로 검색모드로 진입이 가능해야 한다.

헤더를 공유하는 페이지끼리는 아래 View만 바뀌어야 한다. 예를 들어, 경로 /와 경로 /search 사이의 이동은 LandingView와 SearchResultView만 바뀌어야 한다.

헤더를 공유하지 않는 페이지로 간다면 전체 View가 바뀐다. 예를 들어, 경로 /와 경로 /search에서 /detail 페이지로 간다면 헤더를 포함한 페이지가 바뀌어야 한다.

예제 코드

일단 좀 위의 페이지는 구성이 복잡해서, 다른 코드로 단순화 하였다.

import { useState } from "react";
import {
  NavLink,
  Outlet,
  Route,
  RouterProvider,
  createBrowserRouter,
  createRoutesFromElements,
  useNavigate,
} from "react-router-dom";

import { motion, Variants } from "framer-motion";

const fadeAnimation: Variants = {
  initial: { opacity: 0 },
  animate: { opacity: 1 },
  exit: { opacity: 0 },
};

const AnimatedLayout = () => {
  return (
    <div>
      <Outlet />
    </div>
  );
};

const RootLayout = () => {
  const navigate = useNavigate();
  const [isSearchMode, setIsSearchMode] = useState(false);

  const handleClick = () => {
    setIsSearchMode(false);
    navigate("/search");
  };

  return (
    <div>
      <header>
        <button onClick={() => setIsSearchMode(!isSearchMode)}>
          Let's Search
        </button>
      </header>
      {isSearchMode ? <InputComponent onClick={handleClick} /> : <Outlet />}
    </div>
  );
};

const InputComponent = ({ onClick }: { onClick: () => void }) => {
  return (
    <div>
      <input />
      <button onClick={onClick}>찾기</button>
    </div>
  );
};

const LandingPage: React.FC = () => {
  return <div>This is LandingPage</div>;
};

const SearchPage: React.FC = () => {
  return (
    <div>
      <p>This is SearchPage</p>
      <p>
        <NavLink to="/">Go LandingPage</NavLink>
      </p>
      <p>
        <NavLink to="/detail">This is Content</NavLink>
      </p>
    </div>
  );
};

const DetailPage: React.FC = () => {
  const navigate = useNavigate();

  return (
    <div>
      <p>This is DetailPage</p>
      <button onClick={() => navigate(-1)}>Back</button>
    </div>
  );
};

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route element={<AnimatedLayout />}>
      <Route path="/" element={<RootLayout />}>
        <Route index element={<LandingPage />} />
        <Route path="search" element={<SearchPage />} />
      </Route>
      <Route path="detail" element={<DetailPage />} />
    </Route>
  )
);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

Router 설정

Router 설정 부분만 따로 봐보자.

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route element={<AnimatedLayout />}>
      <Route path="/" element={<RootLayout />}>
        <Route index element={<LandingPage />} />
        <Route path="search" element={<SearchPage />} />
      </Route>
      <Route path="detail" element={<DetailPage />} />
    </Route>
  )
);

function App() {
  return <RouterProvider router={router} />;
}

v6로 오면서 BrowserRouter를 직접 선언하는 게 아니고 createBrowserRouter라는 걸로 인스턴스를 생성할 수 있다. 그리고 그 안에 Routes들을 또 createRoutesFromElements라는 걸로 구성을 할 수 있다.

Routes 안에 서브를 넣는 게 아니라, Route안에 자식으로 넣으면 중첩 라우팅을 할 수 있다.

나는 전체적인 AnimatedLayout으로 한 번 Outlet을 지정해주고, 그 다음 /안에서 index과 /search에 대해 RootLayout을 통해 Outlet을 지정해주었다.

페이지 전환 애니메이션

이제 Framer Motion을 통해서 각 뷰의 컨테이너를 motion.div로 바꾸고 효과를 주면 되는데, 이때부터 나는 엄청난 삽질을 시작했다 ..... 너무 많은 문제를 겪어서 대충 상황만 보면 이렇다.

  1. animate에 대해서는 적용되는데, exit에 대해선 적용이 안 된다.
  2. 이걸 해결하기 위해서는 key를 지정해주면 되는데, 문제가 되는 부분은 RootLayout의 다음 부분이었다.
      {isSearchMode ? <InputComponent onClick={handleClick} /> : <Outlet />}
  1. Outlet에 키를 지정하게 되면, LandingView와 SearchResultView가 같은 취급을 받게 된다. -> useLocation으로 받아와서 pathname으로 키를 지정해준 건데, 적용이 안 되더라? -> 이게 왠지 봤더니, RootLayout 역시 AnimatePresnese를 위해 Key를 지정받고 있어서, 그 Key가 바뀌지 않으니 안에 그 자식에 있는 Key가 바뀌어도 Re-rendering이 일어나지 않았다. Route 자체에 key를 걸기도 하고, 넘겨주는 element에 key를 달기도 했는데, Outlet Provider 안에서의 자식이 바뀌는 거라, 소용이 없었다.
  2. 이런 식으로 도저히.... 풀 수 없는 문제가 파생되는 느낌이라 이때부터 검색에 돌입했다.

실마리

이 질문글이 아주 도움이 되었다. 해결 코드

나랑 문제 상황이 비슷해서, 어떻게 해결했나 봤는데 주요 포인트는 '새로운 Routes Key'를 지정해 주는 것이었다. 또, Nest되는 곳에서 다시 Routes를 써서 분기를 나눠주었더라.

The reason why AnimatePresence is re-animating the parent route upon child navigation is because of the key prop. When the key changes, React treats it as an entirely new component. Since the location.key will always change when a route changes, passing location.key to both Routes components will trigger the route animations every time.

해결

그래서 걍 나도 v5으로 일단 해결 봤다(...) v6으로 해보려고 했는데 잘 안 되더라고? 😇

1. index에서 BrowserRouter 뿌려주기

    <BrowserRouter>
      <Routes>
        <Route path="/*" element={<App />} />
      </Routes>
    </BrowserRouter>

2. App에서는 크게크게만 나눠주기
이때~ /와 /search는 같은 헤더를 가지기 때문에 키가 변하면 안 된다. 따로 처리해준다.

function App() {
  const location = useLocation();

  const getRouteKey = (pathname: string) => {
    if (pathname === "/") return "root";
    if (pathname.startsWith("/search")) return "root";
    if (pathname.startsWith("/detail")) return "detail";
  };

  return (
    <AnimatePresence initial={false} mode="wait">
      <Routes location={location} key={getRouteKey(location.pathname)}>
        <Route path="/*" element={<RootLayout />} />
        <Route path="/detail" element={<DetailPage key="detail" />} />
      </Routes>
    </AnimatePresence>
  );

3. 중첩 라우팅에서 다시 뿌려주기
LandingPage와 SearchResult를 구분해줘야 하므로 해당하는 키를 뿌려준다.

  const getRouteKey = (pathname: string) => {
    if (pathname === "/") return "landing";
    if (pathname.startWidth("/search")) return "search";
  };

      <AnimatePresence initial={false} mode="wait">
        {isSearchMode ? (
          <InputComponent onClick={handleClick} />
        ) : (
          <Routes location={location} key={getRouteKey(location.pathname)}>
            <Route index element={<LandingPage />} />
            <Route path="search" element={<SearchPage />} />
          </Routes>
        )}
      </AnimatePresence>

굿


전체 코드

import { useState } from "react";
import {
  NavLink,
  Route,
  Routes,
  useLocation,
  useNavigate,
} from "react-router-dom";

import { AnimatePresence, motion, Variants } from "framer-motion";

const fadeAnimation: Variants = {
  initial: { opacity: 0 },
  animate: { opacity: 1 },
  exit: { opacity: 0 },
};

const RootLayout = () => {
  const getRouteKey = (pathname: string) => {
    console.log("RootLayout: ", pathname);
    if (pathname === "/") return "landing";
    if (pathname === "/search") return "search";
  };
  const location = useLocation();

  const navigate = useNavigate();
  const [isSearchMode, setIsSearchMode] = useState(false);

  const handleClick = () => {
    setIsSearchMode(false);
    navigate("/search");
  };

  return (
    <motion.div
      key="root"
      variants={fadeAnimation}
      initial="initial"
      animate="animate"
      exit="exit"
    >
      <header>
        <button onClick={() => setIsSearchMode(!isSearchMode)}>
          Let's Search
        </button>
      </header>

      <AnimatePresence initial={false} mode="wait">
        {isSearchMode ? (
          <InputComponent onClick={handleClick} />
        ) : (
          // <AnimatePresence initial={false} mode="wait">
          <Routes location={location} key={getRouteKey(location.pathname)}>
            <Route index element={<LandingPage />} />
            <Route path="search" element={<SearchPage />} />
          </Routes>
          // </AnimatePresence>
        )}
      </AnimatePresence>
    </motion.div>
  );
};

const InputComponent = ({ onClick }: { onClick: () => void }) => {
  return (
    <motion.div
      variants={fadeAnimation}
      initial="initial"
      animate="animate"
      exit="exit"
      key="searchmode"
    >
      <input />
      <button onClick={onClick}>찾기</button>
    </motion.div>
  );
};

const LandingPage: React.FC = () => {
  return (
    <motion.div
      key="landing"
      variants={fadeAnimation}
      initial="initial"
      animate="animate"
      exit="exit"
    >
      This is LandingPage
    </motion.div>
  );
};

const SearchPage: React.FC = () => {
  return (
    <motion.div
      variants={fadeAnimation}
      initial="initial"
      animate="animate"
      exit="exit"
      key="search"
    >
      <p>This is SearchPage</p>
      <p>
        <NavLink to="/">Go LandingPage</NavLink>
      </p>
      <p>
        <NavLink to="/detail">This is Content</NavLink>
      </p>
    </motion.div>
  );
};

const DetailPage: React.FC = () => {
  const navigate = useNavigate();

  return (
    <motion.div
      key="detail"
      variants={fadeAnimation}
      initial="initial"
      animate="animate"
      exit="exit"
    >
      <p>This is DetailPage</p>
      <button onClick={() => navigate(-1)}>Back</button>
    </motion.div>
  );
};

function App() {
  const location = useLocation();

  const getRouteKeyB = (pathname: string) => {
    if (pathname === "/") return "root";
    if (pathname.startsWith("/search")) return "root";
    if (pathname.startsWith("/detail")) return "detail";
  };

  return (
    <AnimatePresence initial={false} mode="wait">
      <Routes location={location} key={getRouteKeyB(location.pathname)}>
        <Route path="/*" element={<RootLayout />} />
        <Route path="/detail" element={<DetailPage key="detail" />} />
      </Routes>
    </AnimatePresence>
  );

  // const router = createBrowserRouter(
  //   createRoutesFromElements(
  //     <Routes location={location} key={location.pathname}>
  //       <Route element={<AnimatedLayout />}>
  //         <Route path="/" element={<RootLayout />}>
  //           <Route index element={<LandingPage />} />
  //           <Route path="search" element={<SearchPage />} />
  //         </Route>
  //         <Route path="detail" element={<DetailPage key="detail" />} />
  //       </Route>
  //     </Routes>
  //   )
  // );

  // return <RouterProvider router={router} />;
}

export default App;

적용 화면


후기

profile
FE개발자 가보자고🥳

1개의 댓글

comment-user-thumbnail
2024년 4월 8일

후기 너무 웃기네요 ㅋㅋㅋㅋ

답글 달기