본문 바로가기
Next.js/개발 노트

[React/Next.js] 라이브러리 없이 구현하는 자동 재생 슬라이드

by tokkiC 2023. 12. 31.


라이브러리를 사용하지 않고 무한 자동 재생 슬라이드 구현

포트폴리오에서 만든 광택 효과가 적용된 기술 스택 카드가 생각보다 커서 카드가 아닌 작은 아이콘으로 보여주기로 했다.
물론 그냥 보여주면 재미 없으니, 자동 재생 슬라이드로 직접 구현해보았다. 라이브러리는 필요없다 상남자에겐!

머리를 쓰자 머리를

핵심 원리를 요약하자면,

  1. 충분히 긴 이미지 요소 만들기: 반응형 웹사이트이므로, 큰 해상도에도 슬라이드가 끊어지지 않도록 최대 해상도에서도 가득 찰 정도로 이미지 요소를 만들어 한줄이 가득 보이게 함
  2. 한 사이클이 이동한 것처럼 보일만큼만 반복 이동 : 아이콘이 한 사이클만 이동하도록 하고, 그것을 애니메이션으로 반복하여 계속 이어서 움직이는 것처럼 보이게 함. (3배로 요소를 붙였으면, 1/3만큼 이동하면 한 사이클이 되겠다)

이렇게 된다.

아래의 코드의 각 줄마다 상세 설명을 주석으로 남겨두었다.

Next.js 14와 타입스크립트를 사용하였지만, React에도 "use client" 를 제거하면 그대로 사용 가능하다.

"use client";

import Image from "next/image";
import { useRef } from "react";
import { skillDataList } from "../Skills/skillsData";
import { useZustandStore } from "@/zustand/useZustandStore";
import { SkillData } from "@/model/types";
import { scrollToElementById } from "@/utils/utils";

type SkillCardSliderProps = {
  reverse?: boolean;
};

export default function SkillCardSlider({
  reverse = false,
}: SkillCardSliderProps) {
  const carouselRef = useRef<HTMLDivElement>(null);
  const loadedImageCount = useRef(0);

  const { setSelectedSkill } = useZustandStore();

  const extendedSkillDataList = [
    ...skillDataList,
    ...skillDataList,
    ...skillDataList,
  ];

  const handleMouseEnter = () => {
    if (carouselRef.current) {
      carouselRef.current.style.animationPlayState = "paused";
    }
  };

  const handleMouseLeave = () => {
    if (carouselRef.current) {
      carouselRef.current.style.animationPlayState = "running";
    }
  };

  // useEffect로 슬라이더를 재생하면 이미지 로드가 모두 되지 않은 상태에서 이미지의 총 가로 너비를 계산하므로 너비 계산에 오차가 생겨 순환이 끊김
  // 그러므로 이미지가 로드되면 카운트 하고, 총 이미지 수만큼 모두 로드 되었을때 동적으로 총 너비를 계산해야 함
  const handleImageLoad = () => {
    loadedImageCount.current += 1;

    if (carouselRef.current) {
      // Array.from(carouselRef.current.children)는 이 컨테이너 내의 모든 자식 요소들(이미지들)을 배열로 변환함
      // .reduce((total, child) => total + child.getBoundingClientRect().width, 0)는 이 배열의 각 요소(이미지)에 대해 getBoundingClientRect().width를 호출하여 그 너비를 합함

      if (loadedImageCount.current === skillDataList.length * 3) {
        const totalWidth = Array.from(carouselRef.current.children).reduce(
          (total, child) => total + child.getBoundingClientRect().width,
          0
        );

        // 자연스럽게 순환하며 무한 재생하기 위해서 이미지 배열을 n배로 한 후 1/n에서 시작하도록 했음.
        // 현재 이미지 크기로는 3배 이상이어야 구현 최대 가로 크기가 가득 차기 때문에 1/3을 이동거리로 갖게 함

        const translateValue = `-${totalWidth / 3}px`;

        // 컨테이너 너비 계산 후, css 변수로 설정함
        carouselRef.current.style.setProperty(
          "--carousel-translate",
          translateValue
        );
      }
    }
  };

  const handleImageClick = (data: SkillData) => {
    setSelectedSkill(data);
    scrollToElementById("skills");
  };

  return (
    <div
      ref={carouselRef}
      className={`flex py-2 ${
        reverse ? "animate-reverseSkillCardSlide" : "animate-skillCardSlide"
      }`}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {extendedSkillDataList.map((skillData, index) => (
        <Image
          key={index}
          className="rounded-3xl px-2 hover:cursor-pointer hover:opacity-50"
          src={skillData.image}
          alt={skillData.skillName}
          width={100}
          height={100}
          onLoad={handleImageLoad}
          onClick={() => handleImageClick(skillData)}
        />
      ))}
    </div>
  );
}

댓글