채용 페이지는 기본적으로 캐러셀 이미지와 하단에 채용 중인 포지션을 열거하는 방식으로 기획했다.
캐러셀의 원리를 알지 못하니, 라이브러리를 사용하기 보다는 순수 자바스크립트로 구현해보기로 했다.
참고 링크
우선 기본적인 뼈대를 return문 안에 구축한다.
가운데에 캐러셀 영역이 있고, 양 옆에 이전, 다음 버튼으로 구성되어 있다.
return (
<>
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'left'
handleSwipe('left')
buttonControll()
}}>
이전
</s.Button>
<s.Carousel className='carousel-wrapper'>
<s.Carousel
className='carousel-box'
style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
>
{slides.map(({ url, id }, idx) => (
<s.Carousel
key={idx}
className='carousel-item'
style={{backgroundImage: `url(${url})`}}
>
{id}
</s.Carousel>
))}
</s.Carousel>
</s.Carousel>
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'right'
handleSwipe('right')
buttonControll()
}}>
다음
</s.Button>
</>
)
불러올 이미지 5개를 준비하고, 각각 ID를 부여했다.
useState로 초기 데이터를 배열 형태로 세팅한다.
const [item, setItem] = useState([
{ id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
{ id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
{ id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
{ id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
{ id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
])
보여질 캐러셀 이미지 앞뒤로 가짜 데이터를 생성한다.
이를 통해 좌우 끝 데이터에 도달한 후 다시 돌아올 때, 자연스럽게 무한 스와이프 되는 듯한 transition을 구현할 수 있다.
fakeData가 2이므로, 초기 state는 2로 지정해야 첫번째 이미지가 나타난다.
push : 배열의 맨 뒤에 요소를 추가한 후 새로운 길이 반환
pop : 배열의 맨 뒤 요소를 제거하고, 제거된 요소를 반환
shift : 배열의 첫번째 요소 제거
unshift : 배열의 첫번째 요소 추가
마지막으로 세 배열 (fakeFront, item, fakeLast) 을 합쳐 새로운 배열을 만들면 무한히 순환되는 슬라이드를 만들 수 있다.
const [currentIndex, setCurrentIndex] = useState(2)
const fakeData = 2;
const setSliders = () => {
const fakeFront = []
const fakeLast = []
let index = 0;
while (index < fakeData) {
fakeLast.push(item[index % item.length])
fakeFront.unshift(item[item.length - 1 - (index % item.length)])
index++
}
return [...fakeFront, ...item, ...fakeLast]
}
위에서 많이 헤멨는데, 결국 while문은 배열을 얼마동안 순회할 것인지 정해주는 로직이었다.
index % item.length 를 통해 index가 배열의 길이를 초과하지 않고 순환할 수 있다.
item.length - 1 - (index % item.length) 또한 마찬가지이다.
에를 들어,
item.length가 5일 때, index가 5라면 index % item.length는 0이 되므로, index가 0, 1, 2, 3, 4, 5, 6, 7... 일 때,
나머지는 0, 1, 2, 3, 4, 0, 1, 2, 3, 4... 로 반복된다.
<예시>
const item = [
{ id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
{ id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
{ id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
{ id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
{ id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
]
const fakeData = 2;
const setSliders = () => {
const fakeFront = []
const fakeLast = []
let index = 0;
while (index < fakeData) {
fakeLast.push(item[index % item.length])
fakeFront.unshift(item[item.length - 1 - (index % item.length)])
index++
}
return [...fakeFront, ...item, ...fakeLast]
}
console.log(setSliders())
// 출력:
// [
// { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
// { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
// { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
// { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
// { id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
// { id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
// { id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
// { id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
// { id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' }
// ]
이제 스와이프 방향이 왼쪽인지 오른쪽인지 판별하도록 한다.
첫번째 if 문의 경우, 일반적으로 0 미만의 인덱스는 없으나, 첫번째 슬라이드에서 왼쪽으로 스와이프 했을 때의 조건을 명시한 것이다.
index = slides.length -1 로 설정하여 마지막 슬라이드로 이동시킨다.
비슷하게, index === slides.length - 1 은 마지막 슬라이드에서 오른쪽으로 이동할 때의 조건이다.
const handlerSlider = (index: number) => {
if (index < 0) {
direction.current = 'left'
index = slides.length - 1;
setOffTransition(true)
} else if (index === slides.length - 1) {
direction.current = 'right'
index = slides.length - 1;
setOffTransition(true)
}
setCurrentIndex(index)
}
//방향 판별
const handleSwipe = (direction: 'left' | 'right') => {
console.log('클릭')
const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
handlerSlider(newIndex)
}
자연스러운 캐러셀을 구현하기 위해서는 transition을 때에 따라 끄고 켜야 한다.
첫번째에서 마지막, 또는 마지막에서 첫번째 슬라이드로 넘어가는 순간 transition을 그대로 유지한다면 부자연스러운 전환이 일어난다. 짧은 시간동안 setOff 함으로써 자연스러운 전환을 구현한다.
또한 transition이 마무리되기 전에 이전, 다음 버튼을 빠르게 연타하는 경우, 오류가 날 수 있다.
슬라이드를 일관되지 않게 노출시키거나, 애니메이션이 중첩될 수 있다. 이를 방지하기 위해 1초간 연타를 방지한다.
const slides = setSliders()
const [offTransition, setOffTransition] = useState(false)
const direction = useRef('left')
const transition = offTransition ? '0s' : '0.5s'; //슬라이딩 속도
const [disabled, setDisabled] = useState(false)
const buttonControll = () => {
//버튼 사용 직후 1초간 비활성화 (연타 방지)
setDisabled(true)
setTimeout(() => setDisabled(false), 1000)
}
useEffect(() => {
console.log('Slide ID:', slides[currentIndex].id)
//오른쪽 마지막 인덱스일 경우
if ( direction.current === 'right' && currentIndex === slides.length - 1 ) {
//transition 잠시 껐다가 0번 인덱스로
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(2) // fakedata가 2개이기 때문에 index(2)가 첫번째 id
}, 1000)
//0.1초 후 transition 다시 켜기
setTimeout(() => {
setOffTransition(false)
}, 1100)
//0번에서 마지막으로 넘어갈 경우
} else if ( direction.current === 'left' && currentIndex === slides.length - 1 ) {
//위 조건과 겹치지 않도록 먼저 transition을 끄고 넘어간 뒤 0.01초 후 켜서 눈속임
setTimeout(() => {
setOffTransition(false)
setCurrentIndex(slides.length - 2)
}, 10)
}
}, [currentIndex])
아래는 전체 코드이다.
import React, { useEffect, useRef, useState } from 'react'
import s from '../stores/styling'
export default function Carousel() {
const [item, setItem] = useState([
{ id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
{ id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
{ id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
{ id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
{ id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
])
const [currentIndex, setCurrentIndex] = useState(2)
const fakeData = 2;
const setSliders = () => {
const fakeFront = []
const fakeLast = []
let index = 0;
while (index < fakeData) {
fakeLast.push(item[index % item.length])
fakeFront.unshift(item[item.length - 1 - (index % item.length)])
index++
}
return [...fakeFront, ...item, ...fakeLast]
}
const handlerSlider = (index: number) => {
if (index < 0) {
direction.current = 'left'
index = slides.length - 1;
setOffTransition(true)
} else if (index === slides.length - 1) {
direction.current = 'right'
index = slides.length - 1;
setOffTransition(true)
}
setCurrentIndex(index)
}
//방향 판별
const handleSwipe = (direction: 'left' | 'right') => {
console.log('클릭')
const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
handlerSlider(newIndex)
}
const slides = setSliders()
const [offTransition, setOffTransition] = useState(false)
const direction = useRef('left')
const transition = offTransition ? '0s' : '0.5s'; //슬라이딩 속도
const [disabled, setDisabled] = useState(false)
const buttonControll = () => {
//버튼 사용 직후 1초간 비활성화 (연타 방지)
setDisabled(true)
setTimeout(() => setDisabled(false), 1000)
}
useEffect(() => {
console.log('Slide ID:', slides[currentIndex].id)
//오른쪽 마지막 인덱스일 경우
if ( direction.current === 'right' && currentIndex === slides.length - 1 ) {
//transition 잠시 껐다가 0번 인덱스로
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(2) // fakedata가 2개이기 때문에 index(2)가 첫번째 id
}, 1000)
//0.1초 후 transition 다시 켜기
setTimeout(() => {
setOffTransition(false)
}, 1100)
//0번에서 마지막으로 넘어갈 경우
} else if ( direction.current === 'left' && currentIndex === slides.length - 1 ) {
//위 조건과 겹치지 않도록 먼저 transition을 끄고 넘어간 뒤 0.01초 후 켜서 눈속임
setTimeout(() => {
setOffTransition(false)
setCurrentIndex(slides.length - 2)
}, 10)
}
}, [currentIndex])
return (
<>
<s.Button
disabled={disabled}
onClick={() => {
handleSwipe('left')
buttonControll()
}}>
이전
</s.Button>
<s.Carousel className='carousel-wrapper'>
<s.Carousel
className='carousel-box'
style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
//각 슬라이드를 전체 화면 너비의 100%만큼 이동
>
{slides.map( ({ url, id }, idx) => {
return (
<s.Carousel
key={idx}
className='carousel-item'
style={{backgroundImage: `url(${url})`}}
>
{id}
</s.Carousel>
)
})}
</s.Carousel>
</s.Carousel>
<s.Button
disabled={disabled}
onClick={() => {
handleSwipe('right')
buttonControll()
}}>
다음
</s.Button>
</>
)
}
슬라이드는 버튼 동작에 따라 잘 넘어가는 것으로 나타난다.
그러나 슬라이드가 한바퀴 이상 순회한 뒤, 버튼을 지속적으로 클릭했을 때 슬라이드가 연달아 겹치거나 순서가 뒤바뀌는 현상이 나타나 콘솔 메세지를 통해서도 확인해보았다.
알아보니 setTimeout을 사용하여 offTransition상태를 업데이트 하는 부분에서 충돌이 발생한 것으로 보였다.
또한 불필요한 상태 업데이트를 최소화해야 했다.
먼저 첫번째보다 왼쪽, 마지막보다 오른쪽으로 넘어간 경우에 direction을 설정하도록 핸들러 함수를 수정했다.
offTransition 함수 또한 생략했다.
const handlerSlider = (index: number) => {
if (index < 0) {
index = slides.length - 1;
} else if (index >= slides.length) {
index = 0;
}
setCurrentIndex(index)
}
다음은 useEffect 부분이다.
기존에는 방향에 따라 다르게 설정했는데, 이번에는 방향과 현재 인덱스를 한번 더 명시하여 이에 따라 설정했다.
사용감을 위해 지속 시간을 좀 더 줄였다.
여기서 slides.length - 3 과 slides.length - 2 는 fakeData가 2이기 때문에 나오는 값이다.
useEffect(() => {
console.log('Slide ID:', slides[currentIndex].id)
if (currentIndex === 1 && direction.current === 'left') {
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(slides.length - 3)
}, 500)
setTimeout(() => {
setOffTransition(false)
}, 600)
} else if (currentIndex === slides.length - 2 && direction.current === 'right') {
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(2)
}, 500)
setTimeout(() => {
setOffTransition(false)
}, 600)
}
}, [currentIndex])
핸들러 함수에 direction.current을 명시한다.
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'left'
handleSwipe('left')
buttonControll()
}}>
이전
</s.Button>
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'right'
handleSwipe('right')
buttonControll()
}}>
다음
</s.Button>
최종적으로 다음과 같은 코드가 완성되었고, 오류 없이 잘 나타나는 것을 확인했다.
import React, { useEffect, useRef, useState } from 'react'
import s from '../stores/styling'
export default function Carousel() {
const [items, setItems] = useState([
{ id: 1, url: 'https://images.app.goo.gl/gvhe3VaycwCW6JfZ6' },
{ id: 2, url: 'https://images.app.goo.gl/8bM8GiwpkkzfaSnK7' },
{ id: 3, url: 'https://images.app.goo.gl/SfhtK1KJouYkoAp76' },
{ id: 4, url: 'https://images.app.goo.gl/Xtgy3ug9XT7RxY9n9' },
{ id: 5, url: 'https://images.app.goo.gl/rv8qvSwbz8NWjTi67' },
])
const [currentIndex, setCurrentIndex] = useState(2)
const fakeData = 2;
const [offTransition, setOffTransition] = useState(false)
const [disabled, setDisabled] = useState(false)
const direction = useRef('left')
const setSliders = () => {
const fakeFront = []
const fakeLast = []
let index = 0;
while (index < fakeData) {
fakeLast.push(items[index % items.length])
fakeFront.unshift(items[items.length - 1 - (index % items.length)])
index++
}
return [...fakeFront, ...items, ...fakeLast]
}
const slides = setSliders()
const handlerSlider = (index: number) => {
if (index < 0) {
index = slides.length - 1;
} else if (index >= slides.length) {
index = 0;
}
setCurrentIndex(index)
}
const handleSwipe = (direction: 'left'|'right') => {
console.log('클릭')
const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
handlerSlider(newIndex)
}
const buttonControll = () => {
setDisabled(true)
setTimeout(() => setDisabled(false), 1000)
}
useEffect(() => {
console.log('Slide ID:', slides[currentIndex].id)
if (currentIndex === 1 && direction.current === 'left') {
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(slides.length - 3)
}, 500)
setTimeout(() => {
setOffTransition(false)
}, 600)
} else if (currentIndex === slides.length - 2 && direction.current === 'right') {
setTimeout(() => {
setOffTransition(true)
setCurrentIndex(2)
}, 500)
setTimeout(() => {
setOffTransition(false)
}, 600)
}
}, [currentIndex])
const transition = offTransition ? '0s' : '0.5s';
return (
<>
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'left'
handleSwipe('left')
buttonControll()
}}>
이전
</s.Button>
<s.Carousel className='carousel-wrapper'>
<s.Carousel
className='carousel-box'
style={{transform: `translateX(${-100 * currentIndex}%)`, transition}}
>
{slides.map(({ url, id }, idx) => (
<s.Carousel
key={idx}
className='carousel-item'
style={{backgroundImage: `url(${url})`}}
>
{id}
</s.Carousel>
))}
</s.Carousel>
</s.Carousel>
<s.Button
disabled={disabled}
onClick={() => {
direction.current = 'right'
handleSwipe('right')
buttonControll()
}}>
다음
</s.Button>
</>
)
}