Stardew Dressup 개발일지(4)

Lybell·2022년 8월 27일
0
post-custom-banner

개발 상황

현재 Stardew Dressup 1.1버전을 개발하고 있다. 주된 변경 내역은 디자인 수정으로, 픽셀스럽고 다듬어진 디자인으로 변경하면서, 겸사겸사 성능을 향상시키는 것이 주 목적이다.

개발 예정 목록은 다음과 같다.

  • 랜더마이징 기능 추가
  • 버그 수정
    • 모바일에서 한글 폰트가 굵게 나오는 버그 수정
    • 모바일에서 하단이 짤려 나오는 버그 수정
  • 사용성 개선
    • 의상 선택 창 퍼포먼스 개선
    • 리사이징 퍼포먼스 개선
    • 모바일에서 스와이프를 할 때 하단이 스와이프가 안 되는 문제 수정
    • 의상 선택 창 동기화 퍼포먼스 개선
  • 디자인 변경
  • 일부 코드 리팩토링

문제 상황

Swiper.js의 페이지네이션 태그 분리

Stardew Dressup은 스와이프 기능을 구현하기 위해 Swiper.js 라이브러리를 사용하고 있다.(리액트, 앵귤러, 뷰 등 다른 SPA 라이브러리/프레임워크용 버전을 지원한다) 리액트의 Swiper.js(swiper-react)는 기본적으로 스와이퍼 컴포넌트를 생성할 때 pagination 엘리먼트를 같이 생성하는데, 이것을 다른 태그에 소속되도록 분리할 수 있다.

import {useState, useRef, useEffect} from "react";
import {Pagination} from "swiper";
import {Swiper} from "swiper/react";

// from https://github.com/nolimits4web/swiper/issues/3855
function UseSwiperRef()
{
  const [el, setEl] = useState(null);
  const elRef = useRef(null);
  useEffect( ()=>{
    selEl(elRef.current);
  }, []);
  return [el, elRef];
}
function MySwiper()
{
  const [el, elRef] = UseSwiperRef();
  return <>
    <Swiper
      pagination={el:el}
      module={[Pagination]}
    >
        <SwiperSlide>123</SwiperSlide>
    </Swiper>
  	<div className="mySwiperPagination" ref={elRef} />
  </>
}

참고 : UseSwiperRef를 사용한 이유는 Swiper 컴포넌트 이후에 존재하는 컴포넌트에 ref를 부착하면 Swiper 컴포넌트가 평가될 때 ref는 아직 아무 컴포넌트에도 부착되지 않은 상태이므로 {current:null}로 평가된다. 따라서 useState와 useEffect를 사용하여 컴포넌트가 생성 완료된 후 ref.current를 state로 넣어서 리렌더링을 시도하도록 하였다.

Swiper.js braekpoints

Swiper.js에서는 breakpoints라는 기능을 제공한다. breakpoints는 window나 현재 스와이퍼 컨테이너의 가로 길이나 해상도 비율에 맞춰 스와이퍼가 다르게 동작하도록 하는 기능이다. 숫자를 키값으로 적으면 가로 길이 기준으로, @로 시작하는 문자를 키값으로 적으면 해상도 기준으로 동작한다.
리액트에서 Swiper.js의 breakpoints를 사용하는 법은 다음과 같다.


function MySwiper()
{
  return <>
    <Swiper
  	  slidesPerView:{2},
      spaceBetween:{10},
      breakpoints:{
        // when window width is >= 768px
        768: {
          slidesPerView: 3,
          spaceBetween: 20
        },
        // when window width is >= 1366px
        1366: {
          slidesPerView: 5,
          spaceBetween: 30
        }
      }
    >
        <SwiperSlide>123</SwiperSlide>
    </Swiper>
  </>
}

위의 코드는 기본적으로 2개의 보이는 슬라이드와 10px의 갭으로 스와이퍼가 동작하지만, 창 크기가 768px 이상일 때는 3개의 보이는 슬라이드와 20px의 갭으로, 1366px 이상일 때는 5개의 보이는 슬라이드와 30px의 갭으로 동작한다.

breakpoints 버그

문제는 위와 같이 ref를 이용해서 pagination을 다른 태그로 분리하고, breakpoints를 사용했을 때, 창이 가장 작은 breakpoints 미만으로 줄어들었을 때(위의 예시에서는 768px 이상이었다가 768px 미만으로 줄어드는 상황) pagination.el이 null로 초기화되어버리는 버그가 일어난다는 것이다.

나는 1366px 이상일 때는 스와이퍼가 세로로, 1366px 미만일 때는 스와이퍼가 가로로 동작하도록 만들고, 1366px 미만일 때 페이지네이션의 현재 페이지가 하이라이트되도록 만들었는데, 1366px 이상이었다가 창이 1366px 미만으로 줄어들었을 때 페이지네이션의 하이라이트가 동작하지 않았던 것이다. 디버깅을 해 보니 swiper.params.pagination.el이 null로 변경되어 있어서 pagination의 update가 아예 동작하지 않았다.

function isPaginationDisabled() {
  return !swiper.params.pagination.el || !swiper.pagination.el || !swiper.pagination.$el || swiper.pagination.$el.length === 0;
}

function update() {
  // Render || Update Pagination bullets/items
  const rtl = swiper.rtl;
  const params = swiper.params.pagination;
  if (isPaginationDisabled()) return;
  //...
}

(Swiper.js 라이브러리의 modules/pagination 코드의 일부.)

Swiper.js의 breakpoints 동작원리

setBreakPoint

Swiper.js는 화면 크기가 변경되면, breakpoints를 param으로 갖고 있는 Swiper 객체에 대해, setBreakPoint라는 함수를 호출한다. setBreakPoint는 현재 스와이퍼 객체에 대해 해당하는 breakpoint를 구하고, 해당 breakpoints에 대해 현재 파라미터와 해당하는 breakpoints의 파라미터를 덮어씌운다.

function setBreakpoint() {
  const swiper = this;
  const {
    activeIndex,
    initialized,
    loopedSlides = 0,
    params,
    $el
  } = swiper;
  const breakpoints = params.breakpoints;
  if (!breakpoints || breakpoints && Object.keys(breakpoints).length === 0) return; // Get breakpoint for window width and update parameters

  const breakpoint = swiper.getBreakpoint(breakpoints, swiper.params.breakpointsBase, swiper.el);
  if (!breakpoint || swiper.currentBreakpoint === breakpoint) return;
  const breakpointOnlyParams = breakpoint in breakpoints ? breakpoints[breakpoint] : undefined;
  const breakpointParams = breakpointOnlyParams || swiper.originalParams;
  //중략
  extend(swiper.params, breakpointParams);
  //후략
}

(Swiper.js 라이브러리의 core/breakpoints/setBreakPoint.js 코드의 일부.)
여기서 extend 함수는 {...swiper.params, ...breakpointParams}의 깊은 복사 버전이라고 생각하면 되겠다.

getBreakPoint

현재 스와이퍼 객체의 breakpoint가 무엇인지를 구하는 함수다. getBreakPoint의 알고리즘을 간단하게 말로 풀어서 설명하면 다음과 같다.

  1. 아래 내용은 base가 window 기반일 때를 기준으로 한다.
  2. Swiper 객체가 현재 갖고 있는 breakpoints 객체의 키값에 대해 매핑을 하여 points 변수에 저장한다.
    • 각 breakpoint가 @로 시작하는 문자열일 경우, height * 비율을 breakpoint width로 정한다.
    • 그렇지 않을 경우 자기 자신을 breakpoint width로 정한다.
  3. points 배열을 작은 순으로 정렬한다.
  4. points 배열을 순회하여 각 원소에 대해 현재 window.width가 breakpoint width보다 크면 해당 key를 breakpoint로 정한다. 즉, 현재 window.width보다 작은 breakpoint width들 중 가장 큰 것이 결과값이 된다.
  5. 아무 조건도 일치하지 않는 경우 결과값은 "Max"가 된다.

예를 들어 breakpoints의 키값이 [768, 1366]이라고 하자. 창 크기가 1920일 때, 첫 번째 루프에서 768이 조건에 맞고, 두 번째 루프에서 1366이 조건에 맞으므로 결과값은 1366이 된다. 반면 창 크기가 320일 경우는 첫 번째 루프에서 768이 조건에 맞지 않고, 두 번째 루프에서 1366이 조건에 맞지 않으므로 결과값은 "Max"가 된다.

Breakpoint가 현재 창 너비보다 작은 지점 중 가장 큰 것, 즉 창 너비가 소속된 범위를 의미하므로 창 너비가 어떤 breakpoint보다도 작으면 결과값이 Min이 되어야 하는데 Max이다. 왜일까

버그 원인 해결

breakpoints가 제일 작은 쪽으로 화면이 줄어들었을 때는 다음의 현상이 일어난다.

  1. 화면 크기가 변경되면 setBreakPoint 함수가 호출된다.
  2. setBreakPoint 함수에서 getBreakPoint 함수를 호출한다.
  3. getBreakPoint 함수는 현재 window의 너비가 어떠한 breakpoints보다도 작으므로 "Max"를 반환한다.
  4. breakpoints 객체에서 getBreakPoint의 리턴값이 key값인 value를 찾는다.(this.breakpoints[this.getBreakPoint()]와 같다.)
  5. 해당 키값이 breakpoints 파라미터에 존재하면 이를 breakpointParams으로 삼는다. 존재하지 않으면 originalParams 파라미터를 breakpointParams으로 삼는다.
  6. 스와이퍼의 현재 params에 breakpointParams를 덮어씌운다.

즉, getBreakPoint가 Max를 반환하는 상황(화면의 너비보다 작은 breakpoints가 없음)에서 breakpoints 객체에 Max라는 키값이 존재하지 않으니, Swiper 객체를 처음 초기화했을 때의 파라미터로 덮어씌우는 상황이라고 할 수 있다.

여기서 문제는 Swiper의 페이지네이션을 분리하기 위해 pagination.el에 state를 할당하는 과정에서 나온다. 맨 처음 el state는 null이기 때문에 originalParams.pagination.el이 null이 되어버린 것이다.

해당 문제는 breakpoints 파라미터 객체에 Max 프로퍼티를 추가하여, breakpoints로 변경되는 부분만 초기화하면 해결된다.

function MySwiper()
{
  const [el, elRef] = UseSwiperRef();
  return <>
    <Swiper
  	  slidesPerView:{2}
      spaceBetween:{10}
      breakpoints:{
        // when window width is >= 768px
        768: {
          slidesPerView: 3,
          spaceBetween: 20
        },
        // when window width is >= 1366px
        1366: {
          slidesPerView: 5,
          spaceBetween: 30
        }
        // default(when window width is < 768px)
        Max: {
          slidesPerView: 2,
          spaceBetween: 10
        }
      }
  	  pagination={el}
      module={[Pagination]}
    >
        <SwiperSlide>123</SwiperSlide>
    </Swiper>
  	<div className="mySwiperPagination" ref={elRef} />
  </>
}

이렇게 하면 pagination.el 파라미터가 null로 초기화되지 않는다!

profile
홍익인간이 되고 싶은 꿈꾸는 방랑자
post-custom-banner

0개의 댓글