문제상황
Teengle 프로젝트는 웹뷰프로젝트로, 모바일 기기에서 실행되어야하다보니, 여러 정보를 작은 화면에서 조회하기위해서 상당히 많은... Carousel이 필요했다.
Non-Indexed Carousel
첫번째 Carousel은 커뮤니티 피드에서 마치 스레드처럼 여러개의 사진을 볼 수 있는 기능에 필요했다.
이 Carousel은 아래 사진 처럼 스크롤한 만큼 자유롭게 이동이가능해야했다.
그냥 overflow scroll로 구성해서는 스크롤 속도가 너무 빨라서 사용할 수 없었다.
Indexed Carousel
두번째 Carousel은 투표기능에서 여러 투표를 슬라이드를 통해 확인하기 위해 필요했다.
이 Carousel은 스크롤시에 요소를 중앙으로 배치시켜주어야 했기에 Non-Indexed와 로직은 비슷하지만, 약간은 다른 형태가 필요했다.
결론적으로 Indexed와 Non-Indexed 각각 두개의 Carousel이 필요한 상황이다.
라이브러리 도입을 하지 않은 이유
라이브러리를 사용할까 고민했다.
사실 이전까지 애용하던 js 애니메이션 라이브러리는 framer-motion이라는 라이브러리였는데, 번들사이즈를 확인해보니 놀라지 않을 수 없었다. (물론 이건 트리쉐이킹은 적용되지 않은 용량이긴하고 framer-motion의 번들사이즈를 줄일 수도 있긴하다 하지만.. 그래도 크다)
특히 우리 서비스는 모바일 환경이다보니 느린 3G환경에서 사용하는 사용자도 충분히 많을 것이다. (나도 데이터를 다써서... 느린 3G를 쓰고있다...)
초기 렌더링시에 라이브러리를 다운받다보면 거의 1초가 걸리게된다는 점에서 라이브러리 없이 직접 구현하는게 좋겠다는 생각을 하게됐다.
이 두가지 Carousel을 라이브러리 없이 리액트 환경에서 하나씩 구현해보자.
구현하기
TouchEvent vs MouseEvent
그렇다 둘은 다른 이벤트이다. 하지만, 우리 서비스는 웹뷰 서비스이므로, 데스크톱에 대해서는 대응을 하지 않을 것이다.( 배포접근자체를 막을 생각이다.)
따라서 이 게시글에서는 TouchEvent만을 다룬다.
기본 개념 생각하기
스크롤이란 무엇인가....
사실 이번에 구현을 하면서 정말 많은 것들을 무의식적으로 사용하고 있구나 라고 느꼈다.
모바일환경에서 스크롤이란 무엇일까?
터치가 처음 시작해서 끝날때까지 끊기지 않고 이어지는 것이다. 그리고 터치가 끝나면, 스크롤이 끝난다.
그말은 스크롤을 구현하려면 처음 터치가 시작된 위치를 기록하고, 이동하는 만큼 요소를 이동시켜주면 된다는 것이다.
구현에 필요한 것들 찾기
그럼 구현에 필요한 것들을 어떻게 얻을 수 있을지 생각해보자.
이벤트에 대한 정보이므로 TouchEvent 핸들러에 제공되는 콜백에 event객체로 얻을 수 있으면 베스트다.
그래서 TouchEvent MDN을 살펴봤다.
TouchEvent는 event객체에 touches를 담고있다. 이는 Touch Object의 집합이다.
그리고 Touch Object는 clientX라는 프로퍼티를 갖는데 이 프로퍼티를 통해서 브라우저 기준으로 터치가된 위치 정보를 가져올 수 있게된다. (스크롤은 포함하지 않는다.)
그리고 터치 이벤트로 touchStart, touchMove, touchEnd이벤트가 있다고 한다. 이거 세개면 충분히 구현할 수 가 있다.
Non-Index Carousel 구현하기
그럼 위의 정보들을 써서 인덱스 없는 스크롤 한 그대로 유지되는 Carousel을 만들어보자.
정리해보면 주요로직은 아래와 같다.
- Touch가 시작되면, 시작 지점을 기록한다.
- Touch가 이어지는동안 시작지점에서 현재 터치되는 요소까지의 거리를 스크롤할 요소의 스크롤 값에 더한 뒤, 스크롤 값으로 넣어준다. 그리고 시작값을 현재 터치가 일어나는 값으로 바꿔준다.(중첩해서 적용되지않도록)
이 과정에서 신경써야 할 것은, 스크롤 할 요소의 총 너비를 벗어나지 않게 할 것 이다.
이건 스크롤 요소의 총너비- 화면너비를 통해서 구할 수 있다.
전체 기본 코드
import { ReactNode, useRef, useState } from 'react';
export default function Carousel({ children }: { children: ReactNode }) {
const [touchStartX, setTouchStartX] = useState(0);//터치 시작위치
const [transX, setTransX] = useState(0); //스크롤 값
const ref = useRef<HTMLDivElement>(null);
const mediateScroll = (scrollValue: number) => {
//스크롤 값이 스크롤 요소를 넘어가지 않도록 처리
if (scrollValue > 0) return 0;
if (ref.current) {
const maxScroll = ref.current.scrollWidth - ref.current.clientWidth; //최대 스크롤 너비는 요소의 스크롤 너비 - 화면너비다
if (maxScroll > -scrollValue) return scrollValue;
return -maxScroll;
}
return 0;
};
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((prev) => mediateScroll(prev + moveWidth));
setTouchStartX(e.touches[0].clientX);
};
return (
<div className="overflow-hidden"> //tailwind를 사용했다.
<div
ref={ref}
className="flex gap-2"
style={{
transform: `translateX(${transX}px)`, //여기서 스크롤을 이동시킨다.
transitionDuration: '300ms',
transitionTimingFunction: 'ease-out',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
>
{children}
</div>
</div>
);
}
이러면 잘 동작한다!
스크롤 속도 적용하기
그러나... 스마트폰에서 빠르게 스크롤하면, 더 많이 스크롤된다는 것을 우리는 본능적으로 알고 있다.
또한, 이게 처리가 안되어있으니 스크롤 모션이 너무 뻑뻑해 보이기도 한다.
위의 로직만으로는 이게 불가능하다. 그래서 나는 조금이나마 이런 사용자 경험을 높혀보고자 스크롤 속도에 따른 추가 스크롤을 구현해보았다.
핵심은 최초로 터치가 시작된 때부터 터치가 끝난시점까지 적은 시간동안 얼마나 많이 이동했느냐이다.
그래서 최초 좌표와 터치가 시작된 시점의 스크롤 정보 데이터를 저장해주었다.
const [startInformation, setStartInformation] = useState({ value: 0, time: new Date(Date.now()) });
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
// 실제 이동에 사용할 시작점을 기록
setTouchStartX(e.touches[0].clientX);
// 가속 구현을 위해 touch 최초의 스크롤 정도와, 시간을 기록한다.
setStartInformation({
value: transX,
time: new Date(Date.now()),
});
};
그리고 터치가 끝난 시점에, 끝난 스크롤 위치-시작한 스크롤 위치 / 터치 과정에서 소요된 시간을 통해서 한번의 슬라이드 과정에서 움직인 이동거리를 구해준다.
그리고 가속 값을 곱해서 최종 스크롤 값을 변경시켜주면 터치가 끝났을때 추가 이동거리를 적용하고 이것이 transition이 적용되어 부드럽게 이동하게된다!
const handleTouchEnd=() => {
const moveDistance = (transX - startInformation.value) / (new Date().getTime() - startInformation.time.getTime());
setTransX((prev) => mediateScroll(prev + moveDistance * ACCELERATION_VALUE));
}
이렇게 Non-Indexed Carousel을 구현할 수 있었다.
완성된 전체 코드
import { ReactNode, useRef, useState } from 'react';
const ACCELERATION_VALUE = 30;
export default function Carousel({ children }: { children: ReactNode }) {
const [startInformation, setStartInformation] = useState({ value: 0, time: new Date(Date.now()) });
const [touchStartX, setTouchStartX] = useState(0);
const [transX, setTransX] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const mediateScroll = (scrollValue: number) => {
if (scrollValue > 0) return 0;
if (ref.current) {
const maxScroll = ref.current.scrollWidth - ref.current.clientWidth;
if (maxScroll > -scrollValue) return scrollValue;
return -maxScroll;
}
return 0;
};
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
// 실제 이동에 사용할 시작점으로,
setTouchStartX(e.touches[0].clientX);
// touch 최초의 시작점, 시간을 기록한다.
setStartInformation({
value: transX,
time: new Date(Date.now()),
});
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
const moveWidth = e.touches[0].clientX - touchStartX;
setTransX((prev) => mediateScroll(prev + moveWidth));
setTouchStartX(e.touches[0].clientX);
};
return (
<div className="overflow-hidden">
<div
ref={ref}
className="flex gap-2"
style={{
transform: `translateX(${transX}px)`,
transitionDuration: '300ms',
transitionTimingFunction: 'ease-out',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={() => {
const moveDistance = (transX - startInformation.value) / (new Date().getTime() - startInformation.time.getTime());
setTransX((prev) => mediateScroll(prev + moveDistance * ACCELERATION_VALUE));
}}
>
{children}
</div>
</div>
);
}