지난번 포스팅에서 Web View에서의 Carousel을 구현한데 이어서, 이번에는 Mobile View에서의 Carousel을 구현해보고자 한다.
Web Carousel과 Mobile Carousel의 목적은 본질적으로 비슷하지만, 모바일 환경에 적합하게 구현하기 위해 웹 버전과 다른 점이 꽤 많다.
Mobile Carousel의 특징은 다음과 같다.
1. 모바일 화면 width에 따라, 1개 혹은 2개의 카드가 보여진다.
(Galaxy Fold 사용자거나, 화면을 가로로 볼 때 카드가 2개로 바뀐다)
2. 3초 마다 카드가 오른쪽으로 이동하고 마지막에 다시 원 상태로 돌아온다.
3. 사용자는 터치를 통해 카드를 밀어 한 개씩 넘길 수 있다.
4. 하단의 작은 원 버튼을 터치해서 원하는 순서의 카드로 전환할 수 있다.
5. 사용자의 화면 width를 원활하게 추적하기 위해, custom hook을 활용할 것이다.
Web Carousel 때와 마찬가지로, 사용자의 화면 width를 원활하게 추적하는 custom hook인 useWindowWidth를 활용하고자 한다.
/utils/useWindowWidth.ts
import { useState, useEffect } from 'react';
function useWindowWidth() {
const isClient = typeof window === 'object';
const [windowWidth, setWindowWidth] = useState(isClient ? window.innerWidth : 0);
useEffect(() => {
if (!isClient) return; // SSR일 경우 리턴합니다.
function handleResize() {
setWindowWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [isClient]); // isClient의 변경에 따라 훅을 실행합니다.
return windowWidth;
}
export default useWindowWidth;
Next.js 프로젝트이므로, SSR 상황에서 오류가 발생하지 않도록 조건문을 작성했다.
의존성 관리를 위해, handleResize 함수를 useEffect 내부에 작성했다.
메모리 누수를 방지하기 위해, 컴포넌트가 unmount 될 때, 이벤트 핸들러를 제거했다.
구현한 Mobile Carousel 컴포넌트는 다음과 같다.
import { useEffect, useState, useRef, TouchEvent } from "react";
import WeatherCards from "components/organisms/m/WeatherCards";
import AdCard from "components/templates/m/main/Header/AdCard";
import { CarouselData } from "interfaces/organism";
import useWindowWidth from "utils/useWindowWidth";
interface Props {
carouselData: CarouselData[];
}
const Carousels = ({ carouselData }: Props) => {
const [index, setIndex] = useState(0);
const width = useWindowWidth();
useEffect(() => {
let timeout: NodeJS.Timeout;
if (width < 650) {
timeout = setTimeout(
() => setIndex((prevIndex) => (prevIndex + 1 >= carouselData?.length ? 0 : prevIndex + 1)),
3000
);
} else {
timeout = setTimeout(
() =>
setIndex((prevIndex) => (prevIndex + 1 >= carouselData?.slice(0, carouselData.length / 2).length ? 0 : prevIndex + 1)),
3000
);
}
return () => clearTimeout(timeout);
}, [index, carouselData.length]);
const touchStartPoint = useRef(0);
const handleTouchStart = (e: TouchEvent<HTMLDivElement>) => {
const { screenX } = e.changedTouches[0];
touchStartPoint.current = screenX;
};
const handleTouchEnd = (e: TouchEvent<HTMLDivElement>) => {
const { screenX } = e.changedTouches[0];
// 감도 설정
if (Math.abs(touchStartPoint.current - screenX) < 50) return;
const toRight = touchStartPoint.current > screenX;
if (width < 650) {
if (toRight && index < carouselData.length - 1) {
setIndex((prevIndex) => prevIndex + 1);
} else if (!toRight && index > 0) {
setIndex((prevIndex) => prevIndex - 1);
}
} else {
if (toRight && index < carouselData.length / 2 - 1) {
setIndex((prevIndex) => prevIndex + 1);
} else if (!toRight && index > 0) {
setIndex((prevIndex) => prevIndex - 1);
}
}
};
return (
<div className="flex items-center gap-5 justify-center w-full ">
<div className="overflow-hidden">
<div
className="flex mb-5 ease-in-out duration-500 gap-5"
style={{
transform: `translateX(calc(${index}*-100% - ${index} * ${width > 650 ? "18px" : "20px"}))`,
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{carouselData?.map((asset: CarouselData, i: number) => {
if ((i + 1) % 3 === 0) {
return <AdCard key={i} />;
} else {
return (
<WeatherCards
EXP_CVaRNTS={asset["EXP_CVaRNTS"]}
exchg={asset["HR_ITEM_NM"]}
name={asset["ITEM_ENG_NM"]}
krName={asset["ITEM_KR_NM"]}
ticker={asset["ITEM_CD_DL"]}
key={i}
...
/>
);
}
})}
</div>
<div className="flex gap-1.5 justify-center">
{width > 650
? carouselData.slice(0, carouselData?.length / 2).map(({ ITEM_CD_DL }: { ITEM_CD_DL: string }, i: number) => (
<button
onClick={() => setIndex(i)}
className={`cursor-pointer ${index === i ? "bg-[#0198FF] " : "bg-gray-200 "}w-2 h-2 rounded-20`}
key={i}
/>
))
: carouselData?.map(({ ITEM_CD_DL }: { ITEM_CD_DL: string }, i: number) => (
<button
onClick={() => setIndex(i)}
className={`cursor-pointer ${index === i ? "bg-[#0198FF] " : "bg-gray-200 "}w-2 h-2 rounded-20`}
key={i}
/>
))}
</div>
</div>
</div>
);
};
export default Carousels;
모바일 터치 이벤트를 다루기 위해 screenX
를 활용하였다.
screenX는 터치 이벤트에서 사용되는 속성 중 하나로, 터치 이벤트가 발생한 x 좌표를 나타낸다. 이 값은 뷰포트(viewport)의 왼쪽 상단 모서리를 기준으로 한 터치 지점의 x 좌표이다.
사용자가 왼쪽/오른쪽 방향으로 카드를 끄는 터치 이벤트를 발생시켰을때의 처음 터치 순간의 x좌표와 터치를 끝마칠때의 x좌표 값을 handleTouchStart
, handleTouchEnd
핸들러를 활용하여 추적하고, 카드 넘김 이벤트를 발생시킨다.
이외 코드 설명은 다음과 같다.
사용자 화면의 width를 추적하기 위해 custom hook을 불러왔다.
사용자의 모바일 화면의 width가 650px가 넘어가면, 표시되는 카드가 2개로 변한다.
transform
속성을 활용하기 위해, inline css스타일인 style을 활용했다.
(tailwind css를 활용하므로, 고 수준의 css속성을 다루는데 한계가 있기 때문)
translateX
로 인해, index의 변화에 따라 자식 컴포넌트(WeatherCard
혹은 `AdCard
컴포넌트)의 width의 100%에 더해 18px 혹은 20px 만큼 X축 방향으로 이동하게 된다.
Carousel 하단의 버튼은 간단하게 터치시, index가 해당 순서로 변하면서 index번째 카드로 전환된다.
setTimeout
을 활용하여 3초마다 카드가 오른쪽으로 이동하도록 설정했다.
연이은 포스팅을 통해 Web과 Mobile 에서의 Carousel 컴포넌트를 각각 구현해보았다. Carousel 컴포넌트는 사용자에게 보여주고 싶은 정보들을 보기 편한 UI로 가독성있게 전달하고자 할 때 자주 활용되는 컴포넌트이다.
Web과 Mobile 뷰의 가장 큰 차이는 아무래도, Touch 이벤트의 유/무이다. Web에서 사용자들은 마우스로 상호작용을 하는 반면, Mobile에서는 손가락 터치로 이벤트를 발생시킨다. 터치시 수평 이동을 발생시키기 용이한 screenX
의 사용법과 translateX
사용법을 중점적으로 확인하여 Mobile 환경에서 적합한 Carousel 컴포넌트 구현에 조금이나마 도움이 됐으면 좋겠다.