Skip to main content

Drag And Drop

· 5 min read
이현진

MOZI에서는 할 일을 드래그 앤 드롭을 통해 수정할 수 있어야한다.(예를 들어 할 일 복원) 어떻게 보면 스토리에서 우선순위는 아니였기에 뒤로 미루다가 벨로그에서 좋은 글을 발견하고 글에 나와있는 프로젝트를 그대로 구현해보면서 배우기로 했다.

Drag Event

마우스의 움직임을 바탕으로 element의 위치를 이동시켜야하는 드래그앤 드롭은 마우스 관련 이벤트를 잘 이해해야 한다.

  • mousedown(마우스 클릭 이벤트)
  • mousemove(마우스 이동 이벤트)
  • mouseup(마우스 클릭 해제 이벤트)

MouseEvent가 가진 속성을 알아보자.

  • x, clientX: in local (DOM Content) coordinates. 이벤트가 발생되는 element 기준으로 위치를 산정한다.
  • pageX: relative to the whole document. page document 기준으로 위치를 산정한다.
  • sreenX: in global (screen) coordinates. 더 나아가 듀얼 모니터의 주 모니터를 기준으로 위치를 산정한다.

우선 screenX를 사용해 마우스의 현재 위치를 나타내보자.

Boundary라는 컴포넌트를 만들어 마우스 이벤트를 등록한뒤에 그 내부에서 마우스 이동을 해보자.

<Boundary
onMouseDown={() => {
const mouseMoveHandler = (e: MouseEvent) => {
console.log(`mouse move x:${e.screenX} y:${e.screenY}`)
setPosition({ x: e.screenX, y: e.screenY })
}

const mouseUpHandler = (e: MouseEvent) => {
console.warn(`>>>> mouse up x:${e.screenX} y:${e.screenY}`)
document.removeEventListener("mousemove", mouseMoveHandler)
}
document.addEventListener("mousemove", mouseMoveHandler)
document.addEventListener("mouseup", mouseUpHandler, { once: true })
}}
/>

이제 마우스의 움직임을 기반으로 이를 활용하여 element를 drag해서 위치를 움직여보자.

  1. element의 position 상태를 정의.
  2. 클릭(mousedown)이벤트를 발생시의 커서위치를 기준으로, 이동 (mousedown)이벤트에서 상대적으로 이동한 거리(deltaX, deltaY)를 계산한다.
  3. position 상태를 변경하여 element를 움직이게한다.
// 1️⃣
const [{ x, y }, setPosition] = useState({
x: 0,
y: 0,
})

return (
<div>
<div
style={{ transform: `translateX(${x}px) translateY(${y}px)` }}
onMouseDown={(clickEvent: React.MouseEvent<Element, MouseEvent>) => {
const mouseMoveHandler = (moveEvent: MouseEvent) => {
// 2️⃣
const deltaX = moveEvent.screenX - clickEvent.screenX
const deltaY = moveEvent.screenY - clickEvent.screenY

// 3️⃣
setPosition({
x: x + deltaX,
y: y + deltaY,
})
}

const mouseUpHandler = () => {
document.removeEventListener("mousemove", mouseMoveHandler)
}

document.addEventListener("mousemove", mouseMoveHandler)
document.addEventListener("mouseup", mouseUpHandler, { once: true })
}}
/>
</div>
)

Drag Boundary

drag를 할 때 특정 영역(boundary)를 벗어나지 않길 원할 수 있다.
위의 예제에서 mousemove이벤트에서 특정 범위를 벗어나지 않도록 제한하면 된다.

getBoundingClientRect를 활용하여 element의 정보를 얻을 수 있다.

const boundary = boundaryRef.current.getBoundingClientRect()
const box = boxRef.current.getBoundingClientRect()
// x, y, width, height

이 정보를 이용해 drag할 수 있는 경계(minx, maxx, miny, maxy)를 계산하면 된다. element의 범위를 계산하는 로직은 다음과 같다.

const inrange = (v: number, min: number, max: number) => {
if (v < min) return min
if (v > max) return max
return v
}

const BOUNDARY_MARGIN = 12
const deltaX = moveEvent.screenX - clickEvent.screenX
const deltaY = moveEvent.screenY - clickEvent.screenY

setPosition({
x: inrange(
x + deltaX,
Math.floor(-boundary.width / 2 + box.width / 2 + BOUNDARY_MARGIN),
Math.floor(boundary.width / 2 - box.width / 2 - BOUNDARY_MARGIN)
),
y: inrange(
y + deltaY,
Math.floor(-boundary.height / 2 + box.height / 2 + BOUNDARY_MARGIN),
Math.floor(boundary.height / 2 - box.height / 2 - BOUNDARY_MARGIN)
),
})

Touch Event

이전 Drag이벤트에서 만든 Drag로직은 모바일에서 동작하지 않는다는 단점이 있다. 모바일 기기에서는 TouchEvent가 발생된다.

  • mousedown -- touchdown
  • mousemove -- touchmove
  • mouseup -- touchend

TouchEvent의 속성

  • touches: 모든 접촉점의 터치 리스트
  • targetTouches: 현재 이벤트 타겟에서 시작된 터치 리스트
  • changedTouches : 이전 이벤트에 할당된 모든 접촉점의 터치 리스트

터치 스크린 특성상 여러 터치 이벤트가 동시에 실행될 수 있어서 리스트를 반환한다. 일반저긍로 첫 Touch 이벤트를 사용하면 될 것이다.

움직일 때는 touches[0], 손을 땠을 때는 changedTouches[0]을 사용하면된다.

<Boundary
onTouchStart={(touchEvent) => {
const touchMoveHandler = (moveEvent: TouchEvent) => {
setPosition({
x: moveEvent.touches[0].pageX - touchEvent.touches[0].pageX,
y: moveEvent.touches[0].pageY - touchEvent.touches[0].pageY,
})
}
const touchEndHandler = () => {
document.removeEventListener("touchmove", touchMoveHandler)
}

document.addEventListener("touchmove", touchMoveHandler)
document.addEventListener("touchend", touchEndHandler, { once: true })
}}
/>

그런데 문제가 있다. drag, touch이벤트를 모두 등록하기는 너무 귀찮다. 아래와 같은 util 함수를 만들어서 터치스크린인지 판별할 수 있다.

export const isTouchScreen =
typeof window !== "undefined" &&
window.matchMedia("(hover: none) and (pointer: coarse)").matches

이제 drag 이벤트의 최종코드를 보자.

export default function registDragEvent({
onDragChange,
onDragEnd,
stopPropagation,
}: {
onDragChange?: (deltaX: number, deltaY: number) => void
onDragEnd?: (deltaX: number, deltaY: number) => void
stopPropagation?: boolean
}) {
if (isTouchScreen) {
return {
onTouchStart: (touchEvent: React.TouchEvent<HTMLDivElement>) => {
if (stopPropagation) touchEvent.stopPropagation()

const touchMoveHandler = (moveEvent: TouchEvent) => {
const deltaX =
moveEvent.touches[0].pageX - touchEvent.touches[0].pageX
const deltaY =
moveEvent.touches[0].pageY - touchEvent.touches[0].pageY
onDragChange?.(deltaX, deltaY)
}

const touchEndHandler = (moveEvent: TouchEvent) => {
const deltaX =
moveEvent.changedTouches[0].pageX -
touchEvent.changedTouches[0].pageX
const deltaY =
moveEvent.changedTouches[0].pageY -
touchEvent.changedTouches[0].pageY
onDragEnd?.(deltaX, deltaY)
document.removeEventListener("touchmove", touchMoveHandler)
}

document.addEventListener("touchmove", touchMoveHandler)
document.addEventListener("touchend", touchEndHandler, { once: true })
},
}
}

return {
onMouseDown: (clickEvent: React.MouseEvent<Element, MouseEvent>) => {
if (stopPropagation) clickEvent.stopPropagation()

const mouseMoveHandler = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.pageX - clickEvent.pageX
const deltaY = moveEvent.pageY - clickEvent.pageY
onDragChange?.(deltaX, deltaY)
}

const mouseUpHandler = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.pageX - clickEvent.pageX
const deltaY = moveEvent.pageY - clickEvent.pageY
onDragEnd?.(deltaX, deltaY)
document.removeEventListener("mousemove", mouseMoveHandler)
}

document.addEventListener("mousemove", mouseMoveHandler)
document.addEventListener("mouseup", mouseUpHandler, { once: true })
},
}
}