[🐾 일지:리액트] react-carousel 패키지 없이 애니메이션 구현하기
{ # 인트로 }
프로젝트 UI를 피그마로 디자인한 후 대망의 개발을 시작했다.
맨 처음 about 페이지의 컴포넌트들을 아래와 같이 구현하려면 4가지를 생각해야 한다.
⓵ >,< 버튼을 눌렀을 때 페이지 이동이 될 것
⓶ 밑에 발자국 페이지네이션을 눌렀을 때 해당 페이지로 이동할 것
⓷ ⓵,⓶ 작업을 하지 않고도 5초마다 화면이 넘어갈 것
⓸ >,<,발자국 버튼을 눌렀을 때 ⓷ 작업을 초기화할 것
⓹ Login, Signup 버튼을 눌렀을 때에는 ⓷ 작업을 하지 않을 것
{ # 페이지 구조 }
페이지는 Container로 감싸진 Aside, Slider(Content+Nav), Aside 레이아웃 형태로 이루어져 있다. Container, Aside, Slider, Content, Nav는 기본 제공 태그가 아닌 다른 컴포넌트에서 styled-components 형태로 작성해 import 한 것이다.
import { Pagination, Page1, Page2, Page3 } from 'components/About';
const Main = () => {
const pageSlider = [
{ id: 1, component: <Page1 /> },
{ id: 2, component: <Page2 /> },
{ id: 3, component: <Page3 /> },
];
return (
<Container>
<Aside>
<Pagination/>
</Aside>
<Slider>
<Content>
</Content>
<Nav>
</Nav>
</Slider>
<Aside>
<Pagination/>
</Aside>
</Container>
);
};
export default Main;
Aside 안의 페이지 이동 버튼은 Pagination 컴포넌트로 분리하였고 Content에 carousel 될 페이지도 각각의 컴포넌트로 분리하여 pageSlider 배열에 {id: 페이지 index, component: 페이지 content} 형태로 작성하였다.
⓵ >,< 버튼을 눌렀을 때 페이지 이동이 될 것
{ # Aside >, < 페이지 이동 버튼 기능 }
> 버튼 (nextSlide) : 페이지 index + 1 로 이동 (단, 마지막 페이지에는 버튼을 표시하지 않고 다음 페이지로 이동하지 않음)
< 버튼 (prevSlide) : 페이지 index - 1 로 이동 (단, 처음 페이지에는 버튼을 표시하지 않고 마지막 페이지로 이동하지 않음)
Pagination 컴포넌트는 direction (> : next, < : prev), moveSlide (페이지 이동 함수)를 변수로 받는다.
변수의 조건에 맞는 button 요소가 반환된다.
/* component / Pagination.js */
import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from 'react-icons/md';
import 'assets/styles/About/_style.scss';
const Pagination = ({ direction, moveSlide }) => {
return (
<>
<button onClick={moveSlide} className="button">
{direction === 'next' && (
<MdKeyboardArrowRight size={45} className="icon" />
)}
{direction === 'prev' && (
<MdKeyboardArrowLeft size={45} className="icon" />
)}
</button>
</>
);
};
export default Pagination;
slideIndex는 첫번째 페이지의 index인 1로 설정을 해주었다.
nextSlide, prevSlide 함수는 각각의 조건에 맞게 함수를 작성해주고 Pagination의 moveSlide 함수에 넘겨주었다.
만약, slideIndex === 1 일 경우에는 이전 컴포넌트가 없으므로 1이 아닐 경우에만 < 방향의 Pagination을 렌더링해준다.
마찬가지로 slideIndex가 pageSlider의 길이(.length)와 같을 때는 다음 페이지가 없으므로 같지 않을 때만 < 방향의 Pagination을 렌더링한다.
import { Pagination, Page1, Page2, Page3 } from 'components/About';
import 'assets/styles/About/_style.scss';
const Main = (props) => {
const pageSlider = [
{ id: 1, component: <Page1 /> },
{ id: 2, component: <Page2 /> },
{ id: 3, component: <Page3 /> },
];
const [slideIndex, setSlideIndex] = useState(1);
const nextSlide = () => {
if (slideIndex !== pageSlider.length) {
setSlideIndex(slideIndex + 1);
}
};
const prevSlide = () => {
if (slideIndex !== 1) {
setSlideIndex(slideIndex - 1);
}
};
return (
<Container>
<Aside>
{slideIndex !== 1 && (
<Pagination moveSlide={prevSlide} direction="prev"/>
)}
</Aside>
<Slider>
<Content>
{pageSlider.map((obj, index) => {
return (
<div
key={obj.id}
className={
slideIndex === index + 1
? 'slide active-anim'
: 'slide'
}
>
{pageSlider[index]['component']}
</div>
);
})}
</Content>
<Nav>
</Nav>
</Slider>
<Aside>
{slideIndex !== pageSlider.length && (
<Pagination moveSlide={nextSlide} direction="next" />
)}
</Aside>
</Container>
);
};
export default Main;
<Slider> 안의 <Content> 요소를 뜯어서 보자.
pageSlider의 배열을 매핑하고 {pageSlider[index]['component']}를 반환한다.
이렇게만 반환하면 Page1, Page2, Page3이 겹쳐보인다. 따라서 className에 스타일링을 다르게 해서 보여주는 방식을 바꿔준다.
index는 0에서 시작하므로 index+1 === slideIndex와 같을 때(현재 페이지 == 이동페이지)는 className="slide active-anim", 같지 않을 때는 className="slide"가 된다.
<Content>
{pageSlider.map((obj, index) => {
return (
<div
key={obj.id}
className={
slideIndex === index + 1
? 'slide active-anim'
: 'slide'
}
>
{pageSlider[index]['component']}
</div>
);
})}
</Content>
slide active-anim은 opacity를 1로 설정하고 slide는 opacity를 0으로 설정해 보이지 않게 된다.
/* _style.scss */
@mixin size($width: 14px, $height: 14px, $opacity: 1) {
width: $width;
height: $height;
opacity: $opacity;
}
.slide {
@include size($width: 80%, $height: fit-content, $opacity: 0);
position: absolute;
transition: opacity ease-in-out 0.5s;
}
.active-anim {
opacity: 1;
}
⓶ 밑에 발자국 페이지네이션을 눌렀을 때 해당 페이지로 이동할 것
{ # Nav 하단 강아지 발바닥 페이지 이동 기능 }
강아지 버튼을 누르면 해당 페이지 Index로 넘어간다.
해당 기능을 실행할 함수는 movePaw 함수이고 건내받은 index를 setSlideIndex에 설정해주면 된다.
const Main = (props) => {
const pageSlider = [ ... ];
const [slideIndex, setSlideIndex] = useState(1);
...
const movePaw = (index) => {
setSlideIndex(index);
};
return (
<Container>
<Aside> ... </Aside>
<Slider>
<Content>
{pageSlider.map((obj, index) => {
return (
<div
key={obj.id}
className={
slideIndex === index + 1
? 'slide active-anim'
: 'slide'
}
>
{pageSlider[index]['component']}
</div>
);
})}
</Content>
<Nav>
{Array.from({ length: 3 }).map((item, index) => (
<IoPawSharp
key={index}
onClick={() => movePaw(index + 1)}
className={
slideIndex === index + 1 ? 'paw active' : 'paw'
}
size={15}
/>
))}
</Nav>
</Slider>
<Aside> ... </Aside>
</Container>
);
};
export default Main;
<Nav> 태그안에서는 길이가 3인(pageSlide.length 로 설정해도 된다) 배열을 만들어주고 매핑한다.
key를 index로 설정하고 클릭할 때, index + 1 (index는 0에서 부터 시작, pageSlide는 1부터 시작)를 movePaw에 건내주면 된다.
마찬가지로 className을 조건에 따라 다르게 설정해 해당 페이지 일때는 paw active, 아닐 경우는 paw로 설정해주면 된다.
눌리지 않을 때 (paw)에는 opacity : 0.5로, 눌렸을 때는 opacity: 1로 설정해 다르게 보여주면 된다.
/* _style.scss */
@mixin size($width: 14px, $height: 14px, $opacity: 1) {
width: $width;
height: $height;
opacity: $opacity;
}
.paw {
@include size($width: 12px, $height: 12px, $opacity: 0.5);
margin: 5px;
color: theme.$main-color;
&:hover {
@include size;
cursor: pointer;
transition: ease-in 0.4s;
}
}
.paw.active {
@include size;
}
⓷. ⓵,⓶ 작업을 하지 않고도 5초마다 화면이 넘어갈 것
⓸. >,<,발자국 버튼을 눌렀을 때 ⓷ 작업을 초기화할 것 -> 구현 못함
{ # 5초 화면 전환 애니메이션}
5초마다 화면을 전환해주기 위해 (딜레이) useInterval 함수를 utils에 만들고 import해 사용했다.
👇🏻 참고글
setInterval()을 쓰지 않은 이유는 오류가 발생했기 때문이다. setInterval을 몇번 사용해본 결과, setInterval은 원하는 delay를 완전히 보장하지 못하기 때문에(함수를 실행하는 시간 조차 delay에 포함시킨다) 커스텀 함수를 사용하기로 했다.
/* utils/useInterval.js */
import { useRef, useEffect, useCallback } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef();
const intervalIdRef = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
intervalIdRef.current = setInterval(tick, delay);
}
const id = intervalIdRef.current;
return () => {
clearInterval(id);
};
}, [delay]);
}
export default useInterval;
렌더링이 일어나지 않더라도 함수가 실행될 수 있게 useRef를 이용해 savedCallback를 저장했다.
[첫 번째 useEffect -> callback이 바뀔 때마다 실행]
- callback이 달라질 때마다 savedCallback을 업데이트 해준다.
[두 번째 useEffect -> delay 값이 바뀔 때마다 실행]
- tick()이 실행되면 callback 함수가 실행된다.
- 만약 delay가 null이 아닐 경우, intervalIdRef에 setInterval(tick, delay)값을 업데이트 해주고 실행시킨다.
- 이 후 언마운트 될 때 clearInterval을 실행한다.
작성한 함수를 Main.js에 작성해 사용하면 된다.
delay는 5초(5000)로 설정하고 맨 처음 정한 아래의 규칙을 충족하기 위해 isRunning 변수를 생성했다.
⓹ Login, Signup 버튼을 눌렀을 때에는 ⓷ 작업을 하지 않을 것
Login, Signup 버튼은 부모 컴포넌트에서 props.isBlur로 건네 받아 상태를 제어한다.
props.isBlur === false 일때는, 버튼이 눌리지 않은 상태이다.
props.isBlur === true 일때는, 버튼이 눌린 상태이다. => setIsRunning(false) : 애니메이션 동작 안함
/* Main.js */
const delay = 5000;
const [isRunning, setIsRunning] = useState(true);
useEffect(() => {
if (props.isBlur === true) {
setIsRunning(false);
} else {
setIsRunning(true);
}
}, [props.isBlur]);
useInterval(
() => {
if (slideIndex === pageSlider.length) {
setSlideIndex(1);
} else setSlideIndex(slideIndex + 1);
},
isRunning ? delay : null,
);
const nextSlide = () => {
if (slideIndex !== pageSlider.length) {
setSlideIndex(slideIndex + 1);
}
};
const prevSlide = () => {
if (slideIndex !== 1) {
setSlideIndex(slideIndex - 1);
}
};
const movePaw = (index) => {
setSlideIndex(index);
};
isRunning이 true일 경우, delay를 실행시키고, 그렇지 않을 경우에는 useInterval을 실행시키지 않는다.
페이지는 1 -> 2-> 3-> 1 -> 2 -> ... 의 순서로 5초마다 자동으로 이동하게 된다.
{ # 구현 결과 }
{ # 전체 코드 }
https://github.com/wpetTeam/wpet-client/tree/feature/src
위의 git링크에서 아래의 파일들을 확인하면 된다.
src / layout / About / Main.js
src / Assets / Style / About / style.js
src / Assets / Style / About / _style.scss
src / components / About
src / utils / useInterval.js