React Transition 강좌

모바일 웹을 만들 때, 메뉴 전환에 따라 화면이 왔다 갔다 하는 애니메이션, 다들 만들어보고 싶었을 겁니다. 아무래도 페이지 전환시 화면만 띡, 띡, 띡 바뀌면 너무 웹스러워서 느낌이 좋지 않지요. 저는 비록 웹이지만 화면 전환만은 최대한 앱과 비슷하게 만들어보고 싶었습니다.

이 글은 리액트에 어느 정도 익숙해진 사람을 대상으로 합니다.
따라서 componentDidMount를 모른다거나, useState를 쓸 줄 모르는 분들은 이해하는데 어려울 수도 있는 점, 염두해두시기 바랍니다.

설명과 진행의 편의를 위해 Create React App을 사용하여 시작합니다. CRA에 대한 제 생각은 Create React App(CRA)에 대한 변을 보시고요.


시작해봅시다.

터미널 열고 아래 커맨드를 복붙합니다.

npx create-react-app page-transition-sample
cd page-transition-sample
yarn add react-router-dom history
yarn start
이런 화면 잘 나왔나요?

브라우저가 자동으로 열리고 위와 같은 화면이 나와야 합니다.

우린 이제 React Router로 라우팅을 하고, React Transition Group을 이용해서 전환 효과를 구현할 것입니다. React Router는 리액트 커뮤니티의 표준과도 같은 라우팅 라이브러리입니다. 가장 많은 사람들이 사용하고 온갖 레퍼런스가 널려있죠. 깃허브 별이 3만개가 넘게 찍혀 있고, 70만군데에서 사용하고 있다면 이미 게임 끝난거 아닌가요.

반면 React Transition Group은 조금 끗발이 떨어지긴 합니다. 하지만 트랜지션 효과 주는 라이브러리중 가장 널리 알려져있고, 역시 많이 쓰이는 상태죠. 일단 그냥 따라 하세요.


1. 기본적인 탭 내비게이션 구현

하단에 버튼이 5개가 있는 웹앱을 만들거고요, 페이지 5개 각각 쯔위, 손나은, 수지, 아이유, 설현의 사진을 넣겠습니다. 페이지 순서는 미모의 순서나 개인의 취향을 반영하지 않았습니다. 그리고 하단 버튼을 누를 때 마다 페이지가 멋지게 애니메이션 효과가 붙어 전환되면 미션 성공인겁니다. 자질구레하게 CSS 적용하고 이미지 넣는 부분은 생략하겠습니다. 오늘 메인은 그게 아니니까요. 아래 코드를 보시죠.

import React from 'react';
import { Switch, Route, Router, Link } from 'react-router-dom'
import { createBrowserHistory } from 'history';

import './App.css';
import image1 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image1.jpg';
import image2 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image2.jpg';
import image3 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image3.jpg';
import image4 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image4.jpg';
import image5 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image5.jpg';

const Page1 = () => <div className="page" style={{ backgroundImage: `url(${image1})`}} />;
const Page2 = () => <div className="page" style={{ backgroundImage: `url(${image2})`}} />;
const Page3 = () => <div className="page" style={{ backgroundImage: `url(${image3})`}} />;
const Page4 = () => <div className="page" style={{ backgroundImage: `url(${image4})`}} />;
const Page5 = () => <div className="page" style={{ backgroundImage: `url(${image5})`}} />;
const Footer = () => (
  <nav className="footer">
    <Link to='/'>쯔위</Link>
    <Link to='/2'>손나은</Link>
    <Link to='/3'>수지</Link>
    <Link to='/4'>아이유</Link>
    <Link to='/5'>설현</Link>
  </nav>
)

const history = createBrowserHistory()

function App() {
  return (
    <Router history={history}>
      <Switch>
        <Route exact path="/" component={Page1} />
        <Route exact path="/2" component={Page2} />
        <Route exact path="/3" component={Page3} />
        <Route exact path="/4" component={Page4} />
        <Route exact path="/5" component={Page5} />
      </Switch>
      <Footer />
    </Router>
  );
}

export default App;
.page {
  width: 100vw;
  height: 100vh;
  background-size: cover;
  background-position: center;
}

.footer {
  position: fixed;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  height: 44px;
  background-color: #FFFFFF;
}

.footer a {
  flex: 1;
  line-height: 44px;
  text-align: center;
}

위 내용을 원래 있던 App.js와 App.css 파일에 덮어씌워주세요. 그리고 브라우저 가서 한번 봅니다.

이렇게 짠 하고 나옵니다.

하단의 내비게이션을 하나하나 눌러가며 확인해보세요. 페이지가 바로 바로 바뀌며 평소의 웹 페이지 처럼 바뀝니다. 이걸 이제부터 양념을 쳐서 좀 더 이쁘게 돌아가도록 바꿔봅시다.


2. 간단한 디졸브 애니메이션 구현

터미널 열고 복붙하세요.

yarn add react-transition-group node-sass

그리고 App.js를 아래 내용으로 복붙합니다.

import React from 'react';
import { Switch, Route, Router, Link } from 'react-router-dom'
import { createBrowserHistory } from 'history';
import { TransitionGroup, CSSTransition } from 'react-transition-group'

import './App.scss';
import image1 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image1.jpg';
import image2 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image2.jpg';
import image3 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image3.jpg';
import image4 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image4.jpg';
import image5 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image5.jpg';

const Page1 = () => <div className="page" style={{ backgroundImage: `url(${image1})`}} />;
const Page2 = () => <div className="page" style={{ backgroundImage: `url(${image2})`}} />;
const Page3 = () => <div className="page" style={{ backgroundImage: `url(${image3})`}} />;
const Page4 = () => <div className="page" style={{ backgroundImage: `url(${image4})`}} />;
const Page5 = () => <div className="page" style={{ backgroundImage: `url(${image5})`}} />;

const Footer = () => (
  <nav className="footer">
    <Link to='/'>쯔위</Link>
    <Link to='/2'>손나은</Link>
    <Link to='/3'>수지</Link>
    <Link to='/4'>아이유</Link>
    <Link to='/5'>설현</Link>
  </nav>
)

const history = createBrowserHistory()

const pageTrans = 'trans'
const classNames = {
  appear: `${pageTrans} appear`,
  appearActive: `${pageTrans} appear active`,
  appearDone: `${pageTrans} appear done`,
  enter: `${pageTrans} enter`,
  enterActive: `${pageTrans} enter active`,
  enterDone: `${pageTrans} enter done`,
  exit: `${pageTrans} exit`,
  exitActive: `${pageTrans} exit active`,
  exitDone: `${pageTrans} exit done`
}

function App() {
  return (
    <Router history={history}>
      <Route
        render={({ location }) => (
          <TransitionGroup className='transitionGroup'>
            <CSSTransition key={location.pathname} classNames={classNames} timeout={200}>
              <Switch location={location}>
                <Route exact path="/" component={Page1} />
                <Route exact path="/2" component={Page2} />
                <Route exact path="/3" component={Page3} />
                <Route exact path="/4" component={Page4} />
                <Route exact path="/5" component={Page5} />
              </Switch>
            </CSSTransition>
          </TransitionGroup>
        )}
      />
      <Footer />
    </Router>
  );
}

export default App;

TransitionGroup과 CSSTransition이 추가되었습니다. 또한 CSSTransition의 key 값으로 location.pathname이 들어갔네요. 예를 들어 ‘/5’, ‘/3’, ‘/’ 등의 pathname에 따라 애니메이션을 주는겁니다. location을 받아오기 위해 Route를 하나 더 추가했고요. 뒤에 붙는 timeout은 아시죠? 애니메이션 길이입니다. CSS랑 동일하게 맞춰야 매끄럽게 동작합니다. 단위는 당연히 ms.

중간에 classNames라고 뭘 복잡하게 적어놨는데요, 페이지가 들락날락할 때 변하는 CSS 클래스명을 우리가 직접 컨트롤하기 위해 쓴겁니다. 페이지 전환시 기존에 있던 페이지는 trans exit active 클래스를 갖게 되고, timeout이 끝나면 trans exit done 으로 바뀐 다음 사라집니다. 새로 들어오는 페이지의 경우 trans enter active 클래스를 갖게 되고, timeout이 끝나면 trans enter done으로 변하고 남습니다. 이걸 이용해서 우리는 CSS transition을 걸 수 있는 원리인거죠.

또한 Switchlocation을 추가했습니다. 이 부분은 맨 아래에서 길게 설명하겠습니다.

이거 다음에 CSS 부분이 엄청 복잡한데요, 정신 건강과 편의를 위해 SCSS로 작성하도록 하겠습니다. App.cssApp.scss로 이름을 바꿔주고 아래 내용을 복붙합니다.

$timing: 200ms;

@mixin fill {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

.page {
  width: 100vw;
  height: 100vh;
  background-size: cover;
  background-position: center;
}

.footer {
  @include fill;
  top: auto;
  z-index: 100;
  display: flex;
  height: 44px;
  background-color: #FFFFFF;
}

.footer a {
  flex: 1;
  line-height: 44px;
  text-align: center;
}

.transitionPage {
  max-width: 100vw;
}

.trans {
  width: 100vw;
  transition: $timing;
  transition-timing-function: ease;

  &.enter {
    z-index: 10;
    opacity: 0;

    &.active,
    &.done {
        opacity: 1;
    }
  }

  &.exit {
    z-index: 9;

    &.active,
    &.done {
      opacity: 0;
    }
  }

  &.active {
    position: fixed;
  }

  &.done {
    position: relative !important;
    transform: none !important;
  }
}

위와 같이 집어넣고 브라우저 새로고침을 해보면, 탭을 누를 때 마다 심플하게 화면이 디졸브 되면서 변하는걸 볼 수 있습니다. 잘 안되면 코드와 함께 질문 주세요.

만들어둔 애니메이션이 생각과 다르게 잘 안돌아갈 경우 CSSTransition 부분의 timeout을 200에서 20000 정도로 바꾸고, CSS에서도 $timing을 200ms에서 20000ms로 바꾼 다음 크롬 개발자 도구를 보면서 20초 동안 천천히 관찰하면 좋습니다. 특히 transitionGroup 안의 page trans DIV가 두개가 나왔다 하나가 사라지는지 여부를 꼭 보세요. 원래 있던 놈은 exit active 클래스가 붙은 후 타이밍에 맞춰 exit done 클래스로 변했다가 사라질거고요, 새로 들어오는 놈은 enter active클래스가 붙었다가 enter done 클래스로 변하고 남습니다. enter 부분은 빨간색 배경으로, exit 부분은 노란색 배경으로 해놓고 보는것도 도움이 될겁니다. 클래스가 잘 변하는데 안바뀐다면 CSS 세팅 탓일거고, 클래스가 안변하거나 DIV가 정상적으로 나오고 사라지지 않는다면 JS 세팅 탓이겠지요. 페이지 전환 효과 디버깅은 고된 작업입니다.

3. 그럴싸한 좌우 이동 효과 구현

앞서 보여준 내용은 페이지가 들어가고 나가고 할 때 opacity 값만 변할 뿐, 방향성이 없습니다. 이걸로 쉽게 만족할만한 분들은 아마 안계시겠지요. 우리는 UI를 다루는 프론트엔드 개발자니까요. 욕심을 좀 더 내봅시다. 이제 페이지가 좌우로 슬몃슬몃 이동하면서 나타나고 사라지는 효과를 구현해보죠.

일단 아래의 코드를 복붙합니다.

import React from 'react';
import { Switch, Route, Router, Link } from 'react-router-dom'
import { createBrowserHistory } from 'history';
import { TransitionGroup, CSSTransition } from 'react-transition-group'

import './App.scss';
import image1 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image1.jpg';
import image2 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image2.jpg';
import image3 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image3.jpg';
import image4 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image4.jpg';
import image5 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image5.jpg';

const Page1 = () => <div className="page" style={{ backgroundImage: `url(${image1})`}} />;
const Page2 = () => <div className="page" style={{ backgroundImage: `url(${image2})`}} />;
const Page3 = () => <div className="page" style={{ backgroundImage: `url(${image3})`}} />;
const Page4 = () => <div className="page" style={{ backgroundImage: `url(${image4})`}} />;
const Page5 = () => <div className="page" style={{ backgroundImage: `url(${image5})`}} />;

const Footer = () => (
  <nav className="footer">
    <Link to='/'>쯔위</Link>
    <Link to='/2'>손나은</Link>
    <Link to='/3'>수지</Link>
    <Link to='/4'>아이유</Link>
    <Link to='/5'>설현</Link>
  </nav>
)

const history = createBrowserHistory()

const pageTrans = 'trans toRight'
const classNames = {
  appear: `${pageTrans} appear`,
  appearActive: `${pageTrans} appear active`,
  appearDone: `${pageTrans} appear done`,
  enter: `${pageTrans} enter`,
  enterActive: `${pageTrans} enter active`,
  enterDone: `${pageTrans} enter done`,
  exit: `${pageTrans} exit`,
  exitActive: `${pageTrans} exit active`,
  exitDone: `${pageTrans} exit done`
}

function App() {
  return (
    <Router history={history}>
      <Route
        render={({ location }) => (
          <TransitionGroup className='transitionGroup'>
            <CSSTransition key={location.pathname} classNames={classNames} timeout={200}>
              <Switch location={location}>
                <Route exact path="/" component={Page1} />
                <Route exact path="/2" component={Page2} />
                <Route exact path="/3" component={Page3} />
                <Route exact path="/4" component={Page4} />
                <Route exact path="/5" component={Page5} />
              </Switch>
            </CSSTransition>
          </TransitionGroup>
        )}
      />
      <Footer />
    </Router>
  );
}

export default App;

별로 변한건 없습니다. 이거 아직 잘 동작하는 코드가 아니므로 분석하거나 신경쓰지 말고 아래 CSS도 복붙합니다.

$timing: 200ms;

@mixin fill {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

.page {
  width: 100vw;
  height: 100vh;
  background-size: cover;
  background-position: center;
}

.footer {
  @include fill;
  top: auto;
  z-index: 100;
  display: flex;
  height: 44px;
  background-color: #FFFFFF;
}

.footer a {
  flex: 1;
  line-height: 44px;
  text-align: center;
}

.transitionPage {
  max-width: 100vw;
}

.trans {
  width: 100vw;
  transition: $timing;
  transition-timing-function: ease;

  &.enter {
    z-index: 10;
  }

  &.exit {
    z-index: 9;
  }

  &.active {
    position: fixed;
  }

  &.done {
    position: relative !important;
    transform: none !important;
  }
}

.toLeft.enter {
  @include fill;
  transform: translateX(-20px);

  &.active,
  &.done {
    transform: translateX(0);
  }

  + .exit {
    transform: translateX(0) !important;

    &.active {
      transform: translateX(20px) !important;
    }
  }
}

.toLeft.exit {
  @include fill;

  &.active {
    transform: translateX(20px);
  }

  + .enter {
    transform: translateX(-20px) !important;

    &.active,
    &.done {
      transform: translateX(0) !important;
    }
  }
}


.toRight.enter {
  @include fill;
  transform: translateX(20px);

  &.active,
  &.done {
    transform: translateX(0);
  }

  + .exit {
    transform: translateX(0) !important;

    &.active,
    &.done {
      transform: translateX(-20px) !important;
    }
  }
}

.toRight.exit {
  @include fill;

  &.active {
    transform: translateX(-20px);
  }

  + .enter {
    transform: translateX(20px) !important;

    &.active,
    &.done {
      transform: translateX(0) !important;
    }
  }
}

.enter {
  opacity: 0;

  &.active,
  &.done {
    opacity: 1;
  }
}

.exit {
  opacity: 1;

  &.active,
  &.done {
    opacity: 0;
  }
}


.default-trans-enter {
  @include fill;
  transform: translateX(20px);

  &.default-trans-enter-active {
    transform: translateX(0);
  }
}

.default-trans-exit {
  @include fill;

  &.default-trans-exit-active {
    transform: translateX(-20px);
  }
}

toLeft, toRight 등의 내용이 추가되었습니다. 역시나 분석하지 말고 일단 브라우저를 봅시다. 아까와는 다르게 화면이 횡이동을 하면서 나타나는 모습을 볼 수 있습니다. 다만 뭔가 좀 어색할겁니다. 어딜 이동하던 간에 화면이 우측에서 튀어나오거든요.


3-2. 좌우 전환 효과 구현

이제 화면이 좌에서 나올지, 우에서 나올지 등을 설정해줘야 합니다. hookscontext를 사용하여 구현하겠습니다. 본격적으로 코드를 손봅시다.

src 밑에 contexts 폴더를 만들고, pageTransContext.js 파일을 만들어줍니다.

import React, { createContext, useState } from 'react'
import PropTypes from 'prop-types'

export const PageTransContext = createContext([{}, () => {}])

export const PageTransProvider = props => {
  const { children } = props
  const [pageTrans, setPageTrans] = useState('trans toRight')
  return <PageTransContext.Provider value={{ pageTrans, setPageTrans }}>{children}</PageTransContext.Provider>
}

PageTransProvider.propTypes = {
  children: PropTypes.node
}

export default { PageTransContext, PageTransProvider }

pageTrans라는 값을 세팅하고, 불러올 수 있게 Context를 만들어준겁니다. 기본값은 trans toRight로 적어놨습니다.

이제 src/index.js를 아래와 같이 바꿔줍니다.

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { PageTransProvider } from './contexts/pageTransContext';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(
  <PageTransProvider>
    <App />
  </PageTransProvider>
, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

기존 CRA의 index.js에서 달라진 부분은 App 컴포넌트를 PageTransProvider로 감싸준 부분 뿐입니다. 저렇게 해야 App.js 안에서 PageTransContext에 접근할 수 있습니다.

이제 App.js 가시죠.

import React, { useContext } from 'react';
import { Switch, Route, Router, Link } from 'react-router-dom'
import { createBrowserHistory } from 'history';
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { PageTransContext } from './contexts/pageTransContext';

import './App.scss';
import image1 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image1.jpg';
import image2 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image2.jpg';
import image3 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image3.jpg';
import image4 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image4.jpg';
import image5 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image5.jpg';

const Page1 = () => <div className="page" style={{ backgroundImage: `url(${image1})`}} />;
const Page2 = () => <div className="page" style={{ backgroundImage: `url(${image2})`}} />;
const Page3 = () => <div className="page" style={{ backgroundImage: `url(${image3})`}} />;
const Page4 = () => <div className="page" style={{ backgroundImage: `url(${image4})`}} />;
const Page5 = () => <div className="page" style={{ backgroundImage: `url(${image5})`}} />;

const Footer = () => (
  <nav className="footer">
    <Link to='/'>쯔위</Link>
    <Link to='/2'>손나은</Link>
    <Link to='/3'>수지</Link>
    <Link to='/4'>아이유</Link>
    <Link to='/5'>설현</Link>
  </nav>
)

const history = createBrowserHistory()

function App() {
  let { pageTrans } = useContext(PageTransContext)

  const classNames = {
    appear: `${pageTrans} appear`,
    appearActive: `${pageTrans} appear active`,
    appearDone: `${pageTrans} appear done`,
    enter: `${pageTrans} enter`,
    enterActive: `${pageTrans} enter active`,
    enterDone: `${pageTrans} enter done`,
    exit: `${pageTrans} exit`,
    exitActive: `${pageTrans} exit active`,
    exitDone: `${pageTrans} exit done`
  }

  return (
    <Router history={history}>
      <Route
        render={({ location }) => (
          <TransitionGroup className='transitionGroup'>
            <CSSTransition key={location.pathname} classNames={classNames} timeout={200}>
              <Switch location={location}>
                <Route exact path="/" component={Page1} />
                <Route exact path="/2" component={Page2} />
                <Route exact path="/3" component={Page3} />
                <Route exact path="/4" component={Page4} />
                <Route exact path="/5" component={Page5} />
              </Switch>
            </CSSTransition>
          </TransitionGroup>
        )}
      />
      <Footer />
    </Router>
  );
}

export default App;

PageTransContext를 불러와서, pageTrans를 뽑아내어 아까의 classNames에 집어넣게 만들었습니다. pageTrans에서 toLefttoRight 같은걸 불러올 수 있다면 좌우 페이지 이동 효과를 구현할 수 있겠죠? 그럼 pageTrans는 어디서 set 해줄까요? Footer죠.

Footer에 대공사가 필요합니다. App.js에서 Footer를 따로 떼어내죠.

import React, { useContext } from 'react';
import { Switch, Route, Router } from 'react-router-dom'
import { createBrowserHistory } from 'history';
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { PageTransContext } from './contexts/pageTransContext';
import Footer from './Footer';

import './App.scss';
import image1 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image1.jpg';
import image2 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image2.jpg';
import image3 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image3.jpg';
import image4 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image4.jpg';
import image5 from 'https://raw.githubusercontent.com/miriyas/page-transition-sample/master/src/image5.jpg';

const Page1 = () => <div className="page" style={{ backgroundImage: `url(${image1})`}} />;
const Page2 = () => <div className="page" style={{ backgroundImage: `url(${image2})`}} />;
const Page3 = () => <div className="page" style={{ backgroundImage: `url(${image3})`}} />;
const Page4 = () => <div className="page" style={{ backgroundImage: `url(${image4})`}} />;
const Page5 = () => <div className="page" style={{ backgroundImage: `url(${image5})`}} />;

const history = createBrowserHistory()

function App() {
  let { pageTrans } = useContext(PageTransContext)

  const classNames = {
    appear: `${pageTrans} appear`,
    appearActive: `${pageTrans} appear active`,
    appearDone: `${pageTrans} appear done`,
    enter: `${pageTrans} enter`,
    enterActive: `${pageTrans} enter active`,
    enterDone: `${pageTrans} enter done`,
    exit: `${pageTrans} exit`,
    exitActive: `${pageTrans} exit active`,
    exitDone: `${pageTrans} exit done`
  }

  return (
    <Router history={history}>
      <Route
        render={({ location }) => (
          <TransitionGroup className='transitionGroup'>
            <CSSTransition key={location.pathname} classNames={classNames} timeout={200}>
              <Switch location={location}>
                <Route exact path="/" component={Page1} />
                <Route exact path="/2" component={Page2} />
                <Route exact path="/3" component={Page3} />
                <Route exact path="/4" component={Page4} />
                <Route exact path="/5" component={Page5} />
              </Switch>
            </CSSTransition>
          </TransitionGroup>
        )}
      />
      <Footer />
    </Router>
  );
}

export default App;

App.js에서 Footer를 위와 같이 외부로 분리했고요, Footer.js는 아래와 같이 만들어줍니다.

import React from 'react';
import { Link } from 'react-router-dom'

const Footer = () => (
  <nav className="footer">
    <Link to='/'>쯔위</Link>
    <Link to='/2'>손나은</Link>
    <Link to='/3'>수지</Link>
    <Link to='/4'>아이유</Link>
    <Link to='/5'>설현</Link>
  </nav>
)

export default Footer;

여기까지 충분히 따라오셨죠? 이제 Footer에 PageTransContext를 걸어줍니다.

import React, { useContext } from 'react';
import { Link } from 'react-router-dom'
import { PageTransContext } from './contexts/pageTransContext'

const getGnb = () => {
  return [
    {
      to: '/',
      name: '쯔위'
    },
    {
      to: '/2',
      name: '손나은'
    },
    {
      to: '/3',
      name: '수지'
    },
    {
      to: '/4',
      name: '아이유'
    },
    {
      to: '/5',
      name: '설현'
    }
  ]
}

const getTransDirection = (gnb, gnbIndex) => {
  let pageDirection = 'trans toRight'
  const matched = gnb.find(menu => menu.to === window.location.pathname)
  let matchedIndex = gnb.indexOf(matched)

  if (matchedIndex === -1) {
    matchedIndex = 1
  }

  if (gnbIndex !== matchedIndex) {
    if (gnbIndex >= matchedIndex) {
      pageDirection = 'trans toRight'
    } else {
      pageDirection = 'trans toLeft'
    }
  }

  return pageDirection
}

const Footer = () => {
  const { setPageTrans } = useContext(PageTransContext)

  const gnb = getGnb()

  const handleOnClick = index => {
    const direction = getTransDirection(gnb, index)
    setPageTrans(direction)
  }

  return (
    <nav className="footer">
      {gnb.map((menu, i) => (
          <Link
            key={`gnb-${i}`}
            to={`${menu.to}`}
            exact={menu.exact}
            onClick={() => handleOnClick(i)}
          >
            {menu.name}
          </Link>
        ))}
    </nav>
  )
}

export default Footer;

일단 좀 더 확장성있고 사람 다운 코드를 위해 반복되는 부분을 합쳐줬습니다. 메뉴 항목을 배열로 만들었고요. PageTransContext를 불러와 setPageTrans를 할 수 있게 해줬습니다. 메뉴 항목을 클릭할 경우 getTransDirection으로 방향을 알아내고, 그걸 setPageTrans에 넣어줍니다. 그리고 페이지가 이동되는거죠.

여기까지 했으면 브라우저에서 꽤 근사한 애니메이션이 구현되어야합니다. 들어오는 페이지와 나가는 페이지가 스무스하게 오버랩되며 표현됩니다. 넷플릭스 앱에서 사용중인 효과와 거의 비슷하다고 보시면 됩니다.

좌우 방향 전환에 대한 부분은 제가 적은 방법대로 하면 되고, 나머지 효과를 어떻게 줄지는 위 내용에서 CSS 부분만 조금씩 바꿔가며 해보면 됩니다. 이거 처음 적용할때는 온갖 예제를 짬뽕 해가며 노력해봤는데, 근 4일간 삽질하다가 겨우 구현해냈습니다. 뭔가 이상하게 작동할 경우, 아래의 과정을 반복합시다.

1. transition 타이밍을 20초 정도로 천천히 잡고 살펴봅니다. DOM은 잘 렌더링 되는지, 클래스명은 잘 들어가는지, CSS 애니메이션은 헷갈리지 않게 작성했는지.
2. 로딩되는 페이지에 문제가 될만한 JS 라이브러리가 들어있지는 않은지
3. 아무리 해도 안될 경우 예제 코드와 내 코드를 1:1로 동일하게 맞춰가며 테스트해봅니다. 지우고, 또 지우고, 그러다가 어떤 순간 잘 될 때가 있습니다. 그게 포인트인거죠.


5. 같은 페이지 클릭시 화면 전환 없애기

React Router의 문서에선 CSSTransitionkey 값으로 location.key를 넘기라고 되어 있지만, 이렇게 하면 어색한 문제가 생깁니다.

내가 분명 쯔위 페이지에 있음에도 불구하고 쯔위 탭을 누르면, 또 페이지 트랜지션 효과가 나타나는거죠. 같은 페이지를 계속 로딩할 필요는 없죠. 그럼 이거 왜 이러는걸까요? location.key가 그때그때 매번 다르기 때문입니다.

Switch문 바로 아래에 요런 식으로 로그를 찍어봅시다.

return (
    <Router history={history}>
      <Route
        render={({ location }) => (
          <TransitionGroup className='transitionGroup'>
            <CSSTransition key={location.key} classNames={classNames} timeout={200}>
              <Switch location={location}>
                {console.log(location.key)}
                <Route exact path="/" component={Page1} />
                <Route exact path="/2" component={Page2} />
                <Route exact path="/3" component={Page3} />
                <Route exact path="/4" component={Page4} />
                <Route exact path="/5" component={Page5} />
              </Switch>
            </CSSTransition>
          </TransitionGroup>
        )}
      />
      <Footer />
    </Router>
  );

브라우저 로그에는 탭을 클릭할 때 마다 obv1pa, 0oo3v9, q0fmv0 식으로 랜덤한 문자열이 나옵니다. 이런식이니까 매번 페이지 트랜지션 효과가 나오지요. 그래서 저는 이 예제에서 location.key 대신 location.pathname을 사용했습니다. 이렇게 하면 동일한 탭을 눌러도 URL이 변하지 않으므로 페이지 전환이 일어나지 않습니다.

하지만! 만약 여러분의 페이지가 조금 복잡하다면 다른 방법을 써야 할지도 모릅니다. 예를 들어 '/posts/1''/posts', '/posts/1/comments' 등의 패스가 각각 다르다면, 안에 depth가 더 들어가게 된다면.. 페이지에 서브 내비게이션이 하나가 더 있다면 서브 내비게이션 전환시에도 페이지 전체가 움직여버리는 괴악한 상황을 맞을 수 있습니다. 저는 아래와 같이 해결봤습니다.

const filterPathname = path => {
  return path.split('/')[1]
}

<CSSTransition key={filterPathname(location.pathname)} classNames={classNames} timeout={200}>

이렇게 하면 pathname의 첫번째것만 리턴하게 되니까 서브 라우트로 들어가더라도 페이지 전환 효과가 일어나지 않습니다. 하나하나 이렇게 튜닝하는거죠.


끝맺으며..

모든 애니메이션이 잘 구현되었다면 디자이너분을 옆자리에 앉히고 자랑하세요. 제품의 퀄리티는 디자이너와 개발자 한명만 잘한다고 올라갈 수 있는게 아닙니다. 개발자는 더 공부해서 디자이너의 요구사항을 만족시켜줄만큼 자신감이 차올라야 하고, 디자이너는 창의력을 발휘할 때 구현 가능성 여부에 대해 고민하지 않는 그런 아름다운 모습. 얼마나 훌륭합니까. 함께 밤을 지세우다 제게 x, y, opacity 값을 넘겨준 우리 팀 디자이너 Jason에게 고마움의 뜻으로 담배 한갑을 사줬습니다. 종종 제게 도전할만한 인터렉션을 던져주고 ‘이건 반드시 해야 해요’ 라고 단호하게 말하곤 하는데, 제 입장에선 당장은 고역이지만 다 해놓고 보면 자랑거리입니다. 부디 여러분 모두 좋은 서비스 만들고 만족하시길 바랍니다.

위에서 사용한 코드는 아래 repo에서 받아 써볼 수 있습니다.

https://github.com/miriyas/page-transition-sample


아까 언급했던 Switch 구문에 대해 부연설명 해봅니다.

Switch 구문을 써야 하나

우리가 하고 싶은건 그저 URL이 변경 될 때 마다 화면이 전환되는 효과를 주는겁니다. 하지만 웃기는게, 두 주요 라이브러리의 공식 문서가 서로 다른 말을 하고 있습니다.
React Router의 Animated Transition 문서에선 <Switch>를 사용하고 있고, React Transition Group의 With React Router 문서에선 <Switch>를 사용하지 말라 하고 있습니다. React Router에 내장된 Switch문은 입력된 문서의 URL에 따라 단 하나의 컴포넌트만 리턴합니다. 그래서 페이지가 전환된 다음 exit 애니메이션을 돌려야 할 때 컴포넌트가 이미 unmount 되어버려서 문제가 발생하지요.

뭔 말인지 잘 알아듣기 힘들텐데, 아무튼 Switch문을 사용하면 탭 클릭시 좀 어색하게 보입니다. 왼쪽에서 오른쪽으로 이동하면서 페이지가 전환된다면, 새 페이지가 오른쪽에서 나타나는 애니메이션은 잘 나오는데, 이전 페이지가 왼쪽으로 사라지는 애니메이션은 없는거에요. 그냥 뿅 사라지는겁니다. 눈이 빠르지 않아도 차이점은 바로 눈에 띄죠. Switch문이 이전 페이지를 날려버리고 새 페이지만 리턴하기 때문입니다.

갈 땐 가더라도 담배 하나 정도는 괜찮잖아?

Switch는 필요가 없어진 컴포넌트는 바로 치워버리는 잔악 무도한 놈인가봅니다. 근데 실제 제품을 만들 때 예제 따라 해보면 Switch문을 안쓰면 도저히 답이 안나오는 부분이 있습니다. 404에러 처리죠. 보통 ‘페이지를 찾을 수 없습니다’ 등으로 나오는 404 에러 페이지, 우리는 보통 이렇게 처리합니다.

<Switch location={location}>
  <Route exact path="/" component={HomePage} />
  <Route path="/car" component={CarPage} />
  <Route path="/can" component={CanPage} />
  <Route component={NoMatch} />
</Switch>

하지만 Switch를 안쓸 경우 404 페이지 표현이 아주 괴악해집니다. Router 안에서 아무것도 리턴 안할 경우 바로 빈 페이지가 떠버립니다. 그렇다고 404를 표현하기 위해 뒤에 z-index 먹인 404 페이지를 고정으로 깔아놓는것도 어이없고 자존심 상하는 일입니다. 저는 두가지 방법 모두 실험해봤고, React Router의 문서가 더 좋다는 결론을 내리게 되었습니다.

React Router의 문서에선 다만 Switch에 location이 추가되어 있습니다. 이걸 추가해야만 이전 페이지를 유지하면서 새 페이지로 넘어갑니다.


그럼 마지막으로 몇가지 FAQ내지는 첨언 적어보고 끝냅니다. 혹시 댓글로 질문 주시면 더 추가하겠습니다.

CSS 애니메이션에 margin 같은거 걸지 마라

예전보다 브라우저가 좋아져서, 요새 어지간한 브라우저들은 대부분 하드웨어 가속을 지원합니다. transform 걸 때 translateX 등을 사용하고, margin-left 같은건 사용하지 마세요. 특정 속성만 하드웨어 가속이 적용됩니다. 요약하면 그냥 transform 속성 써라, 더 배우고 싶다면 하드웨어 가속에 대한 이해와 적용 글을 참조해보세요.

transitionGroup 아래에 DIV가 1~2개가 아니라 여러개네요?

테스트 하실 때 timeout을 길게 잡아놨을 때 이럴 수 있습니다. 당황하지 마세요. timeout 끝나면 하나씩 사라질겁니다.

괴이하게 특정 페이지만 transition이 먹지 않습니다.

저도 애를 먹은 부분인데, 이게 참 CSS 문제도 아니었고 route 문제도 아니었습니다.
1. 페이지의 내용물을 싹 비우고 시도해봅니다.
2. 페이지에 걸린 CSS를 모두 뜯고 시도해봅니다.
3. 페이지 url을 바꾸고 시도해봅니다.

제 경우에는 첫 페이지에 들어 있는 slick.js와 충돌이 있었던 것 같습니다. 해당 부분을 지우니까 감쪽같이 효과가 잘 작동하더군요. slick.js 있는 부분을 페이지 mount 후에 로딩하도록 간단하게 처리하여 대응하였습니다. 우리가 흔히 쓰는 setTimeout() { 로딩, 0}을 쓰거나, useEffect(() => 로딩, []) 식으로 쉽게 처리 가능합니다.

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다