이챙의 개발 log
리액트로 마우스 드래그 가로 스크롤 구현하기 (+합성 컴포넌트로 재사용 높이기)
모바일 브라우저에서는 터치 스크린을 기반으로 하기 때문에 scroll이 있을때 드래그를 하면 자연스럽게 스크롤이 된다. 하지만 데스크탑의 경우에는 마우스를 기반이라 마우스로 드래그를 하면 일반 요소를 드래그 하는걸로 인식이 된다. 드래그 했을때 스크롤이 되지 않기 때문에 직접 드래그 이벤트를 걸어줘야 한다.
이번에 리스트에 가로스크롤을 구현하게 되었는데 모바일&데스크탑에서도 구현이 가능해야 했으므로 직접 드래그 이벤트를 구현해 적용했다.
Drag and Drop API 사용하기
HTML요소를 드래그 앤 드롭 하게 해줄 수 있는 기능이다.
나는 총 5가지의 기능을 사용했다.
1. draggable : 드래그 대상에 true값을 설정해주면 해당 요소가 드래그 된다.
2. dragstart : 드래그가 시작될때 발생.
3. dragover: 드래그 요소가 드롭 영역 위에 있을때
4. dragend: 드래그가 끝났을때
5. dragleave: 드래그 요소가 드롭 영역을 벗어났을때
커스텀 hook으로 드래그 동작 구현 만들기
기본적으로 이번 플젝에서 가로 스크롤 기능이 여러곳에서 사용된다. 중복 사용이 많을 것 같아 커스텀 훅으로 따로 빼줬다.
import { useState, useRef } from 'react';
export default function useDragScroll<T extends HTMLElement>() {
const targetEl = useRef<T>(null);
// 드래그 했을때 플래그
const [isDragging, setIsDragging] = useState(false);
// X축 좌표값
const [, setStartX] = useState<number>(0);
// 드래그 시작 시점의 스크롤 포지션이 포함된 X축 좌표값
const [totalX, setTotalX] = useState<number>(0);
// mouse 움직일때
const onMouseMove: React.DragEventHandler<T> = (e) => {
e.stopPropagation();
e.preventDefault();
};
// mouse 눌렀을때
const onMouseDown: React.DragEventHandler<T> = (e) => {
};
// mouse 뗄때
const onMouseUp: React.DragEventHandler<T> = () => {
};
return {
onMouseMove,
onMouseDown,
onMouseUp,
targetEl,
};
}
이벤트 리스너 함수 총 3가지, 값을 담을 state와 드래그 대상인 DOM요소에 접근 하기 위한 ref로 구성했다.
// mouse 눌렀을때
const onMouseDown: React.DragEventHandler<T> = (e) => {
setIsDragging(true);
const x = e.clientX;
setStartX(x);
// 이미 스크롤 되어 있는 경우
if (targetEl.current && 'scrollLeft' in targetEl.current) {
setTotalX(x + targetEl.current.scrollLeft);
}
};
마우스를 눌렀을때 드래그가 시작되는 순간이니 isDragging을 true로 바꿔준다.
이때 현재 드래그가 일어난 이벤트의 x좌표를(e.clientX) startX값에 넣어 저장한다.
이미 스크롤이 되어 있을 경우를 대비해 이미 스크롤된 위치(scrollLeft)에 x값을 더해준다.
// mouse 움직일때
const onMouseMove: React.DragEventHandler<T> = (e) => {
e.stopPropagation();
e.preventDefault();
if (!isDragging) return;
const scrollLeft = totalX - e.clientX;
if (targetEl.current && 'scrollLeft' in targetEl.current) {
targetEl.current.scrollLeft = scrollLeft;
}
};
이제 드래그를 시작했으면 사용자는 마우스를 움직이며 드래깅 할것이다.
드래그 중일때 현재 드래그 요소의 위치 값(scrollLeft)에 마우스가 이동한 좌표값을 업데이트 해주면 된다.
// mouse 뗄때
const onMouseUp: React.DragEventHandler<T> = () => {
if (!isDragging) return;
if (!targetEl.current) return;
setIsDragging(false);
};
마우스를 뗐을 경우 드래깅중이 아니거나 드래그 요소에 벗어난 경우 리턴하고 드래그를 종료한다.
드래그 커스텀 hook 적용하기
import useDragScroll from '@/hooks/useDragScroll';
export default function List() {
const { onMouseDown, onMouseMove, onMouseUp, targetEl } =
useDragScroll<HTMLUListElement>();
return (
<div className="overflow-hidden">
<ul
ref={targetEl}
draggable
onDragStart={onMouseDown}
onDragOver={onMouseMove}
onDragEnd={onMouseUp}
onDragLeave={onMouseUp}
>
<li>리스트</li>
<li>리스트</li>
<li>리스트</li>
<li>리스트</li>
</ul>
</div>
);
}
드래그 대상인 ul 태그에 draggable와 ref를 적용해주고
훅에서 만들었던 이벤트 리스너 함수들을 WEB API 각 호출에 맞게 넣어주면 된다.
🚨이슈 발생
데스크탑에서 드래그를 하면 위와 같이 이미지가 같이 끌어오게되는 고스트 이미지 현상이 발생한다.
이것을 방지하기 위해 드래그 이미지를 설정해줘야 한다.
const dragImg = new Image();
dragImg.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
// mouse 눌렀을때
const onMouseDown: React.DragEventHandler<T> = (e) => {
// 드래그 이미지의 위치 설정
e.dataTransfer.setDragImage(dragImg, 0, 0);
setIsDragging(true);
const x = e.clientX;
setStartX(x);
// 이미 스크롤 되어 있는 경우
if (targetEl.current && 'scrollLeft' in targetEl.current) {
setTotalX(x + targetEl.current.scrollLeft);
}
};
new Image로 투명한 이미지를 넣어 놓으면 드래그시 브라우저가 dragImg에 설정해놓은 투명한 이미지로 대체한다. 고스트 이미지 현상이 사라지고 정상적인 드래그가 작동된다.
합성컴포넌트로 재사용 가능한 가로스크롤 만들기
드래그 기능을 커스텀 훅으로 빼긴 했지만 매번 사용할 드래스 스크롤을 사용할 리스트마다 ref,draggable,ondragstart 등 관련 API 함수, 훅 들을 계속 불러와서 넣어줘야 한다.
그냥 가로스크롤 관련 컴포넌트만 불러오면 여기저기서 알아서 적용되게는 안될까?? 생각하다 합성컴포넌트로 구성을 바꿨다.
import useDragScroll from '@/hooks/useDragScroll';
import React, { ReactElement } from 'react';
interface HorizontalScrollProps {
elementSize?: string;
parentStyle?: string;
children: React.ReactNode;
}
export default function HorizontalScroll({
children,
elementSize,
parentStyle,
}: HorizontalScrollProps) {
const { onMouseDown, onMouseMove, onMouseUp, targetEl } =
useDragScroll<HTMLUListElement>();
return (
<div className="overflow-hidden">
<ul
ref={targetEl}
draggable
onDragStart={onMouseDown}
onDragOver={onMouseMove}
onDragEnd={onMouseUp}
onDragLeave={onMouseUp}
className={`horizontal-scroll-box flex w-full overflow-x-auto whitespace-nowrap ${parentStyle}`}
>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
const element = child as ReactElement;
return React.cloneElement(element, {
className: `${elementSize} inline-block`,
});
}
return null;
})}
</ul>
</div>
);
}
위에 기존 코드에서 li 부분과 props를 추가했다.
elementSize는 리스트 item요소들의 사이즈들을 동적으로 쓸 수 있게끔 사용하기위해 추가했고 parentStyle은 부모요소(ul)에 필요한 스타일들을 적용하기 위해 추가했다.
<HorizontalScroll elementSize="min-w-40 max-w-40">
{
listData.list.map((item) => (
<ProductItem
key={item.productSeq}
className=""
size="full"
productSeq={item.productSeq}
name={item.name}
price={item.price}
favorite={item.favorite}
brand={item.brand}
image={item.image}
/>
))
}
</HorizontalScroll>
가로스크롤을 사용할 컴포넌트에 HorizontalScroll을 불러와서 children에 관련 list item들을 넣어주면 끝이다.
재사용이 쉽게 가능하고 컴포넌트가 독립적으로 기능을 구성할 수 있다.
직접 사용하면서도 너무 편했다,,;; ㅎ (가로 스크롤들어가는 경우가 많음)
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
const element = child as ReactElement;
return React.cloneElement(element, {
className: `${elementSize} inline-block`,
});
}
return null;
})}
요기 코드를 중점적으로 보자면
Children.map은 children들의 요소들을 순회한다. 즉 list item들을 children으로 받으면 item들을 순회하는 것이다.
React.isValidElement로 child가 유효한 리액트 jsx엘리먼트인지 확인하고 true일시 child에 대한 처리를 계속 진행한다.
리액트 엘리먼트를 직접 수정할수가 없기때문에 cloneElement를 사용해 className의 속성값을 바꾸고 복제된 엘리먼트로 리턴을 해준다.
요렇게 구성을 해주면 item에 스타일 클래스를 추가 했을 경우에도 새로운 스타일로 적용이 되어 렌더링 된다.
개선점
드래그 스크롤도 계속 드래그 할때마다 이벤트가 일어나다 보니 스로틀을 적용해서 주기마다 이벤트들을 불러오도록 리팩토링 하면 좋을 것 같다. 추후에 성능 개선점이 가능 한지 체크해보고 리팩토링 해보도록 하겠다.
참고
- https://jihyundev.tistory.com/33
- https://fe-developers.kakaoent.com/2022/220731-composition-component/
'react' 카테고리의 다른 글
이챙(leechaeng)
프론트엔드 개발도 하고 뛰기도 하고