본문 바로가기

환영합니다. 이 블로그 번째 방문자입니다.
프로젝트 일지

[🐾 일지:리액트] react-carousel 패키지 없이 애니메이션 구현하기

{ # 인트로 }

프로젝트 UI를 피그마로 디자인한 후 대망의 개발을 시작했다.

맨 처음 about 페이지의 컴포넌트들을 아래와 같이 구현하려면 4가지를 생각해야 한다.

 

피그마로 짠 것, UI도 직접 짠 것이므로 도용 금지

 

   >,< 버튼을 눌렀을 때 페이지 이동이 될 것
⓶   밑에 발자국 페이지네이션을 눌렀을 때 해당 페이지로 이동할 것
   ⓵,⓶ 작업을 하지 않고도 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초마다 자동으로 이동하게 된다. 


{ # 구현 결과 }

4번을 구현하지 못해 약간의 버그가 있다. 조만간 수정해보도록 하겠당...
커서가 가만히 멈춰있을 때 5초마다 화면이 이동한다.

 


{ # 전체 코드 }

 

https://github.com/wpetTeam/wpet-client/tree/feature/src

 

GitHub - wpetTeam/wpet-client: 반려견 다이어리 wpet 프로젝트 프론트엔드

반려견 다이어리 wpet 프로젝트 프론트엔드. Contribute to wpetTeam/wpet-client development by creating an account on GitHub.

github.com

 

위의 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