일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- react native
- React
- js
- PongWorld
- babel.config.js
- s3 upload
- 문자열 대소문자
- 리엑트 네이티브 아이콘
- Access Key 생성
- aws bucket 정책
- Next.js
- react native 세팅
- GIT
- 리액트
- fire base
- 리액트 네이티브 에러
- react native CLI
- 리액트 네이티브
- error
- react native 개발
- 에러
- 백준
- react native font
- AWS Access Key
- 문자열 대소문자 구별
- Project
- react native picker
- img upload
- firebase 라이브러리
- AWS
- Today
- Total
밝을희 클태
[ React / Next.js ] 이미지 슬라이더(image-slider)를 만들어보자 본문
포트폴리오를 만드는 중인데 외부 라이브러리 없이 만드는 중이라 직접 구현한 이미지 슬라이더를 블로그에 정리를 하려고 한다.
구현 방법
- 이미지가 보이는 고정된 영역, 해당 영역 밖으로 넘어가는 요소들은 overflow: hidden을 사용해서 숨김
- 위의 영역 뒤에 이미지들을 삽입
- 버튼을 클릭할 때마다 이미지들의 위치를 옮겨준다.
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-container의 width를 Hook에 저장해 준다.
이미지의 크기를 px 말고 %, vw, vh의 단위도 사용할 수 있게 하고 싶어서 뷰포트의 크기가 줄거나 늘어나면 이미지의 크기도 늘어나기 때문에 sliderContainer를 ResizeObserver API를 사용해 크기가 변경이 될 때 Hook에 새로 이미지의 크기를 저장해 준다.
window에 resize 이벤트도 있는데 저걸 사용한 이유는 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;