[회고] Vanilla Javascript로 슬라이더 만들기

hejuby·2024년 2월 29일
0

배경

프론트엔드 부트캠프의 3주 차에 주어진 첫 과제는 클론 코딩이었습니다. 자기가 고른 사이트를 HTML, CSS, Javascript를 이용해서 클론 코딩하는 건데요.
제가 선택한 사이트는 Gitlab이었습니다.

이 사이트에서 대부분은 순수 HTML과 CSS로 구현을 할 수 있었지만 제가 맞닥뜨린 가장 어려운 부분 중 하나는 히어로 섹션에 있던 거대한 슬라이더였습니다.

Gitlab Hero

about.gitlab.com의 히어로 슬라이더

이 슬라이더는 강의 시간에 배웠던 Swiper.js와는 작동 방식이 조금 달랐습니다.

  1. 드래그 시 슬라이드 단위 스크롤이 아닌 슬라이더 전체 스크롤(Swiper의 Free Mode)
  2. 마우스 휠을 사용해서 스크롤 가능
  3. 스크롤 진행도를 내비게이션 바를 이용해 표시
  4. Prev, Next 버튼을 누를 시 슬라이드 단위가 아닌 슬라이더 전체의 50%만큼 이동

이 정도가 제가 알게 된 Swiper.js와 Gitlab 사이트에 있는 슬라이더의 차이점이었습니다.
저는 외부 라이브러리에 대한 지식이 별로 없었기 때문인데 다른 슬라이더 관련 라이브러리는 알지 못했는데요. 그래서 저에게 주어진 선택지는 Swiper.js를 그대로 쓰느냐, 또는 직접 자바스크립트를 이용해 구현을 하느냐, 두 가지였습니다.

Swiper.js 분석

처음에는 슬라이더를 직접 구현하는 데에 얼마나 걸릴지 예상이 안됐었기 때문에 Swiper.js의 코드를 뜯어봐서 제가 원하는 부분을 만들기로 했습니다.

Swiper Free Mode

Swiper.js의 Free Mode

우선 Free Mode로 자유로운 슬라이드 드래그를 적용해 줬습니다.
그다음은 슬라이더 전체에 비례해서 스크롤 진행도가 표시되는 내비게이션 바를 구현하기로 했습니다.

  function Scrollbar(_ref) {
    let {
      swiper,
      extendParams,
      on,
      emit
    } = _ref;
    const document = getDocument();
    let isTouched = false;
    let timeout = null;
    let dragTimeout = null;
    let dragStartPos;
    let dragSize;
    let trackSize;
    let divider;

Swiper의 Scrollbar 모듈입니다.

if (swiper.isHorizontal()) {
  dragEl.style.transform = `scaleX(${newPos*0.00432})`;
  dragEl.style.width = `335px`;
}
if (swiper.isHorizontal()) {
  dragEl.style.width = `335px`;
}

이 중에서 스크롤바의 width를 할당하는 로직에 값을 하드 코딩해 주고 transformscaleX로 변경해 주었습니다.
이후로 슬라이더의 50%를 이동하는 버튼을 구현하려고 했지만, Swiper의 Navigation 모듈은 swiper 객체에서 인수를 받아오기 때문에 난이도가 있었습니다. 충분히 시간이 있었다면 찾아볼 수도 있었겠지만, 과제 기한까지 며칠 남지 않은 상황에서 가장 상호작용이 많은 Swiper 클래스를 뜯어보는 건 무리라고 생각했어요.
따라서 저는 직접 자바스크립트로 간단한 슬라이더를 구현하기로 했습니다.

웹 이벤트를 이용한 슬라이더 만들기

이벤트 관련 지식이 적었기 때문에 유튜브 강의를 참고했습니다.
Horizontal Scroll Slider
Create Touch Slider From Scrollbar

스크롤에 비례한 스크롤바 구현

let target = 0;
let current = 0;
let ease = 0.0825;

const slider = document.querySelector('.swiper');
const sliderWrapper = document.querySelector('.swiper-wrapper');

let maxScroll = sliderWrapper.offsetWidth - window.innerWidth + 96 * 2;

const lerp = (start, end, factor) => {
  return start + (end - start) * factor;
}

const update = () => {
  current = lerp(current, target, ease);

  let moveRatio = current / maxScroll;

  gsap.set('.swiper-scrollbar', {
    scaleX: moveRatio,
  });

  requestAnimationFrame(update);
};

slider.addEventListener('wheel', e => {
  if (Math.abs(e.deltaX) > 10) target += e.deltaX;

  target = Math.max(0, target);
  target = Math.min(target, maxScroll);
});

update();

우선 요소 자체의 overflow를 사용한 슬라이더를 만들 생각이었기 때문에 첫 번째 영상의 코드에서 휠 스크롤 비율을 계산하는 부분만 남겼습니다.

gsap.set('.swiper-scrollbar', {
  scaleX: moveRatio,
});

그 후엔 마찬가지로 GSAP에서 transform을 적용하는 부분을 scaleX로 수정해 주었습니다.

let ease = 0.0825;

또한 보간 함수의 ease 값을 살짝 조정하였습니다.

슬라이더 드래그 구현

let pressed = false;
let mouseX = 0;

const prevBtn = document.querySelector('.swiper-button-prev');
const nextBtn = document.querySelector('.swiper-button-next');

slider.addEventListener('mousedown', e => {
  pressed = true;
  mouseX = e.clientX;
  slider.style.cursor = 'grabbing';
});

slider.addEventListener('mouseleave', () => {
  pressed = false;
});

slider.addEventListener('mouseup', () => {
  pressed = false;
  slider.style.cursor = 'grab';
});

slider.addEventListener('mouseenter', () => {
  slider.style.cursor = 'grab';
});

slider.addEventListener('mousemove', e => {
  if (pressed) {
    slider.scrollLeft += mouseX - e.clientX;
    target += mouseX - e.clientX;

    target = Math.max(0, target);
    target = Math.min(maxScroll, target);

    mouseX = e.clientX;
  }
});

이 부분은 두 번째 영상의 코드를 활용해서 구현하였습니다. 마우스가 움직인만큼의 값을target에도 더해주어서 위에서 GSAP을 통해 scaleX를 걸어주는 코드에 적용될 수 있도록 했습니다.

내비게이션 버튼 구현

prevBtn.addEventListener('click', () => {
  slider.scrollLeft -= maxScroll/2;
  target -= maxScroll/2;

  target = Math.max(0, target);
});

nextBtn.addEventListener('click', () => {
  slider.scrollLeft += maxScroll/2;
  target += maxScroll/2;

  target = Math.min(target, maxScroll);
});

내비게이션 버튼은 단순히 전체 슬라이더의 50%만큼 스크롤과 target을 변화시키는 식으로 작성하였습니다.

또한 target의 값이 0이라면 원본 사이트처럼 Prev 버튼을 비활성화 시켜주었습니다.

if (target) prevBtn.classList.remove('disabled');
else prevBtn.classList.add('disabled');

부드러운 스크롤 이동

내비게이션 버튼 이동 시에 화면이 갑자기 바뀌는 걸 방지하기 위해 슬라이더에 scroll-behavior: smooth 옵션을 주었는데 드래그 시 예상과 다르게 동작하는 문제가 생겼습니다.

아마 정량적으로 scrollLeft를 변화시키는 로직이 mousemove 이벤트를 통해 밀리세컨드 단위로 실행되면서 CSS의 scroll-behavior: smooth와 충돌해서 문제가 생기는 것 같았는데요.

그래서 처음엔 smooth를 포기하고 setInterval을 사용한 스크롤 이동을 시도해 보았습니다.

const scrollInterval = (step, direction, speed, threshold) => {
  let cnt = 0;
  const giveInterval = setInterval(() => {
    slideAction(step, direction);
    cnt += step;

    if (cnt > threshold) window.clearInterval(giveInterval);
  }, speed);
};

하지만 스크롤 움직임을 ease하게 구현하기 어려운 점, 메모리 상황에 따라서 setInterval의 동작 퍼포먼스가 달라지는 점, 그리고 CSS에 이미 있는 걸 JS로 구현할 필요 없다는 우선순위의 문제까지 겹쳐 다시 smooth를 사용한 스크롤을 사용하기로 했습니다.

대신, 내비게이션 버튼을 눌렀을 때만 한시적으로 적용해 사이드 이펙트를 방지했습니다.

prevBtn.addEventListener('click', () => {
  slider.style.scrollBehavior = 'smooth';
  slider.scrollLeft -= maxScroll/2;
  target -= maxScroll/2;

  target = Math.max(0, target);

  slider.style.scrollBehavior = 'auto';
});

nextBtn.addEventListener('click', () => {
  slider.style.scrollBehavior = 'smooth';
  slider.scrollLeft += maxScroll/2;
  target += maxScroll/2;

  target = Math.min(target, maxScroll);

  slider.style.scrollBehavior = 'auto';
});

드래그 사이드 이펙트 방지

기존 라이브러리가 아닌 직접 함수를 작성했기 때문에 드래그 스크롤 시에 발생하는 사이드 이펙트들이 생겼습니다. 크게 두 가지로,

  1. 스크롤이 이동하는 게 아닌 HTML 요소가 드래그되는 현상
  2. a 태그 위에서 드래그를 시작했을 때 마우스를 떼면 클릭으로 인식해 링크로 이동하는 현상

이 있었는데요.

Baekjoon Online Judge

img 요소 드래그 (acmicpc.net)

문제를 해결하기 위해 우선 슬라이더 안의 요소들의 draggable 속성에 일괄적으로 false를 부여했습니다.

const draggables = sliderWrapper.querySelectorAll('img, svg, p, a, span, h1, h2, h3, h4, h5, h6, .text');

draggables.forEach((draggable) => {
  draggable.setAttribute('draggable', 'false');
});

또한 a 태그 문제는 pointer-events 속성을 활용했습니다. 마우스를 움직일 때만 none으로 처리해 주고, 마우스를 뗄 때는 다시 원래대로 동작하도록 처리했습니다.

const heroCards = sliderWrapper.querySelectorAll('a.hero-card');

slider.addEventListener('mouseup', () => {
  heroCards.forEach((card) => {
    card.style.pointerEvents = 'auto';
  });
});

slider.addEventListener('mousemove', e => {
  if (pressed) {
    heroCards.forEach((card) => {
      card.style.pointerEvents = 'none';
    });
  }
});

마무리

매우 간단한 기능이지만 외부 라이브러리의 도움을 받지 않고 직접 구현해 본 경험이 많은 도움이 된 것 같습니다. 그리고 다른 사람의 코드를 내가 구현하고자 하는 기능에 맞게 변형하는 과정에서 핵심이 된 웹 이벤트들에 대한 이해가 더 늘어난 것 같았습니다.

외부 라이브러리를 사용하는 것이 나을까, 내가 직접 구현하는 것이 나을까?
아마도 제가 앞으로 개발을 하면서 상황마다 해 볼 고민이라고 생각하는데요. 저 같은 신입의 입장에서는 빠르게 다른 라이브러리를 배우고 도입할 수 있는 능력, 기능을 구현하기 위한 원리를 파악하고 때로는 직접 구현할 수 있는 능력. 양쪽 모두가 중요하다는 것을 느낄 수 있었습니다.

프로젝트 링크

0개의 댓글