요즘 웹사이트에서는 가로 스크롤을 직접 움직이는 대신 어플리케이션처럼 마우스로 컨텐츠를 움직이는 Swiper(좌우 드래그 스크롤) 기능을 제공하는 사이트들이 많다. 따라서 이 기능을 구현해 보았다.
1. clientX, clientY
2. offsetX, offsetY
3. pageX, pageY
4. screenX, screenY
이 기능에서 사용할 마우스 이벤트는 mousedown, mouseup, mousemove, mouseleave
입니다.
pageX
offsetX
mousedown, mouseup, mousemove, mouseleave
touchstart, touchmove, touchend, touchcancel
여기서 중요한 것은 walk이다. 사용자가 마우스 혹은 터치 이벤트를 통해 실제로 움직인 거리가 된다. 위 그림처럼 사용자가 현재 이미지 왼쪽에 있는 이미지를 보기 위해 마우스를 왼쪽에서 오른쪽으로 끌어당기는 상황이다. 이 상황에서 start, current는 다음과 같고 walk는 current에서 start를 뺀 값이다.
여기서 스크롤바의 위치를 옮기기 위해 마우스를 클릭했던 지점의 스크롤 위치 startScrollLeft
에서 walk
를 빼주면 해결된다.
반대의 상황도 마찬가지이며 이를 코드로 구현하면 아래와 같다.
import { RefObject, useEffect } from 'react';
export default function useDragScroll(dragRef: RefObject<HTMLElement>) {
let isDown = false; // 사용자가 마우스를 눌렀는지 판단할 변수 선언
let startX: number; // 사용자가 마우스를 누른 시작 지점 변수 선언
let startScrollLeft: number; // 사용자가 마우스를 눌렀을 때 scrollLeft의 위치 변수 선언
const mouseTouchDown = (e: MouseEvent | TouchEvent) => { // 마우스를 눌렀을 때 이벤트 함수
const ref = dragRef.current; // dragRef.current가 아래에서 반복적으로 사용되기에 선언
if (ref) {
isDown = true; // 마우스가 눌림
if (e.type === 'mousedown' && 'pageX' in e) { // 이벤트 타입이 mousedown이고 e객체에 pageX 속성이 있을 때, 즉, 마우스이벤트를 실행했을 때
startX = e.pageX - ref.offsetLeft; // 마우스를 누른 시작 지점에 pageX - offsetLeft 할당
} else if (e.type === 'touchstart' && 'touches' in e) { // 이벤트 타입이 touchstart이고 e객체에 touches 속성이 있을 때, 즉, 터치이벤트를 실행했을 때
startX = e.touches[0].pageX - ref.offsetLeft; // 마우스를 누른 시작 지점에 pageX - offsetLeft 할당
}
startScrollLeft = ref.scrollLeft; // 마우스를 눌렀을 때 scrollLeft의 위치는 ref의 scorllLeft로 할당
}
};
const mouseTouchLeave = (e: MouseEvent | TouchEvent) => { // 마우스가 해당 target을 벗어났을 때 실행하는 이벤트 함수
isDown = false; // 마우스를 뗐다고 판단
};
const mouseTouchUp = (e: MouseEvent | TouchEvent) => { // 마우스를 뗐을 때 실행하는 이벤트 함수
isDown = false; // 마우스를 뗐다고 판단
};
const mouseTouchMove = (e: MouseEvent | TouchEvent) => { // 마우스를 target 내에서 움직일 때 실행하는 이벤트 함수
const ref = dragRef.current; // dragRef.current가 아래에서 반복적으로 사용되기에 선언
if (!isDown) return; // 마우스가 눌린 상태라면 리턴
if (e.cancelable) e.preventDefault(); // 이벤트 취소가 가능할 때, 마우스를 움직이면서 지나가는 경로에 있는 다른 이벤트를 발생하는 현상을 방지
if (ref) {
if (e.type === 'mousemove' && 'pageX' in e) {
const currentX = e.pageX - ref.offsetLeft; // 현재 위치는 pageX에서 offsetLeft를 뺀 값
const walk = currentX - startX; // 변위 즉, 움직인 거리는 현재 위치에서 시작 위치를 뺀 값
ref.scrollLeft = startScrollLeft - walk; // ref의 scrollLeft 위치에 마우스를 누를 때 scrollLeft 값에서 움직인 거리만큼 뺀다.
} else if (e.type === 'touchmove' && 'touches' in e) {
const currentX = e.touches[0].pageX - ref.offsetLeft;
const walk = currentX - startX;
ref.scrollLeft = startScrollLeft - walk;
}
}
};
useEffect(() => {
const ref = dragRef.current; // dragRef.current가 아래에서 반복적으로 사용되기에 선언
if (ref) { // 마운트 시에 이벤트를 등록
ref.addEventListener('mousedown', mouseTouchDown);
ref.addEventListener('mouseleave', mouseTouchLeave);
ref.addEventListener('mouseup', mouseTouchUp);
ref.addEventListener('mousemove', mouseTouchMove);
ref.addEventListener('touchstart', mouseTouchDown);
ref.addEventListener('touchcancel', mouseTouchLeave);
ref.addEventListener('touchend', mouseTouchUp);
ref.addEventListener('touchmove', mouseTouchMove);
}
return () => {
if (ref) { // 언마운트 시에 이벤트 삭제
ref.removeEventListener('mousedown', mouseTouchDown);
ref.removeEventListener('mouseleave', mouseTouchLeave);
ref.removeEventListener('mouseup', mouseTouchUp);
ref.removeEventListener('mousemove', mouseTouchMove);
ref.removeEventListener('touchstart', mouseTouchDown);
ref.removeEventListener('touchcancel', mouseTouchLeave);
ref.removeEventListener('touchend', mouseTouchUp);
ref.removeEventListener('touchmove', mouseTouchMove);
}
};
}, [dragRef]);
}
상태를 이용하여 이 로직을 구현하게 되면 불필요한 렌더링이 정말 수도 없이 일어나게 된다. 이 렌더링을 최적화하기 위해 debounce, throttle
을 사용하게 되면 UX에 악영향을 미치게 된다. 따라서 ref를 이용하여 불필요한 렌더링이 발생하지 않도록 했다. but scrollLeft, pageX 등 reflow를 유발하는 요소들을 사용했기에 개선이 필요하다.