Indexed Carousel
이제 Index 기반의 Carousel을 구현해보자.
사실 로직은 이전 글과 거의 동일하다. 시작 터치 위치를 기억하고, 움직일때 transX를 변경하고, transX를 기반으로 translateX를 통해 스크롤을 구현한다.
여기서 Index를 추가하고, 특정 scroll값을 넘으면 인덱스를 변경시켜 주고, translateX값을 Index기반으로 작동하도록 만들어주면된다.
그래서 handleTouchStart와 handleTouchMove콜백함수는 Non-Indexed와 동일하다.
// Non-Indexed와 동일하다.
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setTouchStartX(e.touches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
const moveWidth = e.touches[0].clientX - touchStartX;
setTransX(moveWidth);
};
Index State를 추가해주자.
const [index, setIndex] = useState(0);
이제 끝났을때와 translateX가 문제다.
어떻게 이걸 인덱스 기반으로 작동시킬 수 있을까?
내가 어떻게 작동하기 원하는지, 기대하는 것을 작성해보자.
- 임계값보다 많이 scroll했다면, 오른쪽으로 scroll했다면 index를 하나 늘리고, 왼쪽으로 scroll했다면 index를 하나 줄인다.
- index*각 요소 너비만큼 스크롤을 이동시킨다.
여기서 1번은 handleTouchEnd시에, 2번은 translateX에서 일어나야한다.
즉, handleTouchEnd에서 조정해준 index를 기반으로 translateX값을 변경하면된다.
인덱스마다 얼마나 이동시켜 줄 것인가?
인덱스 기반으로 이동시키다보니 인덱스 한칸의 너비를 어떻게 주어야 요소가 중앙에 올것인가?를 고민하게됐다.
그러나 요소 배치를 어떻게 하냐에 따라 어떻게 중앙에 배치시킬 수 있을지가 달라진다.
요소를 순서대로 그냥 나열
첫번째로 아래와 같이 요소의 너비와 관계없이 요소를 주르륵 배치했을때다.
이때는 정확한 자식 요소들의 width값을 알아야 중앙에 위치시킬 수 있다.
하지만 내가 원하는 IndexCarousel은 children을 받아서 어떤 요소든 Carousel로 배치시킬 수 있도록 만들 것이기 때문에 정확한 값에 의존하게 만들고 싶지는 않았다.
내부 요소를 스크롤 요소의 보이는 너비와 동일하게 맞추기
요소를 기본적으로 스크롤 요소가 화면에 보이는 너비와 동일하게 맞춰주고, 내부에서 padding을 줘서 중앙에 맞추면 스크롤은 스크롤 요소가 화면에 보이는 만큼만 줘도 된다.
전체 화면너비가 아닌, 스크롤한 요소의 화면너비로 맞춰준 이유는, 스크롤 요소의 보이는 영역은 줄어들 수 있기 때문이다.
그러면 index*스크롤wrapper요소의 너비 로 index기반의 슬라이드를 구현할 수 있다.
그래서 아래와 같이 style을 주었다.
export default function IndexCarousel({ children }: { children: ReactNode }) {
const getSliderWidth = () => {
if (ref.current) {
return ref.current.clientWidth; //스크롤 wrapper의 화면에 보이는 너비를 가져온다.
}
return 0;
};
...
return (
<div className="overflow-hidden">
<div
ref={ref}
className="flex"
style={{
transform: `translateX(${-index * getSliderWidth() + transX}px)`, //transX를 주지 않으면 중간 스크롤 중에 이동하지 않는다.
transitionDuration: '300ms',
transitionTimingFunction: 'ease-out',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
</div>
그러면 터치가 끝났을때 index와 transX값을 조정해주기만 하면된다.
const handleTouchEnd = () => {
const limitPage = Children.count(children) - 1; //children의 갯수-1만큼 스크롤 가능하게 만든다.
if (transX > MIN_MOVE) { //지정한 최소값보다 큰 경우만 index를 조정한다.
setIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (transX < -MIN_MOVE) {
setIndex((prev) => (prev < limitPage ? prev + 1 : prev));
}
setTransX(0); //translateX에서 준 +transX때문에 0으로 지정해주어야 인덱스에 딱 맞게 지정이 된다.
setTouchStartX(0); //터치 위치를 초기화한다.
};
그럼 이제 인덱스 기반의 Carousel이 동작한다.
전체 코드
import { Children, ReactNode, useRef, useState } from 'react';
const MIN_MOVE = 60;
export default function IndexCarousel({ children }: { children: ReactNode }) {
const [touchStartX, setTouchStartX] = useState(0);
const [transX, setTransX] = useState(0);
const [index, setIndex] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setTouchStartX(e.touches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
const moveWidth = e.touches[0].clientX - touchStartX;
setTransX(moveWidth);
};
const getSliderWidth = () => {
if (ref.current) {
return ref.current.clientWidth;
}
return 0;
};
const handleTouchEnd = () => {
const limitPage = Children.count(children) - 1;
if (transX > MIN_MOVE) {
setIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (transX < -MIN_MOVE) {
setIndex((prev) => (prev < limitPage ? prev + 1 : prev));
}
setTransX(0);
setTouchStartX(0);
};
return (
<div className="overflow-hidden">
<div
ref={ref}
className="flex"
style={{
transform: `translateX(${-index * getSliderWidth() + transX}px)`,
transitionDuration: '300ms',
transitionTimingFunction: 'ease-out',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
</div>
);
}
문제점
문제는 우리 디자인은 양 옆의 요소가 약간 보여야 하고, 중앙에 온 요소가 다른 요소보다 커야한다는 것이다.
Index Carousel을 훅으로 만들기
기존의 IndexCarousel로직을 그대로 사용하되, 약간만 바뀌기 때문에 IndexCarousel 컴포넌트를 useIndexCarousel로 변경해줄 것이다.
import { useRef, useState } from 'react';
export default function useIndexCarousel(pageLimit: number, minMove = 60) {
const [touchStartX, setTouchStartX] = useState(0);
const [transX, setTransX] = useState(0);
const [index, setIndex] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setTouchStartX(e.touches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
const moveWidth = e.touches[0].clientX - touchStartX;
setTransX(moveWidth);
};
const getSliderWidth = () => {
if (ref.current) {
return ref.current.clientWidth;
}
return 0;
};
const handleTouchEnd = () => {
const limitPage = pageLimit;
if (transX > minMove) {
setIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (transX < -minMove) {
setIndex((prev) => (prev < limitPage ? prev + 1 : prev));
}
setTransX(0);
setTouchStartX(0);
};
const style = {
transform: `translateX(${-index * getSliderWidth() + transX}px)`,
transitionDuration: '300ms',
transitionTimingFunction: 'ease-out',
};
return { style, ref, handleTouchStart, handleTouchMove, handleTouchEnd, index };
}
이걸 사용해서 중앙에 온 요소가 커지고, 양 옆요소가 살짝 보이는 ScaleCarousel을 만들어 줄것이다.
기본 아이디어
기존의 요소 배치는 아래 그림과 같았다.
중앙요소가 다른 요소보다 더 크게 보여야하므로 요런식으로 처리하면 된다.
이걸 하려면 자식요소에서 index값을 받아서 transform 처리를 해주어야한다.
그런데 내가 만든 Carousel은 children props을 통해서 자식요소를 렌더링하기때문에 그냥 렌더링해서는 자식요소에서 index를 받을 수 없다는 문제가 있다.
이를 해결하기 위해서 ScaleCarouselProvider를 통해서 자식요소에 index를 내려주었다. 그리고 Wrapper.tsx를 작성해주었다.
context.ts
import { createContext, useContext } from 'react';
export const ScaleCarouselIndex = createContext<number>(0);
export const useScaleCarouselIndex = () => {
return useContext(ScaleCarouselIndex);
};
Wrapper.tsx
import { Children, ReactNode } from 'react';
import { useIndexCarousel } from '@repo/hooks';
import { ScaleCarouselIndex } from './context';
const MIN_MOVE = 60;
export default function Wrapper({ children }: { children: ReactNode }) {
const pageLimit = Children.count(children) - 1;
const { index, ref, style, handleTouchStart, handleTouchMove, handleTouchEnd } = useIndexCarousel(pageLimit, MIN_MOVE);
return (
<div className="overflow-hidden">
<div
ref={ref}
className="flex"
style={style}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<ScaleCarouselIndex.Provider value={index}>{children}</ScaleCarouselIndex.Provider>
</div>
</div>
);
}
이제 자식요소에서 useScaleCarouselIndex를 통해 자신의 map인덱스와 중앙에 위치한 인덱스를 비교하여
중앙 인덱스 양옆에 있는 요소의 경우 scale과 translate를 통해 중앙으로 당겨와주면 된다.
import { ReactNode } from 'react';
import { useScaleCarouselIndex } from './context';
const getTransform = (mapIndex: number, scaleIndex: number) => {
const MOVE_VALUE = 8.5;
const SCALE_POINT = 0.8;
if (mapIndex === scaleIndex) return '';
if (mapIndex === scaleIndex + 1) return `scale(${SCALE_POINT}) translate(-${MOVE_VALUE}rem)`;
if (mapIndex === scaleIndex - 1) return `scale(${SCALE_POINT}) translate(${MOVE_VALUE}rem)`;
};
export default function Card({ index, children }: { index: number; children: ReactNode }) {
const scaleIndex = useScaleCarouselIndex();
return (
<div
className="flex-shrink-0 w-full"
style={{
transform: getTransform(index, scaleIndex),
transitionDuration: '300ms',
}}
>
<div className="px-10 w-full">{children}</div>
</div>
);
}
이러면 이제 옆요소가 잘 보이게된다!
문제 발생
하지만.. Ipad 같이 화면 너비가 넓어지면 양옆 요소가 제대로 보이지 않게된다는 문제가 발생했다.
이는 화면 너비가 넓어짐에 따라 스크롤 Wrapper의 너비가 커지게 되고, 이에따라 scale하는 정도도 같이 커져서 tranlate고정값으로는 요소를 제대로 가져올 수 없기 때문이었다.
translate %로 주기
translate를 %로 주면 해결될 일이라고 생각했다...만
translate의 %비율이 scale처리가 완료된 요소의 %로 들어가면서... 예상한대로 translate되지 않았다.
Scale Value에반응형 적용
화면 너비 500을 기점으로 보이지 않아서 500을 기준으로 scale을 화면너비에 따른 반응형으로 처리해주었다.
const getTransform = (mapIndex: number, scaleIndex: number) => {
const MOVE_VALUE = 8.5;
const SCALE_POINT = window.screen.width > 500 ? 0.9 : 0.8;
if (mapIndex === scaleIndex) return '';
if (mapIndex === scaleIndex + 1) return `scale(${SCALE_POINT}) translate(-${MOVE_VALUE}rem)`;
if (mapIndex === scaleIndex - 1) return `scale(${SCALE_POINT}) translate(${MOVE_VALUE}rem)`;
};
이제 화면너비 1200까지 대응이 된다! 1200까지 대응한 이유는 가장 큰 테블릿의 화면너비가 1200이기 때문이다.
합성 컴포넌트로 마무리
이제 이걸 index.ts로 묶어서 합성 컴포넌트로 만들어주자.
import Card from './Card';
import Wrapper from './Wrapper';
export default { Card, Wrapper };
이제 야무지게 쓰면된다.
import { Loading } from '@repo/components';
import useVoteListQuery from '@/api/useVoteListQuery';
import ScaleCarousel from './ScaleCarousel';
import Vote from './Vote/Vote';
export default function TodayVote() {
const { data } = useVoteListQuery();
if (!data) return <Loading />;
return (
<ScaleCarousel.Wrapper>
{data.todayVoteList.map((vote, index) => (
<ScaleCarousel.Card key={vote.voteListId} index={index}>
<Vote data={vote} />
</ScaleCarousel.Card>
))}
</ScaleCarousel.Wrapper>
);
}