Gunbro Blog

library - react

어떤 그룹에 속한 이미지 슬라이더 만들기

소개

  • 해당 이미지 슬라이더 컴포넌트는 이미지 그룹에 속하는 이미지들이 있는 경우 사용되는 슬라이더입니다.
  • 이미지 그룹 사진이미지 그룹에 속하는 이미지들은 미리 알고 있는 경우 사용이 가능합니다.
  • 사용한 라이브러리들
    • @egjs/react-flicking
    • lodash
    • @types/lodash

겪었던 문제

@egjs/react-flicking의 문제

  • onChanged property 사용하면서 사진을 넘길 때 인디케이터가 넘어가는게 바로 적용이 안됩니다. 유저가 빠르게 넘기고, 놨을 때는 onChnaged는 한 번만 실행됩니다.
  • 위의 문제를 해결하기 위해 onHoldEnd에서 매개변수를 활용하였습니다. onHoldEndonChanged처럼 매개변수가 direction을 갖고 있지 않아서 e.axesEvent.delta를 사용하여 어느 방향으로 넘기고 있는지 파악했습니다. 하지만, 최종적으로 적용했을 때 깜빡임이 심해서 적용할 수 없었습니다.
    (이문제 때문에 onChanged 프로퍼티 사용시 한 번의 변화만 감지하는 걸수도 있겠다는 생각이 들었습니다.. )

해결 방법

  • onChanged를 사용하고, 그 안에서 로직적으로 처리하였습니다. 아래의 onChanged 안의 코드가 핵심로직입니다.
<Flicking
  ref={mainFlickingRef}
  onChanged={(e) => {
    const index = e.index;
    if (index === undefined) return;
 
    let cumulativeIndex = 0; //누적되는 index
    let groupIndex = 0; //그룹핑되는 index
    let currentImageListActiveIndex = 0; //그룹내에서 활성화된 상대적 index
 
    for (let i = 0; i < imageGroupList.length; i++) {
      const groupLength = imageGroupList[i].length;
      if (index < cumulativeIndex + groupLength) {
        groupIndex = i;
        currentImageListActiveIndex = index - cumulativeIndex;
        break;
      }
      cumulativeIndex += groupLength;
    }
 
    if (imageCoverRefList.current?.[groupIndex]) {
      imageCoverRefList.current[groupIndex].scrollIntoView({
        behavior: "smooth",
        block: "nearest",
        inline: "nearest",
      });
    }
    onChnagedMainImageCallback({
      currentImageList: imageGroupList[groupIndex],
      groupIndex,
      currentImageListActiveIndex,
    });
  }}
  cameraClass="w-full aspect-[1/1]"
  align={"prev"}
  circularFallback={"bound"}
  bound
  moveType={["strict"]}
>
  {entireImageList.map((image, index) => (
    <img
      draggable="false"
      className="w-full h-full object-cover"
      key={index}
      src={image}
      alt=""
    />
  ))}
</Flicking>
  • cumulativeIndex는 이전 그룹 인덱스까지의 누적된 사진 수를 저장합니다. index는 현재 바라보고 있는 전체적인 indexindex - cumulativeIndex는 계산을 하면, 활성화된 그룹내의 index로 계산됩니다.
  • scrollIntoView를 사용하여, 밑에 있는 그룹 이미지의 초점을 맞춰줍니다.

전체 코드

ImageSlider.tsx
import Flicking from "@egjs/react-flicking";
import { flatten } from "lodash";
import { useEffect, useMemo, useRef } from "react";
import "@egjs/react-flicking/dist/flicking.css";
interface ImageSliderProps {
  imageGroupList: string[][];
  imageGroupCoverList: string[];
  currentImageData: {
    currentImageList: string[];
    groupIndex: number;
    currentImageListActiveIndex: number;
  };
  onChnagedMainImageCallback: (data: {
    currentImageList: string[];
    groupIndex: number;
    currentImageListActiveIndex: number;
  }) => void;
}
 
function ImageSlider({
  imageGroupList,
  imageGroupCoverList,
  currentImageData,
  onChnagedMainImageCallback,
}: ImageSliderProps) {
  const mainFlickingRef = useRef<Flicking>(null);
  const indicatorBarRef = useRef<HTMLDivElement>(null);
  const imageCoverRefList = useRef<HTMLImageElement[]>([]);
 
  const entireImageList = useMemo(
    () => flatten(imageGroupList),
    [imageGroupList]
  );
 
  useEffect(() => {
    if (!indicatorBarRef.current) return;
    const indicator = indicatorBarRef.current;
 
    // fadein 애니메이션 클래스 추가
    indicator.classList.add("animate-fadein");
 
    // 애니메이션 클래스 제거
    const animationEnd = () => {
      indicator.classList.remove("animate-fadein");
      indicator.removeEventListener("animationend", animationEnd);
    };
 
    indicator.addEventListener("animationend", animationEnd);
  }, [currentImageData.currentImageListActiveIndex]);
 
  return (
    <div className="w-full">
      <div className="relative w-full">
        <Flicking
          ref={mainFlickingRef}
          onChanged={(e) => {
            const index = e.index;
            if (index === undefined) return;
 
            let cumulativeIndex = 0;
            let groupIndex = 0;
            let currentImageListActiveIndex = 0;
 
            for (let i = 0; i < imageGroupList.length; i++) {
              const groupLength = imageGroupList[i].length;
              if (index < cumulativeIndex + groupLength) {
                groupIndex = i;
                currentImageListActiveIndex = index - cumulativeIndex;
                break;
              }
              cumulativeIndex += groupLength;
            }
 
            if (imageCoverRefList.current?.[groupIndex]) {
              imageCoverRefList.current[groupIndex].scrollIntoView({
                behavior: "smooth",
                block: "nearest",
                inline: "nearest",
              });
            }
            onChnagedMainImageCallback({
              currentImageList: imageGroupList[groupIndex],
              groupIndex,
              currentImageListActiveIndex,
            });
          }}
          cameraClass="w-full aspect-[1/1]"
          align={"prev"}
          circularFallback={"bound"}
          bound
          moveType={["strict"]}
        >
          {entireImageList.map((image, index) => (
            <img
              draggable="false"
              className="w-full h-full object-cover"
              key={index}
              src={image}
              alt=""
            />
          ))}
        </Flicking>
        <div className="absolute z-10 bottom-[18px] left-1/2 -translate-x-1/2 w-full h-[3px] bg-[#2222224d]">
          <div
            ref={indicatorBarRef}
            className="h-full bg-[#222222] transition-transform"
            style={{
              width: `${100 / currentImageData.currentImageList.length}%`,
              transform: `translateX(${
                currentImageData.currentImageListActiveIndex * 100
              }%)`,
            }}
          />
        </div>
      </div>
      <div
        className="mt-5 w-full overflow-x-auto flex gap-2 px-3 no-scrollbar"
        onTouchMove={(e) => {
          e.stopPropagation();
        }}
      >
        {imageGroupCoverList.map((item, i) => (
          <img
            loading="lazy"
            ref={(ref) =>
              ref ? (imageCoverRefList.current[i] = ref) : undefined
            }
            onClick={() => {
              mainFlickingRef.current
                ?.moveTo(entireImageList.indexOf(imageGroupList[i][0]))
                .catch((err) => null);
            }}
            width={58}
            height={58}
            key={item}
            className={`object-cover border ${
              currentImageData.groupIndex === i
                ? "border-[#222]"
                : "border-[#ebebeb]"
            }`}
            src={item}
            alt=""
          />
        ))}
      </div>
    </div>
  );
}
 
export default ImageSlider;
Page.tsx
import React, { useEffect, useState } from "react";
import ImageSlider from "./ImageSlider";
 
const imageGroupList = [
  [
    "https://picsum.photos/237/200",
    "https://picsum.photos/240/200",
    "https://picsum.photos/243/200",
  ],
  ["https://picsum.photos/238/200", "https://picsum.photos/241/200"],
  [
    "https://picsum.photos/230/200",
    "https://picsum.photos/231/200",
    "https://picsum.photos/232/200",
  ],
  ["https://picsum.photos/233/200", "https://picsum.photos/234/200"],
  ["https://picsum.photos/260/200", "https://picsum.photos/261/200"],
  ["https://picsum.photos/262/200", "https://picsum.photos/263/200"],
  ["https://picsum.photos/264/200", "https://picsum.photos/265/200"],
];
 
const imageGroupCoverList = [
  "https://picsum.photos/250/200",
  "https://picsum.photos/251/200",
  "https://picsum.photos/252/200",
  "https://picsum.photos/253/200",
  "https://picsum.photos/254/200",
  "https://picsum.photos/255/200",
  "https://picsum.photos/256/200",
];
 
function Page() {
  const [currentImageData, setCurrentImageData] = useState<{
    currentImageList: string[];
    groupIndex: number;
    currentImageListActiveIndex: number;
  }>({
    currentImageList: imageGroupList[0],
    groupIndex: 0,
    currentImageListActiveIndex: 0,
  });
 
  return (
    <div className="w-full">
      <ImageSlider
        onChnagedMainImageCallback={({
          currentImageList,
          groupIndex,
          currentImageListActiveIndex,
        }) => {
          setCurrentImageData({
            currentImageList,
            groupIndex,
            currentImageListActiveIndex,
          });
        }}
        currentImageData={currentImageData}
        imageGroupList={imageGroupList}
        imageGroupCoverList={imageGroupCoverList}
      />
    </div>
  );
}
 
export default Page;

마치며..

이번에 기본적으로 슬라이드 기능이 있는 라이브러리를 사용했지만, 최소한의 슬라이드 기능만 있는 컴포넌트를 만들어보면 좋을 것 같습니다. 아무래도.. @egjs/react-flicking 라이브러리가 용량(약 154k)이 크다 보니 부담스러울 수도 있습니다.