프로젝트/하루한냥

[하루한냥] 레이아웃 구현 : 메뉴바 구현과 리팩토링

알파카털파카 2023. 6. 29. 07:45
[하루한냥]
레이아웃 구현 : 메뉴바 구현과 리팩토링

 

 

드디어 개인 프로젝트를 시작했다. 매일 고양이 스티커로 하루의 기분을 기록하는 감정일기장 프로젝트이다. 워낙 이것저것 기록하는 것을 좋아하기도 하고 고양이도 좋아해서 이런 프로젝트를 해보면 재밌을 것 같았다. 피그마로 UIUX 스토리보드 기획과 디자인까지 직접 다 진행하고 있다. 프로젝트 세팅과 기본적인 구성을 마치고 이제 레이아웃 구현을 하고 있다. 별로 어렵지 않았던 이전 작업들에 비해 이번 메뉴바 구현은 조금 어려운 점이 있었다. 이번 글에서는 메뉴바를 구현하고 코드를 리팩토링해가는 과정을 담아보았다. 

 

 


 

 

디자인 시안

모바일 최적화된 프로젝트이기에 뷰포트의 크기를 width 440px, height 920px으로 잡고 디자인했다. 기본적인 레이아웃은 헤더/바디/푸터(메뉴)로 섹션을 나누었다. 아래 스크린샷은 헤더와 메뉴만 적용해 바디에 내용이 아직 들어가지 않은 상태와, 메인이 될 캘린더 페이지의 디자인 시안이다. 

 

기본 레이아웃. 헤더와 메뉴를 적용한 모습과 캘린더 페이지

 

메뉴바

 

 

 

 

1. 하드코딩으로 UI 기틀 잡기

메뉴 아이콘을 클릭할 경우 필요한 기능은 두 가지이다. 1. 해당 페이지로 이동하며 2. 아이콘이 활성화된다. 현재 단계는 기능이 아무것도 들어가지 않은, UI만 구현한 단계이다. 이미지 주소도 그렇고, 동일한 코드가 반복되어 비효율적이다.

 

페이지 전환 시 아이콘 활성화 효과가 들어가지 않은 상태

 

// src/ui/components/layout/Menu.tsx

<Icon>
  <img 
    src="/images/icon/menu/calendar.svg"
    alt="calendar" 
    style={{ width: 32 }} 
  />
</Icon>
<Icon>
  <img 
    src="/images/icon/menu/report.svg" 
    alt="report" 
    style={{ width: 32 }} 
  />
</Icon>
<Icon>
  <img 
    src="/images/icon/menu/timeline.svg" 
    alt="timeline" 
    style={{ width: 32 }} 
  />
</Icon>
<Icon>
  <img 
    src="/images/icon/menu/setting.svg" 
    alt="setting" 
    style={{ width: 32 }}
  />
</Icon>
<FeelCatIcon>
  <img 
    src="/images/icon/menu/feel-cat.svg" 
    alt="report" 
    style={{ width: 58 }} 
  />
</FeelCatIcon>

// css
const Icon = styled.div`
  height: ${styleToken.size.headerHeight};
  flex: 1;
  display: flex;
  flex-direction: row;
  justify-content: space-around;
  align-items: center;
  cursor: pointer;
`;

 

 

 

2. 페이지 전환 및 아이콘 활성화 표시 구현 

onClick 함수를 따로 분리하지 않고 직접 사용하고 있다. window.location.pathname으로 현재 URL 경로를 가져와 해당 페이지의 활성화/비활성화에 따라 해당 이미지 주소를 넣어주고 있다. 역시 동일 로직이 반복되어 UI만 구현했을 때보다 코드가 더 길고 답답해졌다. 하지만 기능은 제대로 작동하는 상태이다.

 

페이지 전환과 버튼 활성화 기능 추가

 

// src/ui/components/layout/Menu.tsx

export default function Menu() {
  const navigate = useNavigate();

  return (
    <Container>
      <Icon onClick={() => navigate('/calendar')}> 
        <img 
          src={
            window.location.pathname === '/calendar' 
            ? '/images/icon/menu/calendar-active.svg' 
            : '/images/icon/menu/calendar.svg'
          } 
          alt="calendar" 
          style={{ width: 32 }} 
        /> 
      </Icon> 
      <Icon onClick={() => navigate('/timeline')}> 
        <img 
          src={
            window.location.pathname === '/timeline' 
            ? '/images/icon/menu/timeline-active.svg' 
            : '/images/icon/menu/timeline.svg'
          } 
          alt="timeline" 
          style={{ width: 32 }} 
        /> 
      </Icon> 
      <Icon onClick={() => navigate('/report')}> 
        <img 
          src={
            window.location.pathname === '/report' 
            ? '/images/icon/menu/report-active.svg' 
            : '/images/icon/menu/report.svg'
          } 
          alt="report" 
          style={{ width: 32 }}
        /> 
      </Icon> 
      <Icon onClick={() => navigate('/setting')}> 
        <img 
          src={
            window.location.pathname === '/setting' 
            ? '/images/icon/menu/setting-active.svg'
            : '/images/icon/menu/setting.svg'
          } 
          alt="setting" 
          style={{ width: 32 }} 
        /> 
      </Icon>
    </Container>
  );
}

 

 

3. MenuItem 컴포넌트 생성 및 별개 파일로 분리

한번에 분리해서 작성하려니 조금 어려워서 우선 Menu.tsx에서 MenuItem 컴포넌트를 작성했다. 리팩토링은 중복된 로직을 줄이고 하드코딩 최소화를 목표로 했다. 메뉴 아이콘이 가져야 할 기능인 페이지 전환과 아이콘 활성화를 위해 어떤 props를 넘겨주면 좋을 것인지 고민이 되었다.

 

사실 svg 파일의 색상을 <img> 태그에서 동적으로 변경시켜주려고 했는데, 잘 되지 않았다. svg 파일의 "fill" 속성을 직접 바꾸어 활성/비활성 아이콘을 새로 만드는 간단한 방법을 사용했다. active 상태를 추가로 넘겨줘야 할까 싶었는데 location.pathname을 이용하면 해결이 되었다. 그래서 props로 넘겨줄 것은 이미지의 경로와, URL 주소가 될 pathname이다. react router에는 pathname을 가져오는 useLocation hook이 있다. 이전 단계에서는 window.location.pathname을 사용했지만 이것도 줄여서 쓸 수 있게 되었다.

 

// src/lib/const/path.ts

export const PATH = {
  HOME: '/',
  SIGN_IN: '/signin',
  SIGN_UP: '/signup',
  CALENDAR: '/calendar',
  TIMELINE: '/timeline',
  REPORT: '/report',
  SETTING: '/setting',
};

lib의 const 폴더에서 경로를 객체로 만들어 관리하고 있다. 덕분에 하드코딩을 방지할 수 있다. 

 

// src/ui/components/layout/Menu.tsx

export default function Menu() {
  const location = useLocation();
  
  return (
    <Container>
      <MenuItem 
        imageSrc={
          location.pathname === PATH.CALENDAR
          ? '/images/icon/menu/calendar-active.svg' 
          : '/images/icon/menu/calendar.svg'
        } 
        path={PATH.CALENDAR} 
      />
      <MenuItem 
        imageSrc={
          location.pathname === PATH.TIMELINE
          ? '/images/icon/menu/timeline-active.svg' 
          : '/images/icon/menu/timeline.svg'
        } 
        path={PATH.TIMELINE} 
      />
      <MenuItem
        imageSrc={
          location.pathname === PATH.REPORT
          ? '/images/icon/menu/report-active.svg' 
          : '/images/icon/menu/report.svg'
        } 
        path={PATH.REPORT} 
      />
      <MenuItem
        imageSrc={
          location.pathname === PATH.SETTING
          ? '/images/icon/menu/setting-active.svg' 
          : '/images/icon/menu/setting.svg'
        } 
        path={PATH.SETTING} 
      />
      <FeelCatIcon>
        <img 
          src="/images/icon/menu/feel-cat.svg" 
          alt="cat-icon" 
          style={{ width: 58 }} 
        />
      </FeelCatIcon>
    </Container>
  );
}

 

// src/ui/components/layout/MenuItem.tsx

export default function MenuItem({ imageSrc, path }: { imageSrc: string; path: string }) {
  const navigate = useNavigate();

  const handleChangePage = () => {
    navigate(path);
  };

  return (
    <Icon onClick={handleChangePage}>
      <img 
        src={imageSrc} 
        alt={path}
        style={{ width: 32 }}
      />
    </Icon>
  );
}

MenuItem 컴포넌트가 잘 동작하는 것을 확인하고 별개의 파일로 분리해주었다.

 

 

 

4. 이미지 주소 하드코딩을 방지하기 위한 menuIcon 객체 생성

3번 단계에서 컴포넌트를 분리했지만 여전히 이미지 주소 로직 때문에 코드가 반복되고 길어진다. 이 문제를 조금 더 깔끔하게 해결하기 위해 menuIcon 객체를 만들었다. 변수를 키값으로 쓰기 위해 [PATH.CALENDAR]의 형태로 작성했다. 

 

const menuIcon = {
  [PATH.CALENDAR]: { 
      active: '/images/icon/menu/calendar-active.svg', 
      inactive: '/images/icon/menu/calendar.svg' 
    },
  [PATH.TIMELINE]: { 
      active: '/images/icon/menu/timeline-active.svg',
      inactive: '/images/icon/menu/timeline.svg'
    },
  [PATH.REPORT]: { 
      active: '/images/icon/menu/report-active.svg',
      inactive: '/images/icon/menu/report.svg' 
    },
  [PATH.SETTING]: { 
      active: '/images/icon/menu/setting-active.svg', 
      inactive: '/images/icon/menu/setting.svg'
    },
};

 

 

 

5. 현재 페이지에 따라 아이콘의 주소를 반환하는 getMenuIcon 함수 생성 

4단계에서 만든 menuIcon 객체를 사용하기 위해 getMenuIcon 함수를 만들었다. MenuIcon 컴포넌트의 imageSrc에는 함수의 실행값을 넘겨주게 된다. pathname을 비교하기 위해 toUpperCase를 사용해 대문자로 만들어 주었다.

 

export default function Menu() {
  const location = useLocation();

  const getMenuIcon = (iconKey: 'CALENDAR' | 'TIMELINE' | 'REPORT' | 'SETTING') => {
    const pathName = location.pathname.toUpperCase();
    
    if (pathName === `/${iconKey}`) {
      return menuIcon[PATH[iconKey]].active;
    }
    return menuIcon[PATH[iconKey]].inactive;
  };

  return (
    <Container>
      <MenuItem 
      	imageSrc={getMenuIcon('CALENDAR')} 
        path={PATH.CALENDAR}
      />
      <MenuItem
      	imageSrc={getMenuIcon('TIMELINE')} 
        path={PATH.TIMELINE} 
      />
      <MenuItem	
      	imageSrc={getMenuIcon('REPORT')} 
        path={PATH.REPORT} 
      />
      <MenuItem
      	imageSrc={getMenuIcon('SETTING')} 
        path={PATH.SETTING} 
      />
      <FeelCatIcon>
        <img 
          src={feelCatIcon} 
          alt="cat-icon" 
          style={{ width: 58 }} 
        />
      </FeelCatIcon>
    </Container>
  );
}

 

 

 

6. 가독성 떨어지는 코드 변수로 분리

마지막으로 이미지 주소를 불러오는 과정에서 menuIcon[PATH[iconKey]]  코드의 대괄호가 중복되어 가독성이 떨어지므로 변수로 분리해 주었다. 

 

const getMenuIcon = (iconKey: 'CALENDAR' | 'TIMELINE' | 'REPORT' | 'SETTING') => {
    const menuIconKey = PATH[iconKey];
    const pathName = location.pathname.toUpperCase();

    if (pathName === `/${iconKey}`) {
      return menuIcon[menuIconKey].active;
    }
    return menuIcon[menuIconKey].inactive;
  };

 

 

 

마치며

반복되는 로직을 분리해서 코드를 깔끔하게 정리했다. 페이지 전환 외에 이미지 주소 처리하는 로직 하나 추가되었을 뿐인데 초반에는 약간 헤맸다. 처음부터 마지막 단계의 코드를 짜려고 욕심부려서 더 꼬이고 잘 되지 않았는데 마음을 비우고 차근차근 해나가는 연습이 더 필요할 것 같다. 완성되는 그날까지 화이팅!!!