[글마디 프로젝트] 반응형 UI 구현 회고

Minsu Han·2023년 5월 11일
0

Side Projects

목록 보기
7/10
post-thumbnail

시작하며

글마디 웹사이트를 데스크탑뿐만 아니라 태블릿, 모바일 환경에서도 쾌적하게 사용할 수 있도록 반응형 UI를 구현한 회고입니다.

구현할 것들

지금까지 글마디 프로젝트는 데스크탑 환경을 기준으로 UI를 설계하고 작업해왔습니다.

처음부터 css flexboxrem 단위를 적극적으로 활용하고 고정 크기를 지양하였기 때문에, 데스크탑 환경에서는 브라우저 가로길이를 약 800px까지 줄이더라도 보기에 나쁘지 않았습니다.

하지만 화면크기가 그보다 더 작아지면 일부 UI의 구조가 (당연히) 깨지기 시작합니다. 그래서 데스크탑 작업 결과물을 기반으로 태블릿이나 모바일 환경에서 어떻게 UI를 수정할지 계획하였습니다.

먼저 메인화면은 각각 아래와 같이 수정하기로 했습니다.

  • 태블릿
    • 전체 페이지의 좌우 padding을 데스크탑보다 작게
  • 스마트폰 :
    • 전체 페이지의 좌우 padding을 태블릿보다 작게(거의 없는듯이)
    • 인기 태그/작가 컨테이너와 글마디 목록 컨테이너를 가로가 아닌 세로로 나란히 배치
    • 글마디 좋아요 아이콘과 좋아요 개수 label을 글마디 오른쪽이 아닌 우측 하단에 작게 표시
    • 글마디 내부 padding을 작게 수정
    • 인기 태그/작가 탭은 각각 여닫을 수 있도록 수정 (JS 필요)
    • 글마디 목록 스크롤 시 카테고리 내비게이션 바가 화면 상단에 고정되도록 수정 (JS 필요)

로그인/회원가입, 글마디 업로드 시에 사용되는 모달 폼은 아래와 같이 수정하기로 했습니다.

  • 데스크탑 : 가로길이를 뷰포트의 30%로 지정
  • 랩탑 : 가로길이를 뷰포트의 40%로 지정
  • 태블릿, 스마트폰 : 모두 가로길이를 뷰포트의 70%로 지정

마지막으로 글마디 카드 화면은 아래와 같이 수정하기로 했습니다.

  • 태블릿, 스마트폰 : 화면 가로길이가 작아질수록 카드 내부의 frame은 유지하면서 적절하게 카드의 scale을 축소시킴

사전 작업 (@mixin)

반응형 UI의 분기점을 매번 미디어 쿼리문에 픽셀로 표기하기에는 불편하고 중복도 많아지므로 CSS에서 반복적으로 재사용할 스타일시트를 마치 함수처럼 관리할 수 있게 해주는 SCSS의 @mixin을 사용하기로 했습니다. 사용법은 이전 글에서 소개한 바 있습니다 (mixin 사용하기).

아래와 같이 데스크탑, 랩탑, 태블릿, 휴대폰을 키워드로 분기점을 지정했습니다. 저는 데스크탑 우선형으로 작업하였기 때문에 max-width 기준으로 분기점을 설정했습니다.

> _mixin.scss

// Break Point
$phone: 548px;
$tablet: 768px;
$laptop: 1020px;
$desktop: 1400px;

// Mixins
@mixin desktop {
  @media (max-width: #{$desktop}) {
    @content;
  }
}

@mixin laptop {
  @media (max-width: #{$laptop}) {
    @content;
  }
}

@mixin tablet {
  @media (max-width: #{$tablet}) {
    @content;
  }
}

@mixin phone {
  @media (max-width: #{$phone}) {
    @content;
  }
}

이제 반응형 UI 구현을 쉬운 것부터 시작해봅시다!

모달 폼

모든 모달 폼은 .form__modal 클래스명을 가집니다. 내부 요소들은 flexbox로 스타일되어 있기 때문에 그냥 width 값만 계획한 대로 변경되도록 작성하면 됐습니다.

> _forms.scss

- 데스크탑 : 가로길이를 뷰포트의 30%로 지정
- 랩탑 : 가로길이를 뷰포트의 40%로 지정
- 태블릿, 스마트폰 : 모두 가로길이를 뷰포트의 70%로 지정

.form__modal {
  display: flex;
  flex-direction: column;
  background-color: #fefefe;
  margin: auto;
  border: 1px solid #888;
  width: 30%;
  padding: 30px;
  border-radius: 8px;
  position: relative;

  @include laptop {
    width: 40%;
  }
  @include tablet {
    width: 70%;
  }
}

글마디 카드

글마디 카드의 경우 그냥 카드의 width, height를 수정하면 카드 내부의 아이콘, 텍스트 레이아웃이 같이 변경되기 때문에 전체 카드 자체의 scale값을 수정하여 확대/축소하는 방식으로 구현하기로 했습니다.

요소를 확대/축소하려면 transform: scale(상수) 값을 설정하면 됩니다. 그런데 원래 저는 분기점마다 뷰포트 크기의 n% 크기로 축소되도록 하고 싶었습니다. 예컨대 원래 카드 크기가 400px이고 현재 뷰포트 가로길이가 300px이라면 transform: scale(calc((1vw * (n/100)) / 400)) 이런 식으로 뷰포트 가로길이에 relative하게 scale하도록 하고 싶었는데요, scale 값으로는 상수만 들어갈 수 있기 때문에 calc의 계산 결과에는 단위가 포함돼서 저런 식으로 작성할 수가 없었습니다. 여러 번 구글링하면서 단위를 떼어내고 숫자만 리턴해주는 함수도 작성해봤지만 해결하지는 못했습니다(함수에 calc를 전달하면 못알아먹고 오류뱉음)..

그렇다고 scale을 포기할 수는 없기 때문에 결국 아래와 같이 분기점을 많이 나눠서 적당한 값으로 scale하도록 했습니다.

> _card.scss

- 태블릿, 스마트폰 : 화면 가로길이가 작아질수록 카드 내부의 frame은 유지하면서 적절하게 카드의 scale을 축소시킴

.card {
  -webkit-box-shadow: 20px 50px 100px rgba(0, 0, 0, 0.5);
  box-shadow: 20px 50px 100px rgba(0, 0, 0, 0.5);

  // 화면크기가 작아질수록 scale 축소 (내부 요소 비율 유지)
  @media (max-width: 600px) {
    transform: scale(0.95);
  }
  @media (max-width: 480px) {
    transform: scale(0.85);
  }
  @media (max-width: 440px) {
    transform: scale(0.8);
  }
  @media (max-width: 400px) {
    transform: scale(0.75);
  }
  @media (max-width: 350px) {
    transform: scale(0.7);
  }
}

메인 화면

우선 전체 페이지(.container)의 좌우 padding부터 간단하게 아래와 같이 수정했습니다.

> _base.scss

- 태블릿
  - 전체 페이지의 좌우 padding을 데스크탑보다 작게
- 스마트폰 : 
  - 전체 페이지의 좌우 padding을 태블릿보다 작게(거의 없는듯이)

.container {
  padding: 0 10rem;
  margin: 0 auto;
  // width: 120rem;

  @include laptop {
    padding: 0 5rem;
  }

  @include tablet {
    padding: 0 1rem;
  }
}

태블릿 이상의 화면 크기(가로 548px 이상)에서는 이제 추가적으로 수정할 부분이 없고, 나머지는 모바일 환경을 위한 수정 작업입니다.

우선 원래는 가로로 나란히 배치되었던 인기 태그/작가 컨테이너(.popular__container)와 글마디 목록 컨테이너(.collection__container)를 세로로 나란히 배치했습니다.

> _collection.scss

@include phone {
  // .collection__section = .popular__container + .collection__container
  .collection__section {
    display: flex;
    flex-direction: column;
  }
  
  // 데스크탑 환경에서 10rem이었던 하단 여백을 2rem으로 수정
  .popular__tags__container,
  .popular__authors__container {
    display: flex;
    flex-wrap: wrap;
    margin-bottom: 2rem;
  }
}

다음으로, 글마디 좋아요 아이콘과 좋아요 개수 label을 글마디 오른쪽에서 우측 하단으로 위치를 변경하고, 크기도 모바일에 맞게 줄였습니다. 또한 모바일에서는 글마디 텍스트가 표시될 영역이 좁기 때문에 글마디 내부 padding 값도 줄였습니다.

> _collection.scss

// 글마디 리스트 element
.blockquote__list__child {
  display: flex;
  padding: 10px 0 10px 0px;

  @include phone {
    flex-direction: column;
    padding: 3px 0;
	
    // 좋아요 아이콘 크기 축소
    & .material-icons {
      font-size: 1.6rem;
    }
  }
  
  // 좋아요 아이콘 + 개수 label 컨테이너
  &__like__area {
    align-items: center;
    text-align: center;
    margin: auto 15px;

    @include phone {
      display: flex;
      flex-direction: row;
      // 글마디 우측 하단 끝에 위치
      justify-content: flex-end;
      align-items: center;
      gap: 0.3rem;
      margin: 0;
    }
  }
}

// 좋아요 개수 label
.blockquote__like__num {
  font-size: 1.2rem;
  color: #c4c4c4;
  font-weight: 600;
  
  @include phone {
    font-size: 1.6rem;
    font-weight: normal;
  }

  &.favorite {
    color: #ff0000;
  }
}

// 글마디 내부 padding 축소
blockquote {
  // ...
  padding: 1.2em 30px 1.2em 75px;
  
  @include phone {
    padding: 1em 10px 1em 45px;
  }
}

여기까지 작업했더니 아래와 같은 결과를 확인할 수 있었습니다.

그런데 모바일에서는 인기 태그, 인기 작가/가수 컨테이너가 차지하는 자리가 커질수록(각각 20개까지만 표시하지만요) 메인화면에서 글마디 목록이 너무 아래쪽에 위치해서 첫 로딩 시에 글마디 목록이 보이지 않을 수도 있겠다는 생각이 들었습니다.

그래서 인기 태그, 인기 작가 탭은 각각을 유저가 버튼을 눌러 여닫을 수 있도록 했습니다. 이 작업은 버튼 클릭 이벤트를 감지하기 때문에 자바스크립트 코드도 필요합니다.

> index.html

<div class="popular__container">
  <div class="label__popular__tags">
    <span>
      인기 태그
    </span>
    <!-- 확장 아이콘 -->
    <span class="material-icons-outlined expand">
      expand_more
    </span>
  </div>
  <!-- 환경에 따라 숨기거나 표시하기 위한 보조 컨테이너 -->
  <div class="container__hide__responsive">
    <!-- 태그 목록 컨테이너 -->
   	<div class="popular__tags__container">
  </div>
  
  <div class="label__popular__authors">
    <span>
      인기 작가 &#183; 가수
    </span>
    <!-- 확장 아이콘 -->
    <span class="material-icons-outlined expand">
      expand_more
    </span>
  </div>
  <!-- 환경에 따라 숨기거나 표시하기 위한 보조 컨테이너 -->
  <div class="container__hide__responsive">
    <!-- 태그 목록 컨테이너 -->
    <div class="popular__authors__container">
  </div>
</div>
> _collection.scss

// 모바일 화면에서 태그 목록을 유저가 열기 전에는 가려놓음
.container__hide__responsive {
  @include phone {
    display: none;
  }
}
> _popularView.js

#container = document.querySelector('.popular__container');
#tags_container = document.querySelector('.popular__tags__container');
#authors_container = document.querySelector('.popular__authors__container');

#tags_open = false;
#authors_open = false;
  
  /**
   * @description 모바일 환경에서 인기 탭 여닫기
   */
  #addExpandListener() {
    this.#container.addEventListener('click', e => {
      if (e.target.classList.contains('expand')) {
        let type = e.target.closest('.label__popular__tags')
          ? 'tags'
          : 'authors';

        // 여닫기 아이콘
        const expand_icon = document
          .querySelector(`.label__popular__${type}`)
          .querySelector('.expand');
        
        // 보조 컨테이너
        const hider =
          type === 'tags'
            ? this.#tags_container.closest('.container__hide__responsive')
            : this.#authors_container.closest('.container__hide__responsive');

        // 각 탭별로 opened 여부에 따라 목록 표시/숨기기
        let state = type === 'tags' ? this.#tags_open : this.#authors_open;

        if (!state) {
          // 닫혀 있는 경우 가려졌던 보조 컨테이너를 표시
          hider.style.cssText = `
              display: block;
              animation: fadeInDown 0.5s;
              `;
          expand_icon.style.cssText = `
              transform: rotate(180deg);
              transition: all 0.5s;
            `;
        } else {
          // 열려 있는 경우 보조 컨테이너를 다시 가림
          hider.style.cssText = '';
          expand_icon.style.cssText = `
              transition: all 0.5s;
            `;
        }
	
        // 상태 변경
        if (type === 'tags') this.#tags_open = !state;
        else this.#authors_open = !state;
      }
    });
  }

잘 작동하네요!

이제 마지막 작업만 남았습니다. 마지막 작업은 글마디 목록을 아래로 스크롤할 때 카테고리 내비게이션 바 (최신/인기/내 글마디/좋아요)가 화면 상단에 고정되도록 수정하는 것입니다. sticky navigation이라고 부르죠. 목록이 길어질수록 다른 카테고리를 탐색하고 싶을 때 한참을 다시 위로 스크롤해야 하는 불편함을 없게 하기 위함입니다.

이 기능을 구현하기 위해 IntersectionObserver API를 활용하기로 했습니다. 이것을 사용하면 특정 요소가 뷰포트에 threshold 비율만큼 겹쳐 있는지 여부를 관찰할 수 있습니다.

그러면 어떤 요소를 관찰하느냐, 유저가 아래로 스크롤하다보면 아래와 같이 카테고리 바가 최상단에 위치하는 순간이 옵니다.

이 순간 뷰포트에서 사라지는 요소가 하나 있는데 바로 인기 태그, 인기 작가 목록을 표시하는 .popular__container 입니다 (하단 이미지 참고).

바로 저 컨테이너가 뷰포트에서 사라지거나 나타나는 순간을 관찰해서, 사라지면(not intersecting) 카테고리 바를 뷰포트 상단에 고정(position: fixed)하고, 다시 스크롤을 올려서 컨테이너가 화면에 보이면(intersecting) 카테고리 바의 고정을 해제하면 제가 원했던 기능을 구현할 수 있겠다고 생각했습니다.

카테고리 바를 화면 상단에 고정하기 위해 sticky라는 클래스에 아래 스타일을 지정했습니다.

> _collection.scss

.sticky {
  @include phone {
    background-color: #fff;
    // 상단에 고정
    position: fixed;
    top: 0px;
    // bar 하단에 약간의 shadow 추가
    -webkit-box-shadow: 0px 5px 10px -10px #222;
    box-shadow: 0px 5px 10px -10px #222;
    // 다른 요소보다 위에 있어야 하므로 우선순위 설정
    z-index: 3;
  }
}

그리고 .popular__container에 IntersectionObserver를 등록해서 isIntersecting === true 인 경우 카테고리 바의 classList에 sticky를 추가하고, false인 경우 sticky를 제거하도록 구현했습니다.

카테고리 바가 상단에 고정되면 원래 카테고리 바가 차지했던 공간은 사라지기 때문에, sticky 클래스를 추가하면서 카테고리 바가 차지하던 height만큼 글마디 목록 컨테이너를 아래로 내려야 자연스럽게 동작합니다.

> postListView.js

  /**
   * @description IntersectionObserver 콜백함수
   * @param { object } entries
   */
  #stickyFilter(entries) {
    // 목록 컨테이너 가로길이
    const postContainerWidth = this.#container.getBoundingClientRect().width;
    // 필터 컨테이너(카테고리 바) 세로길이
    const filterHeight = this.#filters_container.getBoundingClientRect().height;

    // Entry object
    const [entry] = entries;

    // 뷰포트에서 popular_container 요소가 사라지기 시작하는 시점에 필터를 고정
    if (!entry.isIntersecting) {
      this.#filters_container.classList.add('sticky');
      // flex 컨테이너 안에 있던 필터 컨테이너가 독립하면서 stretch 적용이 풀리기 때문에 현재 글마디 목록 컨테이너 가로길이만큼 width 지정
      this.#filters_container.style.width = `${postContainerWidth}px`;
      // 필터 컨테이너가 차지하던 height만큼 목록 컨테이너를 아래로 내려야 자연스러움
      this.#container.style.marginTop = `${filterHeight}px`;
    }
    // 뷰포트에서 popular_container 요소가 다시 나타나기 시작하는 시점에 필터 고정 해제
    else {
      this.#filters_container.classList.remove('sticky');
      this.#container.style.marginTop = `0px`;
    }
  }

  /**
   * @description 모바일에서 sticky filter를 구현하기 위한 popular_container IntersectionObserver 등록
   * @param { object } entries
   */
  #addPopCotnainerObserver() {
    const popContainerObserver = new IntersectionObserver(
      this.#stickyFilter.bind(this),
      {
        root: null, // 전체 뷰포트
        threshold: 0,
      }
    );

    // popular_container를 observe
    const popular_container = document.querySelector('.popular__container');
    popContainerObserver.observe(popular_container);
  }

결과를 확인해보면,

계획했던대로 동작하는 것을 확인할 수 있습니다. 최종 DEMO 영상에서는 더 부드럽게 동작하는 걸 보실 수 있습니다.


DEMO

지금까지 구현한 반응형 UI를 아이폰 Safari 브라우저에서 확인하는 영상입니다.


후기

처음에는 데스크탑 기준으로도 힘들게 작업했는데 모바일 대응에 도전해보려 하니 다소 막막했습니다.
하지만 미디어쿼리의 핵심적인 개념들을 파악하고 작은 것들부터 하나씩 반응형으로 만들어 보니, UI 및 컴포넌트 설계 시에 신경을 쓰고 공을 들이면, 생각했던 만큼 많은 것을 고치지 않고도 모바일에서 보기 좋은 페이지를 만들 수 있다는 것을 깨달았습니다 (+ flexbox와 rem은 신이다..). 실제로 미디어 쿼리문으로 작성한 내용은 레이아웃과 여백 크기 정도만을 변경하는 데 쓰였습니다.

반응형 UI 구현에 많은 도움이 되었던 블로그 글을 소개하면서 마무리하겠습니다.

profile
기록하기

0개의 댓글