[JavaScript] VanilaJS로 무한 캐러셀 슬라이드 만들기

장유진·2023년 3월 27일
0

Implementation

목록 보기
2/4

캐러셀 슬라이드는 가로, 혹은 세로 축으로 회전하며 일련의 이미지를 보여주는 이미지 갤러리의 일종으로 특히 다양한 이미지들을 사용자에게 시각적으로 매력적으로 보여줄 필요가 있을 때 사용됩니다. 일반적으로 온라인 쇼핑몰 사이트에서 제품 이미지를 보여주는 메인 배너에 많이 사용되고, 그 외에도 개인 포트폴리오 또는 갤러리 사이트에서 작품이나 이미지 모음을 전시할 때 사용될 수 있습니다.

1. 목표

외부 라이브러리를 사용하지 않고 캐러셀 슬라이드를 구현합니다. 이 글에서는 바닐라 자바스크립트를 사용하여 무한 캐러셀 슬라이드를 만드는 방법을 설명합니다. 다음은 이 슬라이드에서 구현하고자 하는 목표입니다.

  • 접근성 : aria-hidden 및 aria-label 속성등을 사용하여 스크린 리더에 적절한 정보를 제공
  • 자동 재생 : 슬라이더가 3초마다 자동 재생
  • 무한 루프 : 슬라이더가 마지막 슬라이드에 도달하면 첫 번째 슬라이드로 이동
  • 일시 정지 : 사용자가 슬라이더 위로 마우스를 가져가면 슬라이드의 자동 재생이 일시 정지되고, 슬라이더 밖으로 마우스를 내보내면 자동재생이 다시 시작됩니다.
  • 이전 및 다음 버튼을 이용한 이동 기능
  • 성능 최적화 : 스로틀(Thottle) 함수를 사용, 빠른 클릭이 슬라이더의 성능에 영향을 미치지 않도록 이전/다음 버튼 클릭 이벤트 핸들러의 실행을 지연

2. 구현 미리보기

3. 구현하기

HTML MarkUP & ARIA Attributes

캐러셀 슬라이드를 구현하기 위해서는 먼저 뼈대가 되는 HTML을 작성해야 합니다.

Slider Container

<div class="slider__wrapper" aria-live="polite">
  <h1 class="sr-only">Infinite Carousel Slide</h1>
  ...
</div>
  • 모든 구성 요소를 감싸는 최상위 컨테이너에 aria-live 속성을 "polite"로 설정합니다.
    이 속성을 사용하면 슬라이더 내부의 콘텐츠가 동적으로 변경될 수 있고, 변경 사항이 있을 경우 사용자에게 변경 사항을 알려야 한다는 것을 스크린 리더에게 알려줄 수 있습니다.
  • 슬라이더에 대한 레이블을 제공하도록 제목 요소를 포함하고, 스크린리더 전용 클래스 sr-only 를 사용해 화면상에서는 보이지 않도록 감춰줍니다.

Slider

<div class="slider">
  <div class="slide" data-index="1" aria-labelledby="slide1">
    <h2 class="slide__title" id="slide1">
      Today-I-Learned Carousel Slide1
    </h2>
  </div>
  ...
</div>
  • 현재 어떤 슬라이드가 보여지는지 알기 위해 각 슬라이드에 커스텀 속성 data-index를 설정합니다.
  • aria-labelldeby속성과 슬라이드의 라벨 역할을 하는 제목 태그의 id 속성을 일치시키도록 합니다. 이렇게 하면 슬라이드의 제목을 스크린 리더의 사용자에게 명시할 수 있습니다.

Slider Control

<div class="slider-control">
  <button id="prevBtn" class="control__button prev" aria-label="Previous slide button"></button>
  <button id="nextBtn" class="control__button next" aria-label="Next slide button"></button>
  ...
</div>
  • aria-label 속성을 사용하여 이전 및 다음 버튼이 어떤 역할을 하고 있는지 스크린 리더 사용자에게 알려 줄 수 있습니다.

Slider Indicator

<p class="slide-count" aria-describedby="slide-description">
  <span class="current-slide"></span>
  / 
  <span class="all-slide"></span>
</p>
<p id="slide-description" class="sr-only">
  Use the previous and next buttons to navigate between the slides.
</p>
  • 현재 슬라이더와 총 슬라이드 수를 표시하는 슬라이더 인디케이터를 제공합니다.
  • aria-describedby속성을 사용해 이전 및 다음 버튼을 사용하여 슬라이드 사용법에 대해 설명합니다.

HTML 전체 코드

<div class="slider__wrapper" aria-live="polite">
      <h1 class="sr-only">Infinite Carousel Slide</h1>

      <div class="slider">
        <div class="slide" data-index="1" aria-labelledby="slide1">
          <h2 class="slide__title" id="slide1">
            Today-I-Learned Carousel Slide1
          </h2>
        </div>

        <div class="slide" data-index="2" aria-labelledby="slide2">
          <h2 class="slide__title" id="slide2">
            Today-I-Learned Carousel Slide2
          </h2>
        </div>

        <div class="slide" data-index="3" aria-labelledby="slide3">
          <h2 class="slide__title" id="slide3">
            Today-I-Learned Carousel Slide3
          </h2>
        </div>
      </div>

      <div class="slider-control">
        <button
          id="prevBtn"
          class="control__button prev"
          aria-label="Previous slide button"
        ></button>
        <button
          id="nextBtn"
          class="control__button next"
          aria-label="Next slide button"
        ></button>

        <p class="slide-count" aria-describedby="slide-description">
          <span class="current-slide"></span>
          &nbsp;/&nbsp;
          <span class="all-slide"></span>
        </p>

        <p id="slide-description" class="sr-only">
          Use the previous and next buttons to navigate between the slides.
        </p>
      </div>
    </div>

CSS Styling

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  border: 0;
  clip: rect(0, 0, 0, 0);
  overflow: hidden;
}

.slider__wrapper {
  position: relative;
  width: 630px;
  height: 760px;
  margin: 120px auto;
  overflow: hidden;
}

.slider {
  position: relative;
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  height: 100%;
}

.slide {
  width: 630px;
  height: 760px;
  -ms-flex-negative: 0;
  flex-shrink: 0;
}
.slider-control {
  position: absolute;
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  -webkit-box-align: center;
  -ms-flex-align: center;
  align-items: center;
  bottom: 115px;
  left: 120px;
}
.slider-control .control__button {
  border: none;
  border-radius: 0;
  width: 40px;
  height: 40px;
  color: #fff;
  cursor: pointer;
}

.slider-control .slide-count {
  margin-left: 10px;
  color: #fff;
}

JavaScript

(function () {
  "use strict";

	// querySelectorAll 메서드를 사용하여 단일 / 복수 요소를 선택하는 유틸 함수
  const get = (target) => {
    const els = document.querySelectorAll(target);
    return els.length > 1 ? els : els[0];
  };

  const $slideContainer = get(".slider__wrapper");
  const $slider = get(".slider");

  const $slideIndicator = get(".slide-count");
  const $totalSlides = get(".all-slide");
  const $currentSlide = get(".current-slide");
  const $prevBtn = get(".control__button.prev");
  const $nextBtn = get(".control__button.next");
  const $slide = get(".slide");

  const slideWidth = $slide[0].clientWidth;
  const slideAmount = $slide.length;
  const sliderWidth = slideWidth * slideAmount;
  const slideSpeed = 1000;

  let currentIndex = 1;
  let moveOffset = 0;

  let interval;

  // 현재 슬라이드 인덱스를 기준으로 슬라이더 구성요소에 대한 접근성 속성을 설정
  const setAccessibility = () => {
    for (let i = 0; i < $slider.children.length; i++) {
      if (i === currentIndex) {
        $slider.children[i].setAttribute("aria-hidden", false);
      } else {
        $slider.children[i].setAttribute("aria-hidden", true);
      }
    }

    // slide-count에 aria-label 속성을 추가하고 현재 슬라이드의 인덱스를 추가한다.
    setTimeout(() => {
      $slideIndicator.setAttribute("aria-label", `slide ${$currentSlide.textContent} of ${slideAmount}`);
    }, 100);
    
  };

  // setInterval을 사용, 3초마다 handleSwipe를 트리거하는 자동 재생 함수
  const slideAutoPlay = () => {
    interval = setInterval(() => {
      handleSwipe(1);

      if (currentIndex === $slider.children.length - 1) {
        setTimeout(() => {
          $slider.style.transition = "none";
          currentIndex = 1;
          moveOffset = (100 / slideAmount) * currentIndex;
          $slider.style.transform = `translateX(-${moveOffset}%)`;
        }, slideSpeed);
      }
    }, 3000);
  };

  /**
   * direction
   * next : 1, prev : -1
   *
   * handleSwipe는 이전, 다음 버튼을 클릭하거나 자동으로 슬라이드가 넘어갈 때 실행되는 함수로
   * 같은 함수에서 이전, 다음 버튼을 클릭했을 때의 이벤트를 구분하기 위해
   * direction을 사용한다.
   */
  const handleSwipe = (direction) => {
    currentIndex = currentIndex + direction;

    if (currentIndex >= slideAmount + 2) {
      currentIndex = 4;
    } else if (currentIndex <= 0) {
      currentIndex = 0;
    }

    moveOffset = (100 / slideAmount) * currentIndex;

    if (currentIndex <= 0) {
      $currentSlide.textContent =
        $slider.children[$slider.children.length - 2].dataset.index;
    } else if (currentIndex >= $slider.children.length - 1) {
      $currentSlide.textContent = $slider.children[1].dataset.index;
    } else {
      $currentSlide.textContent = $slider.children[currentIndex].dataset.index;
    }

    $slider.style.transform = `translateX(-${moveOffset}%)`;
    $slider.style.transition = `all ${slideSpeed}ms ease`;

    setAccessibility();
  };

  // 이전, 다음 버튼 클릭 이벤트 핸들러
  // handleSwipe 함수를 호출하면서 슬라이드의 진행 방향을 인자로 넘겨주는 함수 
  const handleMoveBtn = (event) => {
    event.preventDefault();
    const $target = event.currentTarget;

    if ($target.id === "nextBtn") {
      handleSwipe(1);

      if (currentIndex === $slider.children.length - 1) {
        setTimeout(() => {
          $slider.style.transition = "none";
          currentIndex = 1;
          moveOffset = (100 / slideAmount) * currentIndex;
          $slider.style.transform = `translateX(-${moveOffset}%)`;
        }, slideSpeed);
      }
    } else {
      handleSwipe(-1);

      if (currentIndex === 0) {
        setTimeout(() => {
          $slider.style.transition = "none";
          currentIndex = $slider.children.length - 2;
          moveOffset = (100 / slideAmount) * currentIndex;
          $slider.style.transform = `translateX(-${moveOffset}%)`;
        }, slideSpeed);
      }
    }
  };

  // 슬라이드의 초기 레이아웃 SetUp
  const setSlideLayout = () => {
    // 무한 슬라이드를 위해 첫번째 슬라이드와 마지막 슬라이드를 복제
    const $firstSlideClone = $slider.firstElementChild.cloneNode(true);
    const $lastSlideClone = $slider.lastElementChild.cloneNode(true);

    $slider.insertBefore($lastSlideClone, $slider.firstElementChild);
    $slider.appendChild($firstSlideClone);

    // 슬라이드의 너비를 설정하고, 1번째 슬라이드로 위치 초기화 (translateX)
    $slider.style.width = `${sliderWidth}px`;
    $slider.style.transform = `translateX(-${slideWidth}px)`;

    setAccessibility();
  };

  const handleMouseEnter = (event) => {
    event.preventDefault();
    event.stopPropagation();
    clearInterval(interval);
  };

  const handleMouseLeave = (e) => {
    slideAutoPlay();
  };

  // 사용자가 이전, 다음 버튼을 클릭할 때, throttle 함수를 사용하여 
  // 짧은 시간 내 과도한 이벤트가 일어나지 않도록 방지
  const throttle = (fn, delay) => {
    let lastCall = 0;
    return function (...args) {
      const now = new Date().getTime();
      if (now - lastCall < delay) {
        return;
      }
      lastCall = now;
      fn(...args);
    };
  };

  const handleMoveBtnThrottled = throttle(handleMoveBtn, 1000);

  const init = () => {
    setSlideLayout();
    slideAutoPlay();

    $totalSlides.textContent = slideAmount;
    $currentSlide.textContent = currentIndex;

    $prevBtn.addEventListener("click", handleMoveBtnThrottled);
    $nextBtn.addEventListener("click", handleMoveBtnThrottled);

    $slideContainer.addEventListener("mouseenter", handleMouseEnter);
    $slideContainer.addEventListener("mouseleave", handleMouseLeave);
  };

  window.addEventListener("DOMContentLoaded", init);
})();

4. 프로젝트 회고

처음부터 캐러셀 슬라이드에 구현하고 싶은 기능이 명확하게 있었기 때문에 다른 슬라이드는 고려하지 않고 작업을 시작했습니다. 아무것도 구현되지 않은 상태에서 기능을 한 번에 올리다보니 여러 문제를 맞닥뜨리게 되었는데요.

한 번에 고려해야 할 것이 너무 많다보니 예상대로 동작하지 않을 때가 많아 예상 소요 시간보다 더 오랜 시간을 구현과 에러 추적에 써야 했고,
기능 단위로 구현하지 않았기 때문에 내가 지금 어떤 기능을 구현했고, 다음에 어떤 기능을 구현해야 하는지. 전체적으로 봤을 때 몇 퍼센트를 구현했는지 판단할 수 없었다는 것입니다.

위와 같은 문제로 구현 완료 전까지 슬라이드의 기능을 아무것도 사용할 수 없었고, MVP(최소 기능 제품, Minimum Viable Product) 개발 방법론에 대해 좀 더 찾아보게되는 계기가 되었습니다.


Reference
① 무한 슬라이드 만들기(infinite carousel) +애니메이션 https://ye-yo.github.io/react/2022/01/21/infinite-carousel.html

② Infinite Carousel - vanilla Javascript https://codepen.io/Marouen/pen/OxyEEY

③ Minimum Viable Products - What does MVP mean for your startup?
https://goldenowl.asia/blog/minimum-viable-products-what-does-mvp-mean-for-your-startup

0개의 댓글