개발 이야기/React & Next

[React/Typescript] 반응형 네비게이션 만들기

Heman 2021. 6. 6. 03:49

[React/Typescript] 반응형 네비게이션 만들기 (Navigation with CSS media queries)

이번 포스팅에서는 React 라이브러리와 Typescript로 화면 크기에 따라 배치가 바뀌는 반응형 네비게이션을 만들어보겠습니다.

반응형 웹이란 하나의 웹사이트에서 PC, 태블릿/패드, 모바일 등 디스플레이의 종류에 따라 화면의 크기가 자동으로 변하도록 만든 웹페이지 접근 기법을 말합니다.

위키백과 : 반응형 웹 디자인

 

Overview

우선 최종 결과가 어떤 식으로 나오는지 보시면 좋을 것 같습니다!

풀 스크린 사이즈 웹페이지


최종적으로 코드를 적용하고 실행한 웹 페이지의 모습입니다.


iPhoneX 사이즈 반응형 웹


iPhoneX 기준의 디스플레이 사이즈를 적용한 모습입니다.
반응형이 잘 적용되어 상단 메뉴들은 사라지고, 네비게이션 왼쪽에 있는 메뉴 아이콘을 볼 수 있습니다.


메뉴 아이콘을 클릭한 후 화면


메뉴 아이콘을 누르면 x모양으로 변하고 사이드 바가 열립니다.

 

그럼 이제 반응형 네비게이션 구현 코드들을 살펴보겠습니다~

폴더구조

코드 구현에 앞서서 제가 구현한 반응형 네비게이션의 폴더 구조를 보여드리겠습니다. 꼭 제 방법이 정답은 아니며, 폴더 구조는 프로젝트나 디자인 패턴 등에 따라 제각각이기 때문에 참고만 해주세요~

src 폴더의 구조는 아래와 같습니다.

src 폴더구조



components 폴더에서 navigation.tsxnavItem.tsx가 따로 있는 이유는 navigation 컴포넌트 내에서 배열 데이터에 따라 navItem 컴포넌트를 map 시켜주기 위해서 입니다.
이렇게 구조를 짜게 되면 추후 메뉴가 추가될 때 태그나 엘리먼트 내용들을 일일이 작성하지 않아도, 배열 데이터에 오브젝트 하나만 추가하면 되기 때문입니다.

자세한 내용은 뒤에서 설명하겠습니다.

pages 폴더의 각 페이지는 페이지 이름만을 h1 태그로 반환하도록 작성을 해두었으며, 가독성을 위해 따로 css를 적용해 두었습니다.

 

App.tsx

이번 프로젝트의 상위 파일인 App.tsx에 작성한 코드입니다.
페이지의 이동을 위해 react-router-domBrowserRouter, Switch, Route를 사용하였습니다.
(반응형 웹을 제작하기 위한 포스팅이기 때문에 이번 포스팅에서는 다루지 않겠습니다 ㅎㅎ)

import { BrowserRouter, Switch, Route } from "react-router-dom";
import Navigation from "./components/navigation";
import { Home, Menu1, Menu2, Menu3, Menu4 } from "./pages";

export default function App() {
  return (
    <BrowserRouter>
      <Navigation />

      <Switch>
        <Route component={Home} path="/" exact />
        <Route component={Menu1} path="/menu1" exact />
        <Route component={Menu2} path="/menu2" exact />
        <Route component={Menu3} path="/menu3" exact />
        <Route component={Menu4} path="/menu4" exact />
      </Switch>
    </BrowserRouter>
  );
}

 

Navigation 컴포넌트가 항상 페이지내에 고정되어있고, 특정한 조건(주소 변경)에 따라 Navigation 아래의 다른 컴포넌트들만 Switch 해주는 구조입니다.

components/navigation.tsx

우선 네비게이션 컴포넌트의 전체 코드를 한번 보고 설명하도록 하겠습니다.

import "./styles/navigation.css";
import { useState } from "react";
import { withRouter } from "react-router-dom";
import NavItem from "./navItem";

function Navigation(): JSX.Element {
  const [menuToggle, setMenuToggle] = useState<boolean>(false);
  const menu = [
    { name: "Home", address: "/" },
    { name: "Menu-1", address: "/menu1" },
    { name: "Menu-2", address: "/menu2" },
    { name: "Menu-3", address: "/menu3" },
    { name: "Menu-4", address: "/menu4" },
  ];

  return (
    <nav className="navigation__wrapper">
      <div
        className={!menuToggle ? "burger__menu" : "x__menu"}
        onClick={() =>
          menuToggle ? setMenuToggle(false) : setMenuToggle(true)
        }
      >
        <div className="burger_line1"></div>
        <div className="burger_line2"></div>
        <div className="burger_line3"></div>
      </div>

      <div
        className={[
          "menu__box",
          !menuToggle ? "menu__box__hidden" : "menu__box__visible",
        ].join(" ")}
      >
        <div className="menu__list">
          {menu.map((data) => (
            <NavItem
              data={data}
              key={data.address}
              offNav={() => setMenuToggle(false)}
            />
          ))}
        </div>
      </div>
    </nav>
  );
}

export default withRouter(Navigation);

 

리액트 개발에 익숙하지 않으신 분들은 뭔가 복잡해 보일수 있지만, 생각보다 간단합니다.
하나씩 훑어보겠습니다~

  const [menuToggle, setMenuToggle] = useState<boolean>(false);
  const menu = [
    { name: "Home", address: "/" },
    { name: "Menu-1", address: "/menu1" },
    { name: "Menu-2", address: "/menu2" },
    { name: "Menu-3", address: "/menu3" },
    { name: "Menu-4", address: "/menu4" },
  ];

 

우선 위 코드의 첫번째 줄은 useState 훅을 이용하여 menuToggle이라는 상태값을 false로 초기화 해 준 것입니다. 배열의 두번째에 있는 setMenuToggle을 이용하여 menuToggle에 변화를 줄 수 있습니다.
React 라이브러리에서는 상태값을 직접적으로 변경하면 안되기 때문에, 이런 함수형 컴포넌트에서는 useState와 같은 훅을 사용하여 상태값을 변경합니다.

menuToggle은 화면의 크기가 작아졌을 때 생기는 버거(?)아이콘을 클릭하면, 메뉴바가 사이드에서 보이도록 하기 위한 토글이라고 보시면 됩니다.

다음 줄의 menu 배열은 메뉴에 들어갈 이름과 주소값을 정의한 배열입니다. 앞서 언급한 것처럼 이 배열에 오브젝트 하나만 추가하면 손쉽게 메뉴를 추가할 수 있게됩니다.


<div 
  className={!menuToggle ? "burger__menu" : "x__menu"}
  onClick={() =>
    menuToggle ? setMenuToggle(false) : setMenuToggle(true)
  }
>
  <div className="burger_line1"></div>
  <div className="burger_line2"></div>
  <div className="burger_line3"></div>
</div>

 

위 코드는 버거 아이콘을 위해 작성했습니다.
menuTogglefalse 라는 뜻은 컴포넌트가 처음 마운트되었거나, 열려있는 버거 아이콘을 닫기 위해 클릭한 후일 것입니다.
이때는 burger__menu라는 클래스 이름을 적용하고, menuToggletrue일 때는 x__menu라는 클래스 이름을 적용하도록 삼항연산자를 사용했습니다.

onClick에는 메뉴를 눌렀을때 menuToggle의 상태값을 변경해주는 함수를 작성했습니다.

그 아래의 div 세개는 버거메뉴를 css로 그리기 위해 작성한 태그들입니다.


<div
  className={[
    "menu__box",
    !menuToggle ? "menu__box__hidden" : "menu__box__visible",
  ].join(" ")}
>
  <div className="menu__list">
    {menu.map((data) => (
      <NavItem
        data={data}
        key={data.address}
        offNav={() => setMenuToggle(false)}
      />
    ))}
  </div>
</div>

 

이 코드에서는 첫번째 줄에 작성된 div가 헷갈릴 수 있습니다만, 별로 어렵지 않습니다.

우리가 일반적으로 작성하는 html 문서에서는 태그 내 class 이름을 띄어쓰기(스페이스바)로 연결하여 복수적용 할 수 있습니다.
하지만 위와 같이 작성한 jsx 문법에서는 띄어쓰기를 인식하지 못합니다. 그렇기 때문에 배열에 데이터를 넣은 후 join 문법을 사용하여 연결해 준 것입니다.

아래 예시를 보겠습니다.

<div className={ ["A", "B"].join(" ") }></div>


배열내의 요소들 A와 B를 join 함수 파라미터 안에 있는 " ", 즉 띄어쓰기로 연결한다는 의미입니다.
className 내 두번째 배열요소에 해당하는 삼항연산자는 menuToggle 값에 따라 메뉴박스의 보여짐을 결정하기 위해 작성하였습니다. 버거 아이콘을 눌렀을 때 메뉴박스가 열리거나 닫힐 예정입니다.

그 다음 div에서는 처음에 정의한 menu 배열요소에 따라 map 함수로 메뉴 각각에 데이터와 offNav라는 함수를 뿌려주는 코드입니다. map 함수를 사용했기 때문에 나중에 다른 페이지가 추가되더라도 손쉽게 수정할 수 있습니다.

components/navItem.tsx

NavItem 컴포넌트는 복잡한 내용이 없기 때문에 한번에 보도록 하겠습니다~

import "./styles/navigation.css";
import { Link } from "react-router-dom";

interface NavProps {
  data: {
    name: string;
    address: string;
  };
  offNav: Function;
}

export default function NavItem({ data, offNav }: NavProps): JSX.Element {
  const { name, address } = data;

  return (
    <Link to={`${address}`} className="menu__item" onClick={() => offNav()}>
      {name}
    </Link>
  );
}

 

NavItem 컴포넌트가 상위 컴포넌트에서 받아오는 props는 재사용할 일이 없기 때문에, 따로 types 파일 등에 정의하거나 하지 않고 바로 interface로 선언해두겠습니다. interface 대신 type으로 작성해도 됩니다.

NavItem 컴포넌트는 Link 태그(a 태그에 해당) 한개 만을 반환합니다. 그 이유는 상위 컴포넌트에서 map 함수로 받은 1개 분량의 배열요소에 해당하는 값만 보여주면 되기 때문입니다.
Link 태그 내에 붙는 to는 경로/주소 값을 나타냅니다. Navigation 컴포넌트의 menu 배열에서 props로 받아온 주소를 바로 적용할 수 있습니다.

Link 태그 onClickoffNav라는 함수를 적용한 이유는 메뉴를 클릭했을때 사이드 메뉴를 닫고 바로 페이지를 보이기 위함입니다. 페이지로 이동한 후 사이드 바를 닫기 위해 한번 더 메뉴를 클릭하는 수고를 덜기 위해서 넣어두었습니다.
해당 함수는 상위 컴포넌트에서 setMenuToggle을 항상 false 상태로 받아오도록 작성했습니다.

components/styles/navigation.css

css 파일의 전체 코드입니다.

(네비게이션 내의 a 태그 메뉴들은 따로 텍스트 스타일을 기본 텍스트와 동일하게 적용해 두었습니다.)


스타일시트는 모든 코드를 설명하기엔 무리가 있기 때문에, 간단히 미디어 쿼리만 짚고 넘어가겠습니다.

.navigation__wrapper {
  width: 100%;
  height: 75px;
  position: fixed;
  display: flex;
  align-items: center;
  top: 0;
  z-index: 9999;
  background-color: white;
  border-bottom: 1px solid rgba(44, 44, 44, 0.233);
  box-shadow: 0px 2px 3px rgba(44, 44, 44, 0.137);
}

.menu__list {
  display: flex;
  justify-content: center;
}

@media (max-width: 1099px) {
  .burger__menu,
  .x__menu {
    display: block;
    margin-left: 40px;
    cursor: pointer;
  }

  .burger__menu > div,
  .x__menu > div {
    width: 25px;
    height: 3px;
    background-color: black;
    margin: 5px;
    transition: all 0.3s ease;
  }

  .x__menu > .burger_line1 {
    transform: rotate(-45deg) translate(-5px, 6px);
  }

  .x__menu > .burger_line2 {
    opacity: 0;
  }

  .x__menu > .burger_line3 {
    transform: rotate(45deg) translate(-5px, -6px);
  }

  .menu__box__visible {
    width: 220px;
    height: 100%;
    position: fixed;
    left: 0;
    top: 76px;
    background-color: white;
    box-shadow: 2px 0px 1px rgba(44, 44, 44, 0.137);
  }

  .menu__box__hidden {
    display: none;
  }

  .menu__list {
    position: relative;
    top: 50px;
    left: 50px;
    flex-direction: column;
  }

  .menu__item {
    margin: 15px 0;
  }
}

@media (min-width: 1100px) {
  .navigation__wrapper {
    flex-direction: row;
    align-items: center;
    justify-content: center;
  }

  .burger__menu,
  .x__menu {
    display: none;
  }

  .menu__box {
    width: 50%;
    height: fit-content;
  }

  .menu__list {
    flex-direction: row;
    align-items: center;
  }
}

 

저는 미디어쿼리를 활용하여 화면의 가로 1100px을 기준으로 반응형 웹을 적용하였습니다. 미디어 쿼리를 작성할 때는 어느 사이즈에도 공통으로 적용되는 부분은 따로 디폴트로 작성해두고, 변화가 생기는 부분만 미디어 쿼리 내에 작성하면 좋습니다.


추가적으로 사이드바에 대한 애니메이션이나 각각의 선택메뉴에 대한 색상 변화 기능을 추가해보아도 좋을것 같습니다 :)