Logo
Published on

마우스로 드래그 가능한 가로 스크롤 컴포넌트 만들기

Authors
  • avatar
    Name
    Kim Hyori
    Twitter

들어가며

이전 혼콕 프로젝트를 개발할 때, 스크롤바 없이 가로 스크롤이 가능한 컴포넌트를 구현했었다.

보통 overflow-x: auto;가 적용된 요소가 스크롤이 생길 때, 모바일에서는 터치로 스크롤이 자유롭게 가능하지만 마우스로는 스크롤을 하려면 휠을 누르거나 키보드로 조작해야 한다. 그래서 마우스로도 드래그가 가능한 HorizontalScroll 컴포넌트를 구현하여 사용했었다.

이번 게시글에서는 HorizontalScroll 컴포넌트를 다시 한 번 만들어보고, 프로젝트 때의 코드를 더 깔끔하게 리팩토링 해보는 시간을 가져보려 한다.


HorizontalScroll 컴포넌트 구현하기

먼저 드래그를 하기 위해 어떤 값들이 필요한지 생각 해보자.

  • 드래그를 하는 동안 이동한 거리를 계산한다.
  • 현재 스크롤 위치이동한 거리만큼 더하거나 뺀다.

이를 위해 필요한 값들은 스크롤을 시작한 마우스 위치, 현재 스크롤 중인 마우스 위치, 현재 스크롤 위치이다.


각종 x와 left 알아보기

구현하기에 앞서, 마우스 이벤트 객체의 pageX, clientX, screenX, movementX 속성과 DOM의 clientLeft, offsetLeft, screenLeft, scrollLeft 속성을 알아보자.

  • e.pageX: 브라우저 전체에서의 마우스의 x좌표

  • e.clientX: 뷰포트에서의 마우스의 x좌표

  • e.screenX: 모니터 기준 마우스의 x좌표

  • e.movementX: 마우스 이벤트가 발생한 이전 이벤트와의 x좌표 차이

  • element.clientLeft: 요소의 왼쪽 테두리의 두께

  • element.offsetLeft: 부모 요소로부터의 왼쪽 거리

  • element.screenLeft: 모니터의 왼쪽 상단을 기준으로 한 요소의 왼쪽 거리

  • element.scrollLeft: 요소의 스크롤된 가로 거리

이미지로 표현한 자료도 있다. (자료 출처: javascript.info)

우리는 이 속성 중 e.clientXelement.scrollLeft를 사용해 스크롤 위치를 변경하는 코드를 구현할 것이다.


Mouse 이벤트

가로 스크롤을 구현하기 위해 마우스 이벤트에 대해서도 알아야 한다. 우리는 컨테이너 요소 위에서 마우스를 누를 때, 마우스를 이동할 때, 마우스를 떼었을 때로 구분해 이벤트를 처리해야 한다. 기본적으로 마우스 이벤트가 많지만 우리는 다음 세 가지 이벤트를 사용할 것이다.

  • mousedown: 마우스를 누르는 이벤트
  • mousemove: 마우스를 이동하는 이벤트
  • mouseup: 마우스를 뗄 때 발생하는 이벤트

이제, 리액트로 구현하는 코드를 살펴보자.


이벤트 핸들러 구현하기

HorizontalScroll.tsx
const [dragState, setDragState] = useState(false)
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const containerRef = useRef<HTMLDivElement | null>(null)

/** 드래그 시작 */
const handleDragStart = (e: MouseEvent) => {
  if (!containerRef.current) return

  setDragState(true)
  setStartX(e.clientX)
  setScrollLeft(containerRef.current.scrollLeft)
}

/** 드래그 중 */
const handleDragMove = (e: MouseEvent) => {
  if (!dragState || !containerRef.current) return

  const x = e.clientX - startX
  containerRef.current.scrollLeft = scrollLeft - x
}

/** 드래그 종료 */
const handleDragEnd = () => {
  setDragState(false)
}

코드를 하나씩 살펴보자.


const handleDragStart = (e: MouseEvent) => {
  if (!containerRef.current) return

  setDragState(true)
  setStartX(e.clientX)
  setScrollLeft(containerRef.current.scrollLeft)
}

드래그가 시작되면 dragStatetrue로 변경하고, startX에 처음 스크롤을 시작하는 마우스 x좌표를 저장한다. 그리고 scrollLeft에 현재 컨테이너의 스크롤 위치를 저장한다.


const handleDragMove = (e: MouseEvent) => {
  if (!dragState || !containerRef.current) return

  const x = e.clientX - startX
  containerRef.current.scrollLeft = scrollLeft - x
}

드래그 중에는 스크를 이동 거리를 계산하기 위해 현재 마우스 x좌표에서 startX를 뺀 값을 구한다. 그리고 스크롤 위치를 scrollLeft - x로 변경한다. 즉, 이동 거리만큼 스크롤 위치를 변경하는 로직이다.


const handleDragEnd = () => {
  setDragState(false)
}

드래그가 종료되면 dragStatefalse로 다시 변경한다.

첫 번째 결과

마우스로 드래그가 가능해졌다. 하지만 아직 세 가지 문제가 남았다.

  • 드래그 중 마우스 포인터를 변경하고 싶다. HorizontalScrollcursor: grabbing; 스타일을 적용해도, 자식 요소에 기본 커서값이 정해져 있다면 변경되지 않는다. (자잘한 문제)
  • 드래그 중에 마우스가 컨테이너 밖으로 나가면 mouseup 이벤트가 발생하지 않아 드래그 상태가 유지된다.
  • 만약 HorizontalScroll 컴포넌트 안에 클릭 이벤트가 있는 버튼이 존재한다면, 드래그 이후 마우스를 떼었을 때 클릭 이벤트가 발생해버린다.

첫 번째 사항은 마지막에 간단하게 해결해 보기로 한다.
드래그 중 마우스가 컨테이너 밖으로 나가는 문제는 mouseleave 이벤트에도 handleDragEnd를 등록해 해결할 수 있다.

세 번째 문제가 크게 고민해 볼 문제다. 가장 단순히 생각해보면 드래그 중에는 클릭 이벤트가 발생하지 않도록 처리하면 될 것이다. 예를 들어 다음 코드처럼 작성하면 될 것이다.

if (dragState) return

버튼_이벤트_처리()

하지만 외부에서는 HorizontalScroll 내부의 dragState 상태를 참조할 수 없기 때문에 이런 방식으로 처리할 수 없다. 그렇다면 어떻게 해결해야 할까?


이벤트 캡처링

이벤트 캡처링은 이벤트가 발생한 요소의 부모 요소로 이벤트가 전파되는 방식이다. 이벤트 캡처링을 이용하면 자식 요소에서 발생한 이벤트를 부모 요소에서 먼저 처리할 수 있다. 이를 이용해 HorizontalScroll 컴포넌트 내부에서 클릭 이벤트 핸들러에 캡처링을 적용 후 e.stopPropagation()를 통해 전파를 막아보자.

const stopPropagationOnDrag = (e: MouseEvent) => {
  if (dragState) {
    e.stopPropagation()
  }
}

return (
  <div
    ref={containerRef}
    onMouseDown={handleDragStart}
    onMouseMove={handleDragMove}
    onMouseUp={handleDragEnd}
    onMouseLeave={handleDragEnd}
    onClickCapture={stopPropagationOnDrag}
  >
    {children}
  </div>
)

이제 내부 요소에서 클릭 이벤트가 발생해도 드래그 중에는 클릭 이벤트가 발생하지 않아야 한다. 그런데 아직도 똑같이 클릭 이벤트가 발생하는 것을 확인할 수 있다. 왜 그럴까?

바로 MouseUPEvent와 ClickEvent의 실행 순서 때문이다.

MouseUpEvent는 ClickEvent보다 먼저 발생한다. 그래서 클릭 시에는 항상 dragState가 false이기 때문에 버튼 이벤트를 막을 수 없는 것이다.


매크로 태스크 큐

그렇다면 dragState를 false로 변경하는 로직을 ClickEvent보다 나중에 실행할 수 있는 방법은 없을까? 바로 매크로 태스크 큐를 이용하면 된다. 즉, 클릭 이벤트가 핸들링 될 때까지 기다렸다가 dragState를 false로 변경하는 방법이다. 이를 위해 setTimeout을 이용해 매크로 태스크 큐에 등록할 수 있다. 매크로 태스크 큐를 통한 이벤트 핸들러 액션 스케줄링은 자바스크립트 문서에서도 확인할 수 있다.

const handleDragEnd = () => {
  setTimeout(() => setDragState(false))
}

이렇게 구현하면 드래그 중에는 클릭 이벤트가 발생하지 않는다. 하지만 드래그를 하지 않고 딸깍 클릭만 해도 역시 이벤트가 발생하지 않는다.
이 경우에는 드래그를 시작할 때의 스크롤 위치와 끝났을 때의 스크롤 위치가 같다면 드래그로 판단하지 않도록 수정해야 한다.

const handleDragEnd = () => {
  if (containerRef.current && scrollLeft === containerRef.current.scrollLeft) {
    setDragState(false)

    return
  }

  setTimeout(() => setDragState(false))
}

두 번째 결과

드래그할 땐 버튼 클릭이 되지 않고, 그냥 클릭만 할 때에는 잘 동작한다!


더 나아가기

이렇게 된 김에 클릭 이벤트뿐만 아니라 마우스 관련 이벤트를 모두 막아버리자. 내부 요소에서 onclick 말고도 혹시나 다른 마우스 이벤트를 사용할 경우를 미연에 방지하기 위해서다.

const stopPropagationOnDrag = (e: MouseEvent, handler?: (e: MouseEvent) => void) => {
  if (dragState) {
    e.stopPropagation()
  }

  if (handler) {
    handler(e)
  }
}

return (
  <div
    className={cn('scrollbar-hide w-full overflow-x-auto', {
      'cursor-grab': !dragState,
      'cursor-grabbing': dragState,
    })}
    onMouseDownCapture={(e) => stopPropagationOnDrag(e, handleDragStart)}
    onMouseMoveCapture={(e) => stopPropagationOnDrag(e, handleDragMove)}
    onMouseUpCapture={(e) => stopPropagationOnDrag(e, handleDragEnd)}
    onClickCapture={stopPropagationOnDrag}
    onMouseLeave={handleDragEnd}
    ref={containerRef}
  >
    <ul className={cn('inline-flex items-center gap-4 px-8', className)}>{children}</ul>
  </div>
)

내부 요소의 mouse event

만약 내부 요소에 텍스트가 있다면 텍스트가 드래그 되어 사용자 경험에 방해가 될 수 있다. 또한 본문에서 언급은 안 했지만 위의 코드에서 보이듯 HorizontalScroll 스타일에 cursor-grabcursor-grabbing를 적용해 마우스 커서를 변경하고 있다. 이런 경우 내부 요소에 default cursor가 있다면 마우스 커서가 변경되지 않을 수 있다.

이럴 땐 내부 요소 선택자를 사용해 user-select: none;과 커서를 변경하는 스타일을 적용하면 된다.
나는 tailwind로 내부 요소에 스타일을 주기 번거로워서 생략했다.


최종 코드

react, typescript, tailwind로 구현한 HorizontalScroll 컴포넌트의 최종 코드는 다음과 같다.

HorizontalScroll.tsx
import { MouseEvent, PropsWithChildren, useRef, useState } from 'react'
import { cn } from '../../lib/utils'

interface HorizontalScrollProps {
  className?: string
}

const HorizontalScroll = ({ className, children }: PropsWithChildren<HorizontalScrollProps>) => {
  const [dragState, setDragState] = useState(false)
  const startX = useRef(0)
  const scrollLeft = useRef(0)
  const containerRef = useRef<HTMLDivElement | null>(null)

  /** 드래그 시작 */
  const handleDragStart = (e: MouseEvent) => {
    if (!containerRef.current) return

    setDragState(true)
    startX.current = e.clientX
    scrollLeft.current = containerRef.current.scrollLeft
  }

  /** 드래그 중 */
  const handleDragMove = (e: MouseEvent) => {
    if (!dragState || !containerRef.current) return

    const x = e.clientX - startX.current
    containerRef.current.scrollLeft = scrollLeft.current - x
  }

  /** 드래그 종료 */
  const handleDragEnd = () => {
    if (containerRef.current && scrollLeft.current === containerRef.current.scrollLeft) {
      setDragState(false)

      return
    }

    setTimeout(() => {
      setDragState(false)
    })
  }

  /** 드래그 상태에서 이벤트 전파 방지 */
  const stopPropagationOnDrag = (e: MouseEvent, handler?: (e: MouseEvent) => void) => {
    if (dragState) {
      e.stopPropagation()
    }

    if (handler) {
      handler(e)
    }
  }

  return (
    <div
      className={cn('scrollbar-hide w-full overflow-x-auto', {
        'cursor-grab': !dragState,
        'cursor-grabbing': dragState,
        '[&>*]:select-none': dragState,
      })}
      onMouseDownCapture={(e) => stopPropagationOnDrag(e, handleDragStart)}
      onMouseMoveCapture={(e) => stopPropagationOnDrag(e, handleDragMove)}
      onMouseUpCapture={(e) => stopPropagationOnDrag(e, handleDragEnd)}
      onClickCapture={stopPropagationOnDrag}
      onMouseLeave={handleDragEnd}
      ref={containerRef}
    >
      <ul className={cn('inline-flex items-center gap-4 px-8', className)}>{children}</ul>
    </div>
  )
}

export default HorizontalScroll

사용한 예시 코드는 다음과 같다.

App.tsx
import HorizontalScroll from './components/HorizontalScroll'

const App = () => {
  return (
    <HorizontalScroll>
      <li>1</li>
      <li>2</li>
      <li>3</li>
      <li>4</li>
      <li>5</li>
      <li>6</li>
      <li>7</li>
      <li>8</li>
      <li>9</li>
      <li>10</li>
    </HorizontalScroll>
  )
}

마치며

이렇게 외부에서 전혀 신경쓸 필요가 없는 가로 스크롤 컴포넌트를 만들어보았다. 이전 프로젝트에서는 외부에서 dragState를 작성해주도록 코드를 구현했었는데, 이번 기회에 내부에서 모든 로직을 처리하도록 리팩토링해보았다. 이렇게 하면 외부에서는 컴포넌트를 사용할 때 dragState를 신경쓸 필요가 없어진다.

다음에는 부드러운 스크롤 이벤트를 구현하기 위해 마우스 이동 속도 값을 계산해서, mouseup 시점에 scrollTo를 통해 자연스럽게 슬라이드 되도록 구현해보는 것도 재미있을 것 같다! 🙌