🚨 (24.10.22 업데이트) IOS mobile 상에서 glitch가 있는 코드입니다. 사용 시 유의바라겠습니다!
TL;DR: class component로 된 라이브러리를 funtion component로 마이그레이션하여 3D 캐러샐을 만들다
이번 포스팅에서는 react-spring
만을 이용하여 3D 캐러샐을 만드는 과정에 관해 이야기할 예정이다. react-spring-3d-carousel 라이브러리를 참고하여 구현한다.
원래는 해당 라이브러리를 그대로 사용할 예정이었지만 위에서 보다시피 내비게이션 버튼 제어 및 스타일 커스텀이 불가능하여 새로 만들게 되었다. 그래서 내비게이션 버튼이 필요없거나 아래 스타일도 상관이 없다면 참고한 라이브러리를 그대로 사용해도 문제없다.
캐러샐은 일반적으로 이미지와 같은 콘텐츠가 여러 개 있을 때 해당 콘텐츠들을 일정한 간격을 순서대로 보여주는 UI를 의미한다. 대부분 서비스, 특히나 넷플릭스, 네이버 웹툰과 같은 콘텐츠 위주 서비스의 랜딩 페이지에서 주로 활용되고 있다.
TMI 캐러샐(Carousel) 단어 자체는 원래 회전목마를 의미한다.
여기서 3D 캐러샐이란 일반적인 캐러샐과 달리 실제 회전목마를 앞에서 보는 시점과 유사하게 이미지의 깊이가 존재하는 캐러샐을 의미한다. 썸네일과 데모에서 보는 것처럼 이미지가 이동될 때마다 가장 앞에 있는 것을 알 수 있어야 한다.
레퍼런스를 찾아보면서 대체로 '3D 캐러샐'은 실제 회전목마처럼 이미지가 바라보는 방향이 중앙에서 바깥으로 향해 있는데, 이번 포스트에서 말하는 3D 캐러샐은 이미지가 항상 한 쪽을 향하는 형태로 정의한다. 일반적인 경우는 별도의 라이브러리 없이 HTML, CSS, JS로도 충분히 구현할 수 있다.
npm create vite@latest {프로젝트 폴더 명} -- --template react-ts
cd {프로젝트 폴더 명}
# node 20.10.0
npm i react-spring react-icons && npm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.ts
/** @type {import('tailwindcss').Config} */
export default {
ccontent: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
그리고 vite로 초기 세팅된 코드, 파일(App.css)을 지워준다.
어떤 형태든 몇 장이든 상관없다.
여기서는 정적인 이미지 파일을 준비하여 프로젝트 내부에서 관리할 예정이다.
넉넉하게 10장을 준비해 보겠다. 이미지 레퍼런스는 전부 unsplash에서 가져왔다.
캐러샐 관련 컴포넌트는 다음의 구조로 되어 있다.
각 컴포넌트의 역할은 이렇다.
carousel-container
: 최상위 컴포넌트, 모바일 스크롤 이벤트와 뒤에 있는 카드 클릭 시 이동을 제어carousel
: slide와 navigate-button의 조합, navigate-button의 이미지 이동을 제어, 보이는 slide의 개수 제어navigate-button
: 그저 버튼slide-content
: 각 이미지를 표시하는 컴포넌트slide
: slide-content를 포함하며, 세부 애니메이션이 적용됨라이브러리 및 이벤트 핸들러 적용 전 필요한 마크업 작업을 해준다.
carousel-container
, carousel
, navigate-button
, slide-content
에 필요한 작업을 해준다.
navigate-button.tsx
import { FaArrowAltCircleLeft, FaArrowAltCircleRight } from "react-icons/fa";
interface NavigationButtonProps {
type: "prev" | "next";
onClick: () => void;
}
export function NavigationButton({ type, onClick }: NavigationButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={"flex h-[60px] w-[60px] items-center justify-center "}
>
{type === "prev" && (
<FaArrowAltCircleLeft className="w-full h-full opacity-50" />
)}
{type === "next" && (
<FaArrowAltCircleRight className="w-full h-full opacity-50" />
)}
</button>
);
}
slide-content.tsx
interface SlideContentProps {
src: string;
index: number;
}
export default function SlideContent({ src, index }: SlideContentProps) {
return (
<div className="relative h-fit w-fit">
<div className="absolute top-8 left-8 text-3xl font-bold flex flex-col text-white">
{index}
</div>
<img
src={src}
alt={`carousel image - ${index}`}
className="rounded-[8px] !object-cover"
/>
</div>
);
}
carousel.tsx
import { NavigationButton } from "./navigate-button";
interface CarouselProps {
slides: { key: number; onClick: () => void; content: JSX.Element }[];
}
export default function Carousel({ slides }: CarouselProps) {
return (
<div className="relative flex h-full flex-col">
<div className="h-full w-full">
{slides.map((slide: CarouselProps["slides"][number]) => (
<>{slide.content}</>
))}
</div>
<div className="max-pc:hidden absolute flex h-full w-full items-center justify-between">
<NavigationButton
type="prev"
onClick={() => console.log("prev clicked")}
/>
<NavigationButton
type="next"
onClick={() => console.log("next clicked")}
/>
</div>
</div>
);
}
carousel-container.tsx
import Carousel from "./carousel";
import SlideContent from "./slide-content";
interface CarouselContainerProps {
images: string[];
}
export default function CarouselContainer({ images }: CarouselContainerProps) {
const slides = images.map((image, index) => {
return {
key: index,
content: <SlideContent src={image} index={index} />,
onClick: () => console.log(`${index} clicked`),
};
});
return (
<div className="max-w-screen-pc w-full overflow-x-hidden">
<div className="mx-auto my-0 h-[480px] w-4/5">
<Carousel slides={slides} />
</div>
</div>
);
}
그저 내비게이트 버튼을 커스텀하기 위해 새로 코드를 작성했다면 이번 글을 작성하진 않았을 것이다.
라이브러리를 열어보고 '쉽지 않겠다'라는 생각이 먼저 들었는데, 그 이유는 바로 라이브러리가 class component로 작성됐기 때문이다. 2018년 React hook이 등장한 이후 거의 쓰이지 않는 class component... 필자는 개발을 2022년에 시작했는데 역사 속의 코드를 마주하여 당황스러움과 동시에 재밌는 도전이라 생각하여 이 글을 쓰게 되었다.
class component의 기본 문법은 생략하고, 마운팅과 관련된 내장 메서드가 무엇이 있는지, 해당 메서드가 지금 어떤 hook이 되었는지 등을 살펴보고 마이그레이션을 해보겠다.
componentDidUpdate()
는 첫 렌더 때는 호출되지 않고, 업데이트가 발생할 시 호출되는 메서드이다. 보통의 경우, 이전 props와 현재 props가 달라지면서 네트워크 호출이 필요하거나 다른 작업이 필요할 때 쓰인다고 한다. 만약 props 간의 비교 없이 네트워크 호출을 바로 수행하면 무한 루프에 빠질 수 있기 때문에 주의해야 한다.
라이브러리에서는 가장 앞에 있는 slide 이외 다른 slide를 클릭했을 때, 이동해야 할 targetSlide가 변경될 때 호출되어야 하는 경우 코드가 작성되었다.
componentWillUnmount()
는 컴포넌트가 마운트 해제, 삭제되기 전에 바로 호출되는 메서드이다. 타이머 무효화(clearTimeout
), 네트워크 호출 취소, 컴포넌트가 마운트 된 뒤 호출되는 componentDidMount()
에서 생성된 구독 해제 등 어떤 형태의 정리를 할 때 쓰인다. 컴포넌트가 마운트 해제될 때 이런 정리를 하지 않으면 메무리 누수 등의 문제가 발생할 수 있다.
라이브러리에서는 slide가 이동 시 설정된 setTimeout delay를 해제해야 할 때 clearTimeout()
을 호출하여 타이머를 정리하는 코드가 작성되었다.
여기까지 봤을 때 딱 떠오르는 hook은 useEffect
이다. componentDidUpdate()
는 dependency가 걸려있을 때의 useEffect 내부 로직을 수행할 때, componentWillUnmount()
는 useEffect의 return에서 수행할 때 호출되는 부분과 일치한다.
getDerivedStateFromProps()
은 덜 일반적인 라이프 사이클에서 사용되는 메서드이다. Function Component 기준 return에 해당, jsx 부분을 나타내는, render()
메서드가 호출되기 직전에 호출된다. 더불어 초기 마운트, 연속적인 업데이트를 하기 바로 직전에 호출되는 메서드이다. 즉, 컴포넌트 삭제 외 호출되는 메서드이다. 반드시 업데이트될 상태 또는 아무것도 업데이트하지 않으려면 null을 반환해야 한다.
이 메서드는 상태가 props 변화에 끊임없이 의존해야 하는 드문 케이스를 위해 존재한다. props가 변화할 때마다 상태를 동기화해야 하는 상황이 발생하기 때문에 필요하다. 그러나 이런 패턴은 일반적이지 않고, 상태 관리의 복잡성을 높일 수 있기 때문에 자주 사용되지 않는다.
그런데 그 드문 케이스가 이번 캐러셀 구현에 발생하는 케이스이다. 애니메이션이 시작되고 끝날 때 컴포넌트의 props가 변화하고, 이전에 가지고 있던 targetSlide와의 비교가 필요하다. 때문에 getDerivedStateFromProps()
를 사용하여 props 변화에 따라 상태를 업데이트하는 코드가 작성되었다.
이 부분도 useEffect
dependency와 조건을 잘 활용하면 동일하게 구현할 수 있다.
state
=> useState
예시) carousel-container.tsx
여러 마운트 내장 메서드 => useEffect
예시) carouse.tsx
자세한 변경 사항은 관련 commit을 확인바란다!
react-spring은 slide.tsx
에 적용한다.
라이브러리에 있던 react-spring 코드도 Class Component에서 적합한 형태로 되어 있길 때 변경해 주었다.
(그런데 특이하게 라이브러리의 Slide.tsx
만 Function Component다, 그런데 react-spring은 Class Component 기반에서 자주 쓰이는 형태... 왜지?)
여기까지 마이그레이션을 해주었다면, slide.tsx
의 offsetRadius를 수동으로 변경하였을 때 다음과 같이 동작하는 것을 알 수 있다.
(벌써 뭔가 그럴 싸하다)
내비게이트 버튼을 눌렀을 때, 이미지를 눌렀을 때, 모바일 상에서 swipe 했을 때 이동되도록 만들기 위해 이벤트 핸들러를 carousel-container.tsx
, carousel.tsx
에 추가한다.
터치의 경우 TouchEvent의 touches, targetTouches를 사용한다. 그 중 swipe의 원리는 touch가 시작되었을 때(onTouchStart) xDown, yDown을 기록하고, touch가 변화할 때(onTouchMove) touch 값들이 어떻게 이동했는지 diff를 기록하여 먼저 수평 이동인지 확인한다. 그런 다음 왼쪽 swipe인지, 오른쪽 swipe인지 판별한 다음 currentCarousel의 targetSlideIndex를 변경시킨다.
carousel-container.tsx
const getTouches = (e: TouchEvent<HTMLDivElement>) => {
return (
e.touches || e.targetTouches // browser API
);
};
//...
export default function CarouselContainer({ images }: CarouselContainerProps) {
// ...
const handleTouchStart = (e: TouchEvent<HTMLDivElement>) => {
const firstTouch = getTouches(e)[0];
setCurrentCarousel((prev) => {
return {
...prev,
xDown: firstTouch?.clientX || null,
yDown: firstTouch?.clientY || null,
};
});
};
const handleTouchMove = (e: TouchEvent<HTMLDivElement>) => {
if (!currentCarousel.xDown && !currentCarousel.yDown) {
return;
}
const { clientX, clientY } = e.touches.item(0);
const xDiff = (currentCarousel.xDown || 0) - clientX;
const yDiff = (currentCarousel.yDown || 0) - clientY;
// Check if movement is mostly horizontal
if (Math.abs(xDiff) > Math.abs(yDiff)) {
// Horizontal movement
if (xDiff > 0) {
/* left swipe */
setCurrentCarousel((prev) => ({
...prev,
targetSlideIndex: (prev.targetSlideIndex + 1) % slides.length,
xDown: null,
yDown: null,
}));
} else {
/* right swipe */
setCurrentCarousel((prev) => ({
...prev,
targetSlideIndex: prev.targetSlideIndex - 1 + slides.length,
xDown: null,
yDown: null,
}));
}
}
};
return (
<div className="max-w-screen-pc w-full overflow-x-hidden">
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
className="mx-auto my-0 h-[480px] w-4/5"
>
<Carousel
slides={slides}
currentTargetSlideIndex={currentCarousel.targetSlideIndex}
/>
</div>
</div>
);
이 또한 자세한 변경 사항은 commit을 참고한다.
원인: 내비게이트 버튼을 감싸고 있는 div가 {z-index:10}
을 가지고 있고 이미지 전체를 덮고 있어서 클릭 이벤트를 차단하고 있었다.
해결: 내비게이트 버튼을 감싸고 있는 div의 z-index를 제거하고, 버튼에 적용
원인: 라이브러리 상 getDefaultTranslateX()
가 적절한 normalization을 하고 있지 않음
해결: 하나씩 파라미터를 수정해 가면서 그나마 적절한 값으로 지정하여 새롭게 getDefaultTranslateX()
함수를 작성
지금은 거의 쓰지 않는 Class Component를 깊게 알 필요는 없다. 단지 이렇게 라이브러리나 레거시 코드를 뜯으면 등장할 수 있으니 코드를 보자마자 지레 겁부터 먹지 않아도 된다는 말을 하고 싶다.
Class Component를 한 번이라도 본 사람은 알 것이다. 아무 지식이 없다면 컴포넌트 코드 중 어떤 것이 내장 메서드이고, 어떤 것이 직접 작성한 메서드인지 알 수 없다. 마이그레이션을 해야한다면 내장 메서드 이름을 아는 것은 큰 도움이 될 것이다.
추석 때 붙잡았던 라이브러리와 코드인데 다시 보니 고칠 것이 또 다시 보여서 나는 아직 멀었구나라는 것을 깨달았다.
click, touch, swipe 등 인터렉티브한 동작을 구현하는 것이 재밌다. 앞으로 이런 부분을 좀 더 고려하고, 딥다이브해보는 시도를 많이 해봐야 겠다.