thumbnail

React로 캐러셀(스와이프) 컴포넌트 만들기

생성일2023. 5. 1.
태그
작성자지한솔

안녕하세요,
오늘은 React로 캐러셀 컴포넌트를 만들어보도록 하겠습니다! 회사업무로도 Swiper 라이브러리를 사용하면서 개발하고 있는데, 가끔 커스텀을 하고싶은 상황도 생기고 정해져있는 API로는 구현이 어려운 내용들이 상당히 많아서 아까운 경우가 많았는데 이번엔 직접 한번 만들어보는 시간을 가져보려고 합니다.
우선 우리는 React + Custom Hook을 사용할 예정이고 캐러셀 컴포넌트지만, pc와 모바일의 스와이프로 동작이 가능하도록 개발할 예정입니다. 만약 좀 괜찮게 나온다면 라이브러리까지 한번 도전..? 해보겠습니다.

캐러셀 컴포넌트란?

캐러셀은 하나의 컴팩트한 영역에 여러 콘텐츠를 표시할 수 있는 시각적으로 매력적이고 공간 절약적인 방법을 제공합니다. 정보를 압축하고 사용자가 여러 페이지를 스크롤하거나 탐색할 필요성을 줄여 웹 사이트나 애플리케이션을 정리하는 데 도움이 될 수 있습니다. 캐러셀은 제품 이미지, 뉴스 헤드라인 또는 프로모션 배너와 같은 주요 콘텐츠를 표시하는 데 특히 유용합니다.

라이브러리로 캐러셀 만들기

캐러셀 구성 요소를 처음부터 만들 수 있지만 프로세스를 단순화하고 추가 기능을 제공하는 많은 라이브러리가 있습니다.
이러한 라이브러리 중 하나는 React 및 TypeScript와 호환되는 반응형 및 사용자 정의 가능한 캐러셀 구성 요소를 제공하는 'react-responsive-carousel'가 있습니다.
이 섹션에서는 react-responsive-carousel 라이브러리 사용법이 아닌 직접 캐러셀 을 설정하고 사용하여 기본 캐러셀 구성 요소를 만드는 과정을 살펴보겠습니다.

캐러셀 만들기

외부 라이브러리에 의존하지 않고 캐러셀 구성 요소를 만드는 것을 선호하는 사용자를 위해 이 포스팅에서는 React, TypeScript 및 CSS를 사용하여 간단한 캐러셀을 만들어보겠습니다.
캐러셀을 처음부터 만들면 모양과 기능을 완전히 제어할 수 있습니다!
우선 swipe가 가능한 캐러셀을 만들거기 때문에, event를 담고있는 Custom Hook을 만들어볼 생각입니다.
import { useRef, useState } from "react"; type SwipeHandlers = { onMouseDown: (e: React.MouseEvent) => void; onMouseMove: (e: React.MouseEvent) => void; onTouchStart: (e: React.TouchEvent) => void; onTouchMove: (e: React.TouchEvent) => void; handleEnd: () => void; }; const useSwipe = (onSwipe: (deltaX: number) => void): SwipeHandlers => { const touchStartX = useRef(0); const [isDragging, setIsDragging] = useState(false); const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); touchStartX.current = e.clientX; }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging) return; const deltaX = e.clientX - touchStartX.current; onSwipe(deltaX); setSwipeOccurred(true); }; const handleTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; const handleTouchMove = (e: React.TouchEvent) => { const deltaX = e.touches[0].clientX - touchStartX.current; onSwipe(deltaX); setSwipeOccurred(true); }; const handleEnd = () => { setIsDragging(false); }; return { onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, handleEnd, }; }; export default useSwipe;
모바일 버전에서도 사용 가능한 TouchEvent와 MouseEvent 를 설정하였습니다. 캐러셀 컴포넌트로부터 onSwipe 메소드를 받아 deltaX값을 변경해줍니다.
그럼 이제 간단하게 캐러셀 컴포넌트를 구상해보겠습니다.
import React, { useState, useCallback, useRef } from "react"; import styled from "styled-components"; import useSwipe from "../../hook/useSwipe"; type Props = { children: React.ReactNode; }; export const Carousel: React.FC<Props> = ({ children }) => { const ref = useRef<HTMLDivElement>(null); const [currentIndex, setCurrentIndex] = useState(0); const [dragX, setDragX] = useState(0); const [translateX, setTranslateX] = useState(0); const handleSwipe = useCallback( (deltaX: number) => { setDragX(deltaX); }, [setDragX] ); const swipeHandlers = useSwipe(handleSwipe); const handleEndSwipe = useCallback(() => { if (!ref.current) return; const clientWidth = ref.current.children[currentIndex].clientWidth; const threshold = clientWidth / 6; if (dragX > threshold) { setCurrentIndex((prevIndex) => (prevIndex === 0 ? ref.current?.children.length! - 1 : prevIndex - 1)); } else if (-dragX > threshold) { setCurrentIndex((prevIndex) => (prevIndex === ref.current?.children.length! - 1 ? 0 : prevIndex + 1)); } setDragX(0); setTranslateX(clientWidth); }, [dragX, currentIndex]); const trackStyle = { transform: `translateX(calc(-${currentIndex * translateX + currentIndex * 6}px + ${dragX}px))`, }; return ( <S.CarouselContainer> <S.CarouselTrack ref={ref} style={trackStyle} {...swipeHandlers} onMouseUp={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} onMouseLeave={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} onTouchEnd={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} > {children} </S.CarouselTrack> </S.CarouselContainer> ); }; const S = { CarouselContainer: styled.div` position: relative; box-sizing: border-box; overflow: hidden; width: 100%; height: 100%; display: flex; justify-content: center; `, CarouselTrack: styled.div` display: flex; position: relative; left: calc(20% / 2); column-gap: 6px; width: 100%; transition: transform 0.3s ease-in-out; img { -webkit-user-drag: none; -khtml-user-drag: none; -moz-user-drag: none; -o-user-drag: none; user-drag: none; -webkit-user-select: none; -khtml-user-select: none; -moz-user-select: none; -o-user-select: none; user-select: none; } `, };
우선 위 코드를 하나씩 살펴보도록 하겠습니다.
  • state: dragX, translateX 는 각각 (유저의 캐러셀 컴포넌트 드래그 x의 값), (캐러셀 컴포넌트의 width 값)
  • function: handleSwipe, handleEndSwipe 는 각각 (유저의 swipe한 x 값을 drag에 저장), (유저의 swipe 이벤트 이후 발생하는 이벤트)
  • trackStyle 는 translateX 로 생성된 값을 바탕으로 swipe 애니메이션 효과를 주기위해 사용합니다.
 
아래에 있는 S 는 css-in-js 인 styled-component로 생성한 내용입니다.
근데 여기서 캐러셀 동작을 수행하는 내부 컴포넌트에 click 이벤트를 넣으면 문제가 발생합니다. 왜? 우리는 mouse와 touch의 이벤트를 위해 컴포넌트를 생성하였으나, swipe도 결국 클릭을 시작으로 드래그 하는 것을 캐치하게 됩니다.
따라서 스와이프만 하려고 하는 동작은 사실상 클릭을 기반으로 하기때문에 클릭 이벤트를 수행하게 되는 겁니다. 이 클릭 이벤트를 방지하기 위해서 지금이 클릭의 이벤트인지, 드래그 상태인지를 체크하고 그 상태 값을 자식 값으로 전달해주어야 합니다.

스와이프, 클릭 이벤트 동시 실행 막기

드래그 상태 추가한 useSwipe

import { useRef, useState } from "react"; type SwipeHandlers = { ... setSwipe: () => void; swipeOccurred: boolean; }; const useSwipe = (onSwipe: (deltaX: number) => void): SwipeHandlers => { ... const [swipeOccurred, setSwipeOccurred] = useState(false); ... const handleMouseMove = (e: React.MouseEvent) => { ... setSwipeOccurred(true); }; const handleTouchMove = (e: React.TouchEvent) => { ... setSwipeOccurred(true); }; const setSwipe = () => { setTimeout(() => { setSwipeOccurred(false); }, 300); }; return { ... setSwipe, swipeOccurred, }; }; export default useSwipe;
우리는 드래그 상태를 체크할 수 있는 swipeOccurred를 추가하였습니다. setSwipe로 swipeOccurred를 변경할때 setTimeout을 사용한 이유로는 click 이벤트도 결국 마우스를 눌렀다 떼는 동작의 일환으로써 결국 click 과 onMouseUp 은 같이 일어날 것이기 때문에 약간의 딜레이를 추가하였습니다.
그럼 이제 캐러셀 컴포넌트에서 swipeOccurred를 받아 자식에게 넘겨주는 작업을 진행해 보도록 하겠습니다. 자식 컴포넌트로 swipeOccured를 전달해주어야 click 이벤트를 수행하기 전 드래그 상태인지를 판별할 수 있을겁니다.
우리는 해당 작업을 위해 react.cloneElement[공식문서]를 사용할 생각입니다. 비록 공식문서에서 깨질 수 있으니 잘 사용하지 않는다 라고 나오고 비슷한 예제로 Alternatives 탭으로 이동시켜 주지만.. 내가 원하는건 그게 아니기 때문에.. 좀 더 좋은 방법이 생각난다면 리팩토링 진행하겠습니다 ㅎㅎ.. 아니면 아시는 분은 댓글로 부탁드립니다!
import React, { useState, useCallback, useRef } from "react"; import styled from "styled-components"; import useSwipe from "../../hook/useSwipe"; type Props = { children: React.ReactNode; }; export const Carousel: React.FC<Props> = ({ children }) => { ... return ( <S.CarouselContainer> <S.CarouselTrack ref={ref} style={trackStyle} {...swipeHandlers} onMouseUp={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} onMouseLeave={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} onTouchEnd={() => { swipeHandlers.handleEnd(); handleEndSwipe(); swipeHandlers.setSwipe(); }} > {React.Children.map(children, (child) => ( <> {React.cloneElement(child as React.ReactElement<any>, { swipeOccurred: swipeHandlers.swipeOccurred, })} </> ))} </S.CarouselTrack> </S.CarouselContainer> ); };
이렇게 cloneElement를 통해 children에게 props 를 넘겨주게 될 겁니다. 그렇다면 이제 넘겨주는 컴포넌트에서 swipeOccurred 상태인지를 체크 해 click 이벤트를 수행시켜주면 되는것이죠.
이렇게 한다면 문제 없이 드래그와 클릭상태를 구분할 수 있습니다.

결론

캐러셀은 제한된 공간 내에서 여러 콘텐츠를 제시할 수 있는 다양하고 매력적인 방법입니다.
'react-responsive-carousel'과 같은 라이브러리를 활용하거나 처음부터 자신만의 캐러셀 구성 요소를 구축하면 특정 디자인 요구 사항을 충족하는 맞춤형 캐러셀을 만들 수 있습니다.
캐러셀을 만들 때 접근성을 우선시하고 모범 사례를 따라 모든 사용자가 콘텐츠를 쉽게 탐색하고 상호 작용할 수 있도록 해야 합니다. 최대한 사용자가 쉽게 탐색할 수 있도록 해보고 싶었으나.. 아직도 어렵게 느껴지긴 합니다.. 최대한 이쁘게 다듬어 보도록 노력하겠습니다.