최근 프로젝트에서 모바일에서만 노출되던 화면을 PC 환경에서 그대로 띄워줘야 할 일이 생겼다. 이때 신경써야 할 부분 중 하나가 바로 좌우스크롤이었다. 기존 모바일 브라우저 환경에서는 터치로 스크롤이 가능했지만, 터치가 아닌 마우스를 사용하는 PC 환경에서는 스크롤바로만 스크롤이 가능한 구조였기 때문이다. 즉 유저들은 스크롤바를 직접 움직이거나, 좌우 스크롤이 가능한 마우스, 트랙패드 등의 입력장치를 통해서만 스크롤이 가능하다는 것이었는데, 모바일과 동일한 유저 경험을 줄 수 없다는 문제가 있었다. 따라서 모바일에서 터치로 좌우 스크롤을 조작하는 것처럼, 마우스와 같은 입력장치로도 드래그가 가능하게끔 개선이 필요했다.
다음과 같이 요소들을 수평으로 랜더링해주는 React 컴포넌트가 있다고 해보자. (편의상 Styling은 emotion/styled로 작성했다)
import styled from '@emotion/styled';
const Container = styled.div`
display: flex;
overflow: scroll;
max-width: 800px;
gap: 10px;
`;
function CardList() {
return (
<Container>
{[...Array(5)].map((item, index) => (
<CardItem key={index}/>
))}
</Container>
)
}
css overflow 속성만으로도 간단하게 스크롤을 발생시킬 수 있지만, 요소들을 마우스로 클릭해서 드래그할 경우 의도치 않은 텍스트 하이라이팅이나 이미지가 움직이는 등의 동작이 실행될 것이다. 이는 브라우저에서 자체적으로 이미지나 요소에 대한 드래그 앤 드롭을 지원하기 때문이다.
따라서 드래그가 발생해야 하는 요소에 직접 이벤트 리스너를 걸고, 브라우저의 기본 동작을 실행하는 대신 유저가 드래그 한 만큼 스크롤을 명시적으로 발생시켜줘야 한다.
Event Listeners & Web API
이 작업에 필요한 이벤트 리스너는 다음 네 가지가 있다.
- mousedown: 마우스 포인터가 요소 범위 안에 있으면서 마우스 왼쪽 버튼이 눌러졌을 때 실행된다. 마우스 드래그가 시작되었다는 것을 이 이벤트를 통해 알 수 있겠다.
- mousemove: 마우스 커서의 핫스팟이 요소 범위 안에 있으면서 커서를 움직였을 때 발생한다. mousemove 이벤트에다 스크롤을 발생시켜주는 로직을 넣어야 할 것이다.
- mouseup: 요소 범위 안에서 눌러져있던 마우스 커서를 뗐을 때 발생한다. 이때 드래그 동작을 종료시켜주면 되겠다.
- mouseleave: 포인터 커서가 요소 범위를 벗어났을 때 발생한다. 이때 역시 mouseup과 동일하게 드래그 동작이 종료되어야 할 것이다.
또한 스크롤 위치 계산을 위해 다음과 같은 Web API를 사용하고자 한다.
- clientX: 이벤트가 발생한 곳의 X축 좌표. 이때 페이지가 수평으로 스크롤되었는지의 여부와 관계없이 해당 Viewport의 왼쪽 가장자리를 0으로 측정한다.
- scrollLeft: 요소가 왼쪽으로부터 얼마나 스크롤되었는지에 대한 px 단위의 값. 만약 스크롤할 수 없는 요소라면(overflow 속성이 정의되지 않은 경우) 항상 0으로 고정된다. 이 값을 업데이트하면서 유저가 스크롤바를 움직이지 않더라도 자동으로 스크롤 위치를 업데이트해줄 수 있겠다.
기본 동작 구현
이제 본격적으로 구현에 들어가보자. 목록 컴포넌트에 좌표 계산을 위한 로컬 데이터, 그리고 이벤트 리스너 함수들을 다음과 같이 추가해볼 수 있다. DOM에 접근하기 위한 ref 또한 추가해야 함에 유의하자.
function CardList() {
const containerRef = useRef<HTMLElement>(null);
/** 요소를 드래그하고 있는가? */
const [isDragging, setIsDragging] = useState<boolean>(false);
/** 드래그 시작 시점의 X축 좌표값 */
const [startX, setStartX] = useState<number>(0);
/** 드래그 시작 시점의 스크롤 포지션이 포함된 X축 좌표값 */
const [totalX, setTotalX] = useState<number>(0);
const onDragStart = (e: MouseEvent) => {};
const onDragMove = (e: MouseEvent) => {};
const onDragEnd = (e: MouseEvent) => {};
return (
<Container
ref={containerRef}
onMouseDown={onDragStart}
onMouseMove={onDragMove}
onMouseUp={onDragEnd}
onMouseLeave={onDragEnd}
>
{[...Array(5)].map((item, index) => (
<CardItem key={index}/>
))}
</Container>
)
}
마우스 포인터의 실행 순서에 따르면, 가장 먼저 onMouseDown이 실행될 것이다. 이곳에 마우스 드래그가 시작될 때 수행되어야 할 동작을 다음과 같이 작성할 수 있겠다.
const onDragStart = (e: MouseEvent) => {
setIsDragging(true);
const x = e.clientX;
setStartX(x);
if (scrollerRef.current && 'scrollLeft' in scrollerRef.current) {
setTotalX(x + scrollerRef.current.scrollLeft);
}
};
드래그가 시작되는 순간이므로 isDrag 상태값을 true로 업데이트해준다. 그리고 드래그 시작 시점의 x 좌표값을 clientX API를 사용하여 세팅해준다. 또한 이미 스크롤이 되어 있을 경우를 대비하여 clientX 값에 실제 스크롤 된 위치(scrollLeft)까지 반영한 x 좌표값을 세팅해준다.
이후 유저가 마우스로 드래그를 시도할 경우, onMouseMove가 실행될 것이다. 이곳에 마우스가 드래그될 때 스크롤 위치를 업데이트시켜주는 로직을 다음과 같이 추가할 수 있다.
const onDragMove = (e: MouseEvent) => {
if (!isDragging) return;
const scrollLeft = totalX - e.clientX;
if (containerRef.current && 'scrollLeft' in containerRef.current) {
// 스크롤 발생
containerRef.current.scrollLeft = scrollLeft;
}
};
마우스로 드래그 중이라면, DOM 요소의 scrollLeft 값을 마우스가 이동하는 만큼 업데이트해주면 된다. 그렇지 않을 경우 early return을 시켜줌으로서 계산 로직이 너무 많이 실행되는 것을 막는다.
마지막으로 마우스 드래그가 끝났을 경우, 즉 눌렀던 마우스를 떼거나 커서가 스크롤 영역 밖으로 벗어난 경우에 대한 리스너 함수를 다음과 같이 작성해준다. 드래그가 끝났다고 상태를 업데이트시켜주면 된다.
const onDragEnd = (e: MouseEvent) => {
if (!isDragging) return;
if (!containerRef.current) return;
setIsDragging(false);
};
이제 가로스크롤이 들어가는 요소들을 마우스로 잡아끌 수 있게 되었다.
사용성 개선
그런데 뭔가 걸리는 것이 하나 있다.
드래그가 끝날 때마다 스크롤 내부에 children으로 존재하는 카드 컴포넌트가 클릭된다는 것이었다. 만약 카드를 클릭할 때마다 상세페이지로 이동하는 로직이라도 있다면, 드래그가 끝날 때마다 강제로 페이지가 이동하는 대참사(..)가 발생할 것이다. 또한 텍스트가 있는 위치에서 드래그한다면 글자가 선택되는 등의 브라우저 기본 동작도 사용자 경험을 해친다. 따라서 이런 사이드이펙트를 막기 위한 방어코드를 추가할 필요가 있다.
const preventUnexpectedEffects = useCallback((e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}, []);
const onDragStart = (e: MouseEvent) => {
preventUnexpectedEffects(e)
...
};
const onDragMove = (e: MouseEvent) => {
preventUnexpectedEffects(e)
...
};
const onDragEnd = (e: MouseEvent) => {
...
const endX = e.clientX;
const childNodes = [...(containerRef.current?.childNodes || [])];
const dragDiff = Math.abs(startX - endX)
if (dragDiff > 10) {
childNodes.forEach((child) => {
child.addEventListener('click', preventUnexpectedEffects);
});
} else {
childNodes.forEach((child) => {
child.removeEventListener('click', preventUnexpectedEffects);
});
}
};
우선 브라우저의 기본 동작을 막는 preventDefault, 그리고 이벤트 버블링을 막아주는 stopPropagation을 모든 이벤트 리스너에 걸어준다. 이때 드래그가 끝났을 때(onDragEnd 시점) 발생하는 클릭이벤트를 막기 위해서는 자식 요소들에 대한 클릭 이벤트가 부모 요소인 목록 컴포넌트까지 버블링되지 않도록, 자식 노드(childNode)를 탐색하면서 하나씩 이벤트 전파를 막아줄 필요가 있다. 따라서 자식 노드의 click 이벤트 리스너로 preventUnexpectedEffects 함수를 걸어주었다. 해당 함수는 useCallback으로 메모이제이션해주었는데, 자식 노드의 addEventListener, removeEventListener가 동일한 함수 인스턴스를 참조하도록 하기 위함이다. 유저가 너무 적은 범위 내에서 드래그했을 경우, 클릭을 막지 않도록 하는 오차 범위(dragDiff)도 10px로 설정해두었다.
또한, 마우스 드래그(스크롤) 시 발생하는 mousemove 이벤트는 매우 자주 발생하는 이벤트기도 하다. 따라서 브라우저 연산에 지나친 부담이 가지 않게끔 스로틀링을 걸어주면 부하를 줄일 수 있다.
const throttle = (func: () => void, delay: number) => {
let timer;
if (!timer) {
timer = setTimeout(function () {
timer = null;
func();
}, delay);
}
};
...
const onDragMove = (e: MouseEvent) => {
if (!isDragging) return;
throttle(function() {
preventUnexpectedEffects(e)
const scrollLeft = totalX - e.clientX;
if (containerRef.current && 'scrollLeft' in containerRef.current) {
// 스크롤 발생
containerRef.current.scrollLeft = scrollLeft;
}
}, 100)
};
이렇게 예외처리를 한 결과 기본적인 좌우 드래그 + 요소 클릭을 적절하게 할 수 있는 상태가 되었다.
리팩토링
컴포넌트로 돌아가보면, 목록을 출력해주는 CardList 컴포넌트는 이제 좌우스크롤을 할 수 있게 되었다. 그런데 만약 다른 목록 컴포넌트에서도 좌우스크롤이 필요해진다면 어떨까? 또는 CardList 컴포넌트에서 처리해야 할 비즈니스로직이 쌓이게 된다면 어떨까?
코드의 유지보수성과 재사용성을 위해서라면 미리미리 컴포넌트화를 해두는 것이 좋을 것이다. 따라서 이번에는 CardList에서 분리된, 스크롤 기능만을 처리해주는 DraggableScroller 라는 컴포넌트로 관심사를 분리해보자.
우선, 드래그에 대한 각종 이벤트를 처리해주는 커스텀 훅을 만들어두면 좀더 컴포넌트를 심플하게 만들 수 있다.
// useDraggable.ts
import { useState, useCallback, MouseEvent, RefObject } from 'react';
const throttle = (func: () => void, delay: number) => {
let timer;
if (!timer) {
timer = setTimeout(function () {
timer = null;
func();
}, delay);
}
};
type DraggableHook = {
onMouseDown: (e: MouseEvent) => void;
onMouseMove: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
};
export const useDraggable = (
scrollerRef: RefObject<HTMLElement>
): DraggableHook => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [startX, setStartX] = useState<number>(0);
const [totalX, setTotalX] = useState<number>(0);
const preventUnexpectedEffects = useCallback( (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}, []);
const onDragStart = (e: MouseEvent) => {
preventUnexpectedEffects(e)
setIsDragging(true);
const x = e.clientX;
setStartX(x);
if (scrollerRef.current && 'scrollLeft' in scrollerRef.current) {
setTotalX(x + scrollerRef.current.scrollLeft);
}
};
const onDragEnd = (e: MouseEvent) => {
if (!isDragging) return;
if (!scrollerRef.current) return;
setIsDragging(false);
const endX = e.clientX;
const childNodes = [...(scrollerRef.current?.childNodes || [])];
const dragDiff = Math.abs(startX - endX)
if (dragDiff > 10) {
childNodes.forEach((child) => {
child.addEventListener('click', preventUnexpectedEffects);
});
} else {
childNodes.forEach((child) => {
child.removeEventListener('click', preventUnexpectedEffects);
});
}
};
const onDragMove = (e: MouseEvent) => {
if (!isDragging) return;
throttle(function () {
// 클릭 등 마우스 이동 외 다른 이벤트 실행되는 것 방지
preventUnexpectedEffects(e);
// 스크롤 포지션
const scrollLeft = totalX - e.clientX;
if (scrollerRef.current && 'scrollLeft' in scrollerRef.current) {
// 스크롤 발생
scrollerRef.current.scrollLeft = scrollLeft;
}
}, 100);
};
return {
onMouseDown: onDragStart,
onMouseMove: onDragMove,
onMouseUp: onDragEnd,
onMouseLeave: onDragEnd,
};
};
그리고 훅을 사용하여 좌우스크롤 컨테이너를 만들어주는 DraggableScroller 컴포넌트를 다음과 같이 만들면 된다. 스크롤은 기본적으로 overflow: scroll 속성을 가질 것이라고 생각하여 기본 스타일을 갖게끔 구성했다. 그 밖에 커스텀해서 쓸 스타일들은 props로 받을 수 있게끔 설계해보았다.
// DraggableScroller.tsx
import { ReactNode, CSSProperties, useRef } from 'react';
import styled from '@emotion/styled';
import { useDraggable } from './useDraggable.ts'
type Props = {
children: ReactNode;
maxWidth?: number;
style?: CSSProperties;
};
type DivStyleProps = {
maxWidth?: number;
};
const Container = styled.div<DivStyleProps>`
display: flex;
overflow: scroll;
max-width: ${(props) => props.maxWidth && props.maxWidth};
`;
const DraggableScroller = ({ children, maxWidth, style }: Props) => {
const containerRef = useRef<HTMLElement>(null);
const events = useDraggable(containerRef);
return (
<>
<Container
maxWidth={maxWidth}
style={style}
ref={containerRef}
{...events}
>
{children}
</Container>
</>
);
};
이제 DraggableScroller 컴포넌트를 사용한 CardList 컴포넌트는 굉장히 심플한 형태가 된 것을 볼 수 있다.
// CardList.tsx
function CardList() {
return (
<DraggableScroller
style={customStyle}
>
{[...Array(5)].map((item, index) => (
<CardItem key={index} onClick={handleClick}/>
))}
</DraggableScroller>
)
}
결론
모바일 디바이스 환경의 터치와 PC 환경의 마우스 이벤트는 프론트엔드 개발을 하면서 지속적으로 마주하는 문제다. 이번에는 드래그와 관련된 컴포넌트를 직접 만들어보며 여러 가지 마우스 UI Event, 그리고 이벤트 버블링에 대해 알아볼 수 있었다. 앞으로도 보다 모바일스러운 어플리케이션 경험을 웹으로 옮겨오기 위한 다양한 시도들을 포스팅해보고자 한다. 이번에 실습해보았던 코드는 Github 에서도 확인해볼 수 있다.
Reference
https://developer.mozilla.org/en-US/docs/Web/API/Element/mousedown_event
https://developer.mozilla.org/ko/docs/Web/API/MouseEvent/clientX
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft
'Web > React' 카테고리의 다른 글
[React] controlled vs. uncontrolled input (2) | 2023.05.11 |
---|---|
[React] 배열 랜더링과 Key (0) | 2022.11.04 |