[리뷰] 드래그 이벤트 제어하기(마우스, 터치)

Goyoung2·2023년 1월 1일
1

드래그

안녕하세요~ HTML 엘리먼트를 마우스로 옮기는 방법을 적어볼게요~

드래그

드래그는 클릭시작, 이동, 클릭끝 3단계로 이뤄지는데요. 이를 이벤트 함수로 등록해서 드래그를 제어할 수 있어요.

단 주의할 점은 마우스와 터치 이벤트의 속성이 조금 다르다는 건데요. 이를 해결하기 위해 약간의 조건문이 추가되어야해요.

draggable

저는 draggable 이라는 이벤트 제어 함수를 만들었구요. 이를 컴포넌트 마운트 시에 실행하도록 했어요.
draggable 내부에는 이벤트 상태를 제어하기 위한 변수들과, start, move, end 함수가 있어서 각 이벤트가 발생할때 실행되도록 했어요.
이벤트 추가는 마우스와 터치를 따로 등록시켜줘야해요

  • 추가: pointer 이벤트를 사용하면 마우스와 터치를 한번에 제어할 수 있을 것 같네요.
Pointer 이벤트는 웹 애플리케이션에서 다양한 입력 장치(예: 마우스, 터치, 펜 등)를 통합하여 처리할 수 있도록 도와주는 이벤트입니다. 이를 통해 여러 입력 장치에 대한 이벤트를 별도로 처리할 필요 없이 일관된 방식으로 이벤트를 처리할 수 있습니다.

Pointer 이벤트의 주요 유형과 설명은 다음과 같습니다:

1. **pointerdown**: 포인터(마우스, 터치, 펜 등)가 접촉을 시작할 때 발생합니다. 마우스로는 버튼을 누르거나 터치스크린에서는 손가락이 화면에 닿을 때 발생합니다.

2. **pointerup**: 포인터가 접촉을 끝낼 때 발생합니다. 마우스로는 버튼을 놓거나 터치스크린에서는 손가락이 화면에서 떨어질 때 발생합니다.

3. **pointermove**: 포인터가 움직일 때 발생합니다. 마우스 포인터가 이동하거나 터치스크린에서 손가락이 움직일 때 발생합니다.

4. **pointerover**: 포인터가 요소 위로 이동할 때 발생합니다. 마우스 포인터가 요소 위로 들어올 때 발생합니다.

5. **pointerout**: 포인터가 요소에서 벗어날 때 발생합니다. 마우스 포인터가 요소를 벗어날 때 발생합니다.

6. **pointerenter**: 포인터가 요소의 경계를 넘어 들어올 때 발생합니다. `pointerover`와 유사하지만 자식 요소로 들어갈 때 중첩되지 않습니다.

7. **pointerleave**: 포인터가 요소의 경계를 넘어 나갈 때 발생합니다. `pointerout`과 유사하지만 자식 요소로 나갈 때 중첩되지 않습니다.

8. **pointercancel**: 시스템이 포인터 이벤트를 취소할 때 발생합니다. 예를 들어 터치 장치에서 사용자가 두 손가락으로 화면을 조작하여 브라우저가 스크롤이나 확대/축소 동작을 시작하는 경우 발생합니다.

9. **gotpointercapture**: 포인터 캡처가 설정될 때 발생합니다. 특정 요소가 포인터 캡처를 얻으면 해당 포인터 이벤트가 항상 그 요소로 전달됩니다.

10. **lostpointercapture**: 포인터 캡처가 해제될 때 발생합니다.

Pointer 이벤트의 주요 속성은 다음과 같습니다:

- **pointerId**: 포인터의 고유 ID. 여러 포인터를 구별할 때 사용됩니다.
- **pointerType**: 포인터 장치의 유형("mouse", "pen", "touch").
- **isPrimary**: 기본 포인터 여부. 다중 입력 장치에서 기본 포인터인지 여부를 나타냅니다.
- **width**: 접촉 영역의 너비.
- **height**: 접촉 영역의 높이.
- **pressure**: 포인터의 압력. 0.0(최소 압력)에서 1.0(최대 압력)까지의 값입니다.
- **tiltX**와 **tiltY**: 포인터의 기울기 각도.

Pointer 이벤트를 사용하면 다양한 입력 장치에 대해 일관되고 효율적으로 이벤트를 처리할 수 있어 웹 애플리케이션의 사용자 경험을 향상시킬 수 있습니다.
// 엘리먼트에 이벤트 등록
eventTarget.onmousedown = start
window.onmousemove = move
eventTarget.onmouseup = end

eventTarget.ontouchstart = start
window.ontouchmove = move
eventTarget.ontouchend = end

start

start 함수를 살펴볼게요. start 함수는 e(마우스이벤트 또는 터치이벤트)를 파라미터로 받고 pressed 상태를 true로 변경해요. e에 clientY가 있으면 마우스 이벤트로 인식하고 touches가 있으면 터치 이벤트로 인식해서 prevPosX/Y에 마우스의 위치를 저장해요. 참고로 마우스의 위치는 브라우저 기준 왼쪽 위가 (0,0)이고 오른쪽 아래로 갈 수록 수치가 늘어나요. 그리고 터치는 여러개일 수 있기 때문에 배열형태에요.

// 드래그 시작
function start(e: MouseEvent | TouchEvent) {
	pressed = true
    if ('clientY' in e) {
    	prevPosY = e.clientY
    } else if ('touches' in e) {
    	prevPosY = e.touches[0].clientY
    }
}

move

move 함수를 살펴볼게요. move 함수는 e를 파라미터로 받고 pressed 상태가 아니라면 함수를 종료해요. pressed 상태이면 diffX,Y를 계산하고, 현재 마우스의 위치를 prevPosX/Y에 저장해요. 그런다음 움직이고 싶은 엘리먼트(movingTarget) 스타일의 left, top을 변경해서 현재 마우스의 위치로 엘리먼트가 이동되도록 만듭니다. 추가적으로 엘리먼트를 처음 위치로 돌리고 싶다면 처음 위치를 startLeft/Top 변수에 저장해두고 end 함수에서 활용하면 돼요.
참고로 window에 이벤트를 추가해줍니다.

// 드래그 중
function move(e: MouseEvent | TouchEvent) {
	if (!pressed) return
	let diffY = 0
	if ('clientY' in e) {
		diffY = prevPosY - e.clientY
		prevPosY = e.clientY
	} else if ('touches' in e) {
		diffY = prevPosY - e.touches[0].clientY
		prevPosY = e.touches[0].clientY
	}
	if (movingTarget) {
		// 현재 위치 옮기기
		movingTarget.style.top = movingTarget.offsetTop - diffY + 'px'
		// startTop 저장
        if (startTop === '') startTop = movingTarget.offsetTop - diffY + 'px'
	}
}

end

end 함수를 살펴볼게요. 마우스 드래그가 끝날때는 pressed를 false로 초기화 시켜줘요. 추가적으로 다양한 액션을 추가할 수 있어요. 초기 위치로 되돌리고 싶다면 아래 코드를 참고하세요~
참고로 movingTarget은 실제 움직이는 엘리먼트이고, eventTarget 드래그 이벤트를 등록하는 엘리먼트에요.(드래그 손잡이) wrapperTarget은 딤처리된 백그라운드인데 위 타겟들의 상위 엘리먼트에요. 전체 엘리먼트를 제거할때 사용하고 있어요.

// 드래그 끝
function end() {
	pressed = false
    // 초기 위치로 되돌리기
	const delay = 300
	if (movingTarget && wrapperTarget) {
		// 트랜지션 잠깐 추가
		movingTarget.style.transition = `top ${delay}ms ease-out`
		// 1/3 이상 내렸으면 닫기
		if (prevPosY >= startPosY + (rootHeight - startPosY) / 3) {
			movingTarget.style.top = '100vh'
			setTimeout(() => {
				wrapperTarget.remove()
				setOpen?.(false)
			}, delay)
		} else {
			// 그렇지 않으면 원상복귀
			movingTarget.style.top = startTop
		}
		// 트랜지션 제거
		setTimeout(() => {
			movingTarget.style.transition = ``
		}, delay)
	}
}

전체코드(리액트)

아래는 모달바텀(액션시트)의 전체코드에요. 참고하시면 될것 같아요

const [open, setOpen] = useState(true)

// 마우스 드래그 이벤트
  const draggable = useCallback(
    (
      // wrapper > draggable >= moving
      eventTarget: HTMLElement, // 이벤트 등록 엘리먼트
      movingTarget?: HTMLElement, // 실제로 움직이는 엘리먼트
      wrapperTarget?: HTMLElement, // 전체 wrapper
    ) => {
      let isPress = false
      let prevPosY = 0
      if (!movingTarget) movingTarget = eventTarget
      const rootHeight = document.querySelector('#root')?.clientHeight || 0
      const startPosY = movingTarget.offsetTop
      let startTop = ''

      // 엘리먼트에 이벤트 등록
      eventTarget.onmousedown = start
      window.onmousemove = move
      eventTarget.onmouseup = end

      eventTarget.ontouchstart = start
      window.ontouchmove = move
      eventTarget.ontouchend = end

      // 드래그 시작
      function start(e: MouseEvent | TouchEvent) {
        if ('clientY' in e) {
          prevPosY = e.clientY
        } else if ('touches' in e) {
          prevPosY = e.touches[0].clientY
        }
        isPress = true
      }

      // 드래그 중
      function move(e: MouseEvent | TouchEvent) {
        if (!isPress) return
        let posY = 0
        if ('clientY' in e) {
          posY = prevPosY - e.clientY
          prevPosY = e.clientY
        } else if ('touches' in e) {
          posY = prevPosY - e.touches[0].clientY
          prevPosY = e.touches[0].clientY
        }
        if (movingTarget) {
          // 현재 위치 옮기기
          movingTarget.style.top = movingTarget.offsetTop - posY + 'px'
          // startTop 저장
          if (startTop === '') startTop = movingTarget.offsetTop - posY + 'px'
        }
      }

      // 드래그 끝
      function end() {
        isPress = false
        const delay = 300
        if (movingTarget) {
          // 트랜지션 잠깐 추가
          movingTarget.style.transition = `top ${delay}ms ease-out`
          // 1/3 이상 내렸으면 닫기.
          if (prevPosY >= startPosY + (rootHeight - startPosY) / 3) {
            movingTarget.style.top = '100vh'
            setTimeout(() => {
              wrapperTarget?.remove()
              setOpen?.(false)
            }, delay)
          } else {
            // 그렇지 않으면 원상복귀
            movingTarget.style.top = startTop
          }
          // 트랜지션 제거
          setTimeout(() => {
            movingTarget.style.transition = ``
          }, delay)
        }
      }
    },
    [setOpen],
  )

참고

profile
프론트엔드 엔지니어로 일하고 있어요. 제품, 동료, 성장을 중요시해요. 겸손, 존중, 신뢰를 갖춘 동료가 되기 위해 노력해요. 😄

0개의 댓글