밝을희 클태

[ React / Next.js ] 이미지 슬라이더(image-slider)를 만들어보자 본문

React

[ React / Next.js ] 이미지 슬라이더(image-slider)를 만들어보자

huipark 2024. 5. 9. 14:58

 포트폴리오를 만드는 중인데 외부 라이브러리 없이 만드는 중이라 직접 구현한 이미지 슬라이더를 블로그에 정리를 하려고 한다.

구현 방법

  1. 이미지가 보이는 고정된 영역, 해당 영역 밖으로 넘어가는 요소들은 overflow: hidden을 사용해서 숨김
  2. 위의 영역 뒤에 이미지들을 삽입
  3. 버튼을 클릭할 때마다 이미지들의 위치를 옮겨준다. 

image-slider 구조

container
├── slider-container
│   ├── button (left arrow)
│   ├── slider-img-container
│   │   └── img-inner-container
│   │       └── Image
│   └── button (right arrow)
└── circle-container

 

사용하는 변수들와 훅

	const [imgWidth, setImgWidth] = useState<number>(0);
	const [offset, setOffset] = useState<number>(0);
	const [idx, setIdx] = useState<number>(0);
	const sliderContainerRef = useRef<HTMLDivElement>(null);
	const totalChildren: number = images.length;
	let initDragPos: number = 0;
	let travelRatio: number = 0;
	let travel: number = 0;
	let originOffset: number = 0;

 

 우선 처음에 DOM이 브라우저에 렌더링 되고 나면 이미지를 얼마나 넘길지 알아야 하기 때문에 slider-containerwidthHook에 저장해 준다.

 이미지의 크기를 px 말고 %, vw, vh의 단위도 사용할 수 있게 하고 싶어서 뷰포트의 크기가 줄거나 늘어나면 이미지의 크기도 늘어나기 때문에 sliderContainerResizeObserver API를 사용해 크기가 변경이 될 때 Hook에 새로 이미지의 크기를 저장해 준다.

 

windowresize 이벤트도 있는데 저걸 사용한 이유는 resize event에서 이미 너무 많고 큰일을 하고 있어서 그런지 뷰포트의 크기가 변경이 될 때마다 정확하게 이미지의 크기를 새로 측정해야 하는데 조금씩 오차가 있어 ResizeObserver로 변경을 했다. 만약 resize에 별다른 이벤트가 없다면 resize가 될 때마다 이미지의 크기를 측정을 해도 될 거 같다.

	useEffect(() => {
		const resizeObserver = new ResizeObserver((entries) => {
			for (let e of entries) {
				const { width } = e.contentRect;
				setImgWidth(width);
				setOffset(-idx * width);
				setIdx(0);
			}
		});

		if (sliderContainerRef.current)
			resizeObserver.observe(sliderContainerRef.current);

		return () => {
			if (sliderContainerRef.current)
				resizeObserver.unobserve(sliderContainerRef.current);
		};
	}, []);

이미지 이동 방법

css transform: translate 속성에 offset 값을 준다.

.img-inner-container {
	display: flex;
	transform: translateX(${offset}px);
	transition: 0.1s;
}

좌, 우 버튼으로 이미지 넘기기 

 가장 기본적인 좌, 우 버튼을 클릭해서 이미지를 넘겨보자. 이미지의 개수보다 idx가 크거나 적은 경우 넘어가면 안 되기 때문에 max, min을 사용해서 막아준다.

	const navigate = (direction: number) => {
		const newIdx = Math.max(0, Math.min(totalChildren - 1, idx + direction));
		setIdx(newIdx);
		setOffset(-newIdx * imgWidth);
	};

드래그로 이미지 넘기기

 간단하게 설명을 하면 이미지 슬라이더의 영역을 클릭하는 순간 mousemove, mouseup 이벤트를 등록 -> 클릭한 순간의 좌표, 이미지의 offset을 저장 -> 드래그를 하는 동안 클릭한 좌표에서 이동거리를 계산(이미지 크기의 0.8 정도를 넘기면 더 이상 안 넘어감) -> 클릭을 놓으면 이미지 크기의 0.3 이상을 움직이면 이미지를 움직임 -> 아니면 이미지를 원위치

	useEffect(() => {
		const $imgInnerContainer = document.querySelector(
			".img-inner-container"
		) as HTMLElement;

		// 마우스를 드래그하는 동안 발생하는 이벤트
		const startMouse = (e: MouseEvent) => {
			// 이동거리를 비율로 했을때 0.8 이상이면 더이상 안 넘어감
			if (Math.abs(travelRatio) < 0.8) {
				// 이동거리
				travel = e.clientX - initDragPos;
				// 이동거리 비율
				travelRatio = travel / imgWidth;
				// 실시간으로 사용자에게 얼마나 넘겼는지 보여줌
				setOffset(originOffset + travel);
			}
		};

		// 클릭을 놓으면 발생하는 이벤트
		const stopMouse = () => {
			// 넘긴 비율이 0.3 이상이면 이미지를 넘김
			if (Math.abs(travelRatio) > 0.3) {
				const newIdx =
					travelRatio > 0
						? Math.max(idx - 1, 0)
						: Math.min(idx + 1, totalChildren - 1);
				setIdx(newIdx);
				setOffset(newIdx * -imgWidth);
			} else {
				setOffset(idx * -imgWidth);
			}
			// 이벤트 제거
			document.removeEventListener("mousemove", startMouse);
			document.removeEventListener("mouseup", stopMouse);
		};

		// 마우스를 클릭한 순간 발생하는 이벤트
		const downMouse = (e: MouseEvent) => {
			// 기존 이벤트의 동작 방식을 막음
			e.preventDefault();
			// 이미지 크기를 기준으로 얼마나 이동했는지 비율로 확인하는 변수
			travelRatio = 0;
			// 클릭한 순간의 좌표를 저장
			initDragPos = e.clientX;
			// 현재 이미지 슬라이더의 offset위치를 저장
			originOffset = offset;

			document.addEventListener("mousemove", startMouse);
			document.addEventListener("mouseup", stopMouse);
		};

		$imgInnerContainer?.addEventListener("mousedown", downMouse);

		return () => {
			$imgInnerContainer?.removeEventListener("mousedown", downMouse);
			document.removeEventListener("mousemove", startMouse);
			document.removeEventListener("mouseup", stopMouse);
		};
	}, [imgWidth, idx]);

인디케이터 클릭으로 이미지 넘기기

	const onClickIndicator = (idx: number) => {
		setOffset(-idx * imgWidth);
		setIdx(idx);
	};

인디케이터에 현재 사진의 idx 표시

인디케이터는 Hook에 저장된 현재의 idx와 인디케이터의 idx를 비교해서 색을 넣어서 렌더링 해준다.

const RenderCurrentPosition = () => {
		return Array.from({ length: totalChildren }, (_, _idx) => {
			return (
				<div
					className="circle"
					onClick={() => onClickindicator(_idx)}
					key={_idx}
				>
					<style jsx>{`
						.circle {
							width: 10px;
							height: 10px;
							border: 0.5px solid ${dotBorderColor};
							background-color: ${idx === _idx ? dotColor : ""};
							border-radius: 50%;
							margin-inline: 1.5px;
							transition: 1s;
							cursor: pointer;
						}
					`}</style>
				</div>
			);
		});
	};

 

전체 코드

import Image from "next/image";
import React, { useState, useEffect, useRef } from "react";

interface Image {
	url: string;
}

interface ImageSlider {
	images: Image[];
	width?: number | null;
	height?: number | null;
	objectFit?: "fill" | "contain" | "cover" | "none" | "scale-down";
	dotColor?: string;
	dotBorderColor?: string;
	arrowColor?: string;
	borderRadius?: number;
}

const ImageSlider = ({
	images,
	width = null,
	height = null,
	objectFit = "fill",
	dotColor = "white",
	dotBorderColor = "white",
	arrowColor = "white",
	borderRadius = 0,
}: ImageSlider) => {
	const imgStyle: React.CSSProperties = {
		width: width ? `${width}px` : "100%",
		height: height ? `${height}px` : "100%",
		objectFit: objectFit,
		borderRadius: borderRadius,
	};

	const [imgWidth, setImgWidth] = useState<number>(0);
	const [offset, setOffset] = useState<number>(0);
	const [idx, setIdx] = useState<number>(0);
	const sliderContainerRef = useRef<HTMLDivElement>(null);
	const totalChildren: number = images.length;
	let initDragPos: number = 0;
	let travelRatio: number = 0;
	let travel: number = 0;
	let originOffset: number = 0;

	useEffect(() => {
		const resizeObserver = new ResizeObserver((entries) => {
			for (let e of entries) {
				const { width } = e.contentRect;
				setImgWidth(width);
				setOffset(-idx * width);
				setIdx(0);
			}
		});

		if (sliderContainerRef.current)
			resizeObserver.observe(sliderContainerRef.current);

		return () => {
			if (sliderContainerRef.current)
				resizeObserver.unobserve(sliderContainerRef.current);
		};
	}, []);

	useEffect(() => {
		const $imgInnerContainer = document.querySelector(
			".img-inner-container"
		) as HTMLElement;

		// 마우스를 드래그하는 동안 발생하는 이벤트
		const startMouse = (e: MouseEvent) => {
			// 이동거리를 비율로 했을때 0.8 이상이면 더이상 안 넘어감
			if (Math.abs(travelRatio) < 0.8) {
				// 이동거리
				travel = e.clientX - initDragPos;
				// 이동거리 비율
				travelRatio = travel / imgWidth;
				// 실시간으로 사용자에게 얼마나 넘겼는지 보여줌
				setOffset(originOffset + travel);
			}
		};

		// 클릭을 놓으면 발생하는 이벤트
		const stopMouse = () => {
			// 넘긴 비율이 0.3 이상이면 이미지를 넘김
			if (Math.abs(travelRatio) > 0.3) {
				const newIdx =
					travelRatio > 0
						? Math.max(idx - 1, 0)
						: Math.min(idx + 1, totalChildren - 1);
				setIdx(newIdx);
				setOffset(newIdx * -imgWidth);
			} else {
				setOffset(idx * -imgWidth);
			}
			// 이벤트 제거
			document.removeEventListener("mousemove", startMouse);
			document.removeEventListener("mouseup", stopMouse);
		};

		// 마우스를 클릭한 순간 발생하는 이벤트
		const downMouse = (e: MouseEvent) => {
			// 기존 이벤트의 동작 방식을 막음
			e.preventDefault();
			// 이미지 크기를 기준으로 얼마나 이동했는지 비율로 확인하는 변수
			travelRatio = 0;
			// 클릭한 순간의 좌표를 저장
			initDragPos = e.clientX;
			// 현재 이미지 슬라이더의 offset위치를 저장
			originOffset = offset;

			document.addEventListener("mousemove", startMouse);
			document.addEventListener("mouseup", stopMouse);
		};

		$imgInnerContainer?.addEventListener("mousedown", downMouse);

		return () => {
			$imgInnerContainer?.removeEventListener("mousedown", downMouse);
			document.removeEventListener("mousemove", startMouse);
			document.removeEventListener("mouseup", stopMouse);
		};
	}, [imgWidth, idx]);

	const onClickIndicator = (idx: number) => {
		setOffset(-idx * imgWidth);
		setIdx(idx);
	};

	const navigate = (direction: number) => {
		const newIdx = Math.max(0, Math.min(totalChildren - 1, idx + direction));
		setIdx(newIdx);
		setOffset(-newIdx * imgWidth);
	};

	const RenderCurrentPosition = () => {
		return Array.from({ length: totalChildren }, (_, _idx) => {
			return (
				<div
					className="circle"
					onClick={() => onClickIndicator(_idx)}
					key={_idx}
				>
					<style jsx>{`
						.circle {
							width: 10px;
							height: 10px;
							border: 0.5px solid ${dotBorderColor};
							background-color: ${idx === _idx ? dotColor : ""};
							border-radius: 50%;
							margin-inline: 1.5px;
							transition: 1s;
							cursor: pointer;
						}
					`}</style>
				</div>
			);
		});
	};

	return (
		<div className="container">
			<div className="slider-container">
				<button onClick={() => navigate(-1)}>
					<svg
						xmlns="http://www.w3.org/2000/svg"
						width="1em"
						height="1em"
						viewBox="0 0 20 20"
					>
						<path
							fill="currentColor"
							d="m4 10l9 9l1.4-1.5L7 10l7.4-7.5L13 1z"
						/>
					</svg>
				</button>
				<div className="slider-img-container" ref={sliderContainerRef}>
					<div className="img-inner-container">
						{images.map((e, idx) => {
							return (
								<Image
									src={e.url}
									alt="img-slider"
									key={idx}
									width={1}
									height={1}
									style={imgStyle}
								/>
							);
						})}
					</div>
				</div>
				<button onClick={() => navigate(1)}>
					<svg
						xmlns="http://www.w3.org/2000/svg"
						width="1em"
						height="1em"
						viewBox="0 0 20 20"
					>
						<path
							fill="currentColor"
							d="M7 1L5.6 2.5L13 10l-7.4 7.5L7 19l9-9z"
						/>
					</svg>
				</button>
			</div>
			<div className="circle-container">
				<RenderCurrentPosition />
			</div>
			<style jsx>{`
				.container {
					display: flex;
					flex-direction: column;
					align-items: center;
					width: ${width ? width + "px" : "100%"};
					${height && `height : ${height}px`};
				}
				.slider-container {
					display: flex;
				}
				.slider-img-container {
					display: flex;
					overflow: hidden;
					width: ${width ? width + "px" : "100%"};
					height: ${height ? height + "px" : "100%"};
					border-radius: ${borderRadius}px;
				}
				.img-inner-container {
					display: flex;
					transform: translateX(${offset}px);
					transition: 0.1s;
				}
				.circle-container {
					transform: translateY(-20px);
					display: flex;
				}
				.image-wapper {
				}
				button {
					background: none;
					border: none;
					color: ${arrowColor};
					font-size: 24px;
					cursor: pointer;
					z-index: 1;
					&:hover {
						transition: 0.3s;
						transform: scale(150%);
					}
					&:active {
						transition: 0s;
						transform: scale(100%);
					}
				}
			`}</style>
		</div>
	);
};

export default ImageSlider;