마우스 드래그로 좌측 슬라이드 토글 만들기
원티드 프리온보딩 프론트엔드 코스 과제 중 todo 아이템을 마우스로 끌어 왼쪽으로 밀어내는 동작이 필요했다. 방법은 여러 가지가 있겠지만 CSS의 transform: translateX로 요소를 옆으로 옮기는 방식이 가장 먼저 떠올랐다. 위치를 실제로 옮기지 않고 화면상으로만 밀어내는 게 가벼워 보였다.
어떤 마우스 이벤트가 필요했나
드래그라는 동작 자체가 결국 "버튼을 누른 채로 마우스를 움직이는 것"이라, React에서 노출되는 세 가지 마우스 이벤트를 조합해야 했다.
onMouseDown— 마우스 왼쪽 버튼을 누른 순간 발생onMouseUp— 누르고 있던 버튼을 뗀 순간 발생onMouseMove— 마우스가 움직이는 동안 계속 발생. 클릭 여부와 무관하게 호출된다
문제는 onMouseMove가 클릭을 떼도 계속 발생한다는 점이었다. 단순히 onMouseMove에 슬라이드 로직을 붙이면 마우스를 그냥 올려놓기만 해도 요소가 움직였다.
그래서 드래그 중인지를 가리는 isDrag 상태를 따로 두기로 했다. onMouseDown에서 true, onMouseUp에서 false. onMouseMove는 isDrag가 true일 때만 실제 이동을 처리한다.
좌표 값을 어디서 가져올지
마우스 이벤트 객체에는 좌표 관련 프로퍼티가 여러 개 있어서 한 번 정리할 필요가 있었다. 이번 작업에서는 pageX와 getBoundingClientRect().width만 썼지만, 비슷한 작업에서 자주 헷갈리는 값들이라 같이 적어둔다.
| 값 | 의미 |
|---|---|
e.pageX / e.pageY | 문서 전체(스크롤 포함) 기준 좌표 |
e.clientX / e.clientY | 현재 뷰포트(보이는 창) 기준 좌표 |
scrollLeft / scrollTop | 스크롤된 만큼의 X·Y 값 |
e.getBoundingClientRect() | 요소의 위치·크기 정보(DOMRect 객체) |
getBoundingClientRect()는 요소의 top, left, right, bottom, width, height 등이 담긴 DOMRect를 돌려준다. 여기서는 todo 아이템의 너비(width)만 가져와서 절반만큼 밀어내는 기준값으로 썼다.
상태로 무엇을 들고 있어야 하나
드래그 동작은 시간축이 있다. 누른 순간의 좌표와, 움직이는 순간의 좌표를 따로 들고 있어야 비교가 된다. 거기에 어떤 요소를 드래그 중인지도 기억해둬야 다른 요소 위로 마우스가 지나갈 때 엉뚱한 요소가 같이 움직이지 않는다.
const [isDrag, setIsDrag] = useState(false);
const [locationX, setLocationX] = useState(null);
const [taskWidth, setTaskWidth] = useState(null);
const [dragTarget, setDragTarget] = useState(null);isDrag: 드래그 중인지 여부locationX:onMouseDown시점의 X좌표. 드래그 시작 지점taskWidth: 드래그 대상 요소의 너비. translateX에 절반 값을 넣기 위해dragTarget: 지금 드래그 중인 요소의data-id. todoList 배열의 id와 대조해 그 요소만 움직이게 하려고
핸들러 세 개로 동작이 갈린다
onMouseDown, onMouseUp, onMouseMove에 각각 핸들러를 붙였다.
const handleMouseDown = (e) => {
setLocationX(e.pageX);
const { id } = e.currentTarget.dataset;
setDragTarget(id);
setTaskWidth(e.currentTarget.getBoundingClientRect().width);
setIsDrag(true);
};
const handleMouseUp = () => {
setIsDrag(false);
};
const handleMouseMove = (e) => {
const { id } = e.currentTarget.dataset;
const mouseMovePositionX = e.pageX;
const dragDistance = locationX - mouseMovePositionX;
if (isDrag && id === dragTarget && dragDistance >= 80) {
e.currentTarget.style.transform = `translateX(-${taskWidth / 2}px)`;
}
};handleMouseDown은 드래그가 시작되는 지점을 기록한다. 클릭한 위치의 pageX, 클릭된 요소의 data-id(currentTarget.dataset으로 꺼낸다), 그리고 그 요소의 너비를 함께 저장하고 isDrag를 true로 올린다.
handleMouseUp은 단순하다. isDrag만 false로 내린다. 다음 onMouseMove가 발생해도 조건문에서 걸려서 아무 일도 일어나지 않는다.
handleMouseMove가 본 동작이다. 현재 마우스의 pageX와 드래그 시작 지점인 locationX의 차이로 드래그 거리를 잰다. locationX - mouseMovePositionX로 계산하면 마우스가 왼쪽으로 이동한 거리가 양수로 나온다. 이 값이 80px을 넘기면 그제야 transform을 적용한다. 단순히 마우스가 움직인다고 바로 미끄러지면 의도치 않은 클릭이 슬라이드로 오인되기 때문에, 80px이라는 최소 거리 기준을 뒀다.
조건문에 id === dragTarget을 같이 넣은 이유는 위에서 말했듯 다른 아이템 위로 마우스가 지나갈 때 그 아이템까지 같이 미끄러지는 걸 막기 위해서다. onMouseMove 핸들러가 각 아이템마다 붙어 있으면 마우스가 지나가는 모든 아이템에서 호출된다.
translateX에 음수값(-${taskWidth / 2}px)을 넣어 요소를 왼쪽으로 절반만큼 밀어냈다. 이렇게 하면 오른쪽에 가려져 있던 영역(삭제 버튼 같은 것)이 드러난다.