터치 이벤트로 캔버스에서 지도 이동과 확대/축소 구현하기: feat. 마우스 이벤트와의 차이

정혜인·2024년 11월 30일
0

지도 상호작용을 구현하면서, 처음에는 마우스 이벤트를 활용해 지도 이동과 확대/축소를 구현했습니다.

하지만 모바일 환경을 지원하기 위해 터치 이벤트를 추가해야 했습니다. 터치 이벤트는 마우스 이벤트와 기본적으로 비슷하지만, 일부 동작과 처리 방식이 달랐습니다. 예상했던 것보다 구현해야 할 로직이 더 많았고, 여러 가지 문제를 해결하며 많은 고민을 하게 됐습니다.

이번 포스팅에서는 마우스 이벤트로 구현된 지도 상호작용을 터치 이벤트로 확장한 과정을 작성해보려고 합니다.


🎯 목표

제가 작업해야하는 주요 기술은 아래 3가지로 정리할 수 있었습니다.

  1. 한 손가락 터치로 지도를 이동할 수 있게 하기.
  2. 두 손가락 터치로 확대/축소를 구현하기.
  3. 터치와 관련된 모든 동작이 자연스럽고 직관적으로 작동하도록 설계하기.

🖱 마우스 이벤트로 구현한 기존 로직

이전 포스팅에서 작성한 것처럼, 마우스 이벤트로 지도 이동/줌은 구현해 둔 상황이었습니다.

모바일이 아닐 때에는 마우스 이벤트로, 모바일로 접속했을 때에는 터치 이벤트로 모두 동작할 수 있게 터치 이벤트를 구현해주어야 했습니다.

참고로 기존 마우스 이벤트는 아래와 같이 구현되었습니다!

  • onMouseDown: 마우스를 클릭했을 때 시작 좌표를 저장.
  • onMouseMove: 마우스를 움직이며 시작 좌표와의 차이를 계산해 지도를 이동.
  • onWheel: 마우스 휠 동작으로 확대/축소를 구현.

마우스 이벤트는 1개의 포인터만 다루면 되고, 마우스의 이동, 클릭, 휠 이벤트가 명확히 구분되기 때문에 상대적으로 간단했습니다.


📱 터치 이벤트 추가의 주요 고민

마우스 이벤트와 다르게 터치 이벤트를 구현하려면 마우스와 다른 접근이 필요했고, 마우스 이벤트와의 차이를 작성해보자면 아래와 같이 정리할 수 있었습니다.

1. 터치 이벤트는 여러 개의 포인터를 다룬다

  • 마우스 이벤트는 포인터가 하나만 존재하지만, 터치 이벤트는 여러 손가락(포인터)을 사용할 수 있습니다.
  • 한 손가락으로는 이동을 구현하고, 두 손가락으로는 확대/축소를 구현해야 했습니다……….하하…..
  • 이 과정에서 터치 개수(e.touches.length)에 따라 로직을 분기 처리해야 했습니다.

2. 터치 동작의 의도 파악

  • 한 손가락으로 터치했을 때, 이는 단순히 클릭인지 아니면 이동을 위한 터치인지를 구분해야 했습니다.
  • 두 손가락으로 터치했을 때, 이 동작이 확대/축소를 위한 동작임을 명확히 인식해야 했습니다.

3. 줌 비율 계산

  • 두 손가락 터치로 확대/축소를 구현하려면 두 손가락 간의 초기 거리이동 중 거리를 비교하여 줌 비율을 계산해야 했습니다.
  • 단순히 비율을 계산하는 것뿐만 아니라, 줌의 중심을 정확히 설정해야 자연스러운 확대/축소가 가능했습니다.

🛠 터치 이벤트 구현 과정

그래서 저는 터치 이벤트를 추가하기 위해 아래와 같은 과정을 거쳤습니다.

1. 기본 이벤트 추가

터치 동작은 onTouchStart, onTouchMove, onTouchEnd 이벤트로 처리할 수 있습니다. 이를 통해 터치 시작, 움직임, 종료 시 필요한 상태를 업데이트하도록 기본 구조를 작성했습니다.

그래도 마우스보다 다행인 것은, mouseMove 이벤트에서는 클릭한 상태로 움직이는 것인지, 그냥 클릭만하고 끝나는 것인지 등을 분류해주는 작업이 어려웠는데,

터치 이벤트는 애초에 터치를 한 상태로 움직이는 이벤트가 touchMove로 존재했기 때문에, 터치된 상태로 움직이고 있는 건지를 분리하는 로직이 필요없다는 장점이 있었습니다.

(개인적으로 마우스 이동할 때 이 로직 짜는게 가장 어렵고 정답이 없었는데, 터치는 그러지 않아도 되어서 너무 좋았습니다…..ㅎㅎㅎ,,,)

const handleTouchStart = (e: React.TouchEvent) => {
  if (e.touches.length === 2) {
    // 두 손가락 터치 시작
  } else if (e.touches.length === 1) {
    // 한 손가락 터치 시작
  }
};

const handleTouchMove = (e: React.TouchEvent) => {
  if (e.touches.length === 2) {
    // 두 손가락으로 확대/축소
  } else if (e.touches.length === 1) {
    // 한 손가락으로 지도 이동
  }
};

const handleTouchEnd = (e: React.TouchEvent) => {
  // 터치 종료 시 상태 초기화
};

2. 한 손가락 터치로 지도 이동

문제:

  • 한 손가락으로 터치한 상태에서 이동하려면 현재 터치 좌표와 이전 터치 좌표 간의 차이를 계산해야 했습니다.
  • 하지만 터치한 위치를 단순히 클릭으로 처리할 가능성도 있었기 때문에, 이동인지 클릭인지를 구분할 필요가 있었습니다.

해결 방법:

  • 터치 시작 시(onTouchStart) 위치를 저장한 뒤, 터치가 움직이는 동안(onTouchMove)의 좌표와 비교해 이동 거리를 계산했습니다.
  • 이동 거리가 일정 값 이상이면 클릭이 아니라 이동으로 간주했습니다.
const handleTouchStart = (e: React.TouchEvent) => {
  if (e.touches.length === 1) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    setDragStartPos({
      x: e.touches[0].clientX - rect.left,
      y: e.touches[0].clientY - rect.top,
    });
    setIsTouching(true);
  }
};

const handleTouchMove = (e: React.TouchEvent) => {
  if (isTouching && e.touches.length === 1) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    const newX = e.touches[0].clientX - rect.left;
    const newY = e.touches[0].clientY - rect.top;

    const deltaX = dragStartPos.x - newX;
    const deltaY = dragStartPos.y - newY;

    map?.panBy(new naver.maps.Point(deltaX, deltaY));
    setDragStartPos({ x: newX, y: newY });
  }
};

3. 두 손가락 터치로 확대/축소

문제:

  • 두 손가락의 터치 간 거리를 기준으로 줌 비율을 계산해야 했습니다.
  • 확대/축소의 중심점은 두 손가락의 중심이어야 하므로, 두 손가락의 중심 좌표를 지도 좌표로 변환해 줌 중심으로 설정해야 했습니다.

해결 방법:

  1. 두 손가락 간 거리를 계산하여 시작 거리와 현재 거리의 비율로 줌 크기를 계산.
  2. 두 손가락의 중심 좌표를 지도 좌표로 변환해 줌 중심(zoomOrigin)으로 설정.
  3. 줌 크기 변경에 따라 지도 상의 확대/축소를 적용.
const handleTouchStart = (e: React.TouchEvent) => {
  if (e.touches.length === 2) {
    const distance = Math.sqrt(
      Math.pow(e.touches[0].clientX - e.touches[1].clientX, 2) +
        Math.pow(e.touches[0].clientY - e.touches[1].clientY, 2),
    );

    setTouchStartDistance(distance);

    const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
    const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;

    const mapCenter = map?.getProjection().fromContainerPixelToLatLng(
      new naver.maps.Point(centerX, centerY),
    );

    setTouchCenter(mapCenter);
  }
};

const handleTouchMove = (e: React.TouchEvent) => {
  if (e.touches.length === 2 && touchStartDistance) {
    const newDistance = Math.sqrt(
      Math.pow(e.touches[0].clientX - e.touches[1].clientX, 2) +
        Math.pow(e.touches[0].clientY - e.touches[1].clientY, 2),
    );

    const zoomChange = (newDistance - touchStartDistance) / 30; // 비율 조정
    const currentZoom = map?.getZoom() ?? 10;

    map?.setOptions({ zoomOrigin: touchCenter });
    map?.setZoom(currentZoom + zoomChange);

    setTouchStartDistance(newDistance);
  }
};

🧩 구현 과정 중 발생한 문제와 해결

1. 줌 비율의 과도한 변화

줌 비율 계산에서 거리 차이에 따라 줌 변화량을 직접 반영했더니 확대/축소가 너무 민감하게 반응했습니다. 이를 해결하기 위해 스케일링 비율을 조정하여 줌 변화량을 적절히 제한해주고 있습니다.

다만,,,, 그 임계값이 완벽하지 않기에 아직도 개선해야하는 부분 중 하나입니다……ㅠㅠㅠ


2. 줌 중심이 화면 밖으로 벗어나는 문제

두 손가락의 중심을 기준으로 줌을 적용했지만, 중심 좌표가 잘못 계산되어 화면 밖으로 벗어나는 문제가 있었습니다.

이를 해결하기 위해 지도 좌표 변환(fromContainerPixelToLatLng)을 통해 정확히 중심을 설정해주었습니다.


📝 결론

터치 이벤트는 마우스 이벤트와 기본 원리가 비슷하지만, 멀티 포인터를 처리해야 한다는 점과 터치 동작의 의도를 파악해야 한다는 점에서 추가적인 고민이 필요했습니다.

특히 두 손가락 줌의 중심을 정확히 계산하고, 동작의 자연스러움을 유지하는 것이 가장 큰 도전 과제였습니다.

마우스에서는 클릭한 상태로 이동하는 것을 추적하는게 쉽지 않았다면, 오히려 터치이벤트는 이동이 아니라 을 얼마나 할지에 대해 정하는 것이 쉽지 않았던 것 같습니다.

하지만 결론적으로 아래와 같이 구현했고, 터치 이벤트가 잘 동작하는 것도 확인할 수 있었습니다 ㅎㅎ
(만약 이미지가 멈춰있다면, 이미지에서 오른쪽 마우스 클릭 후 새 탭에서 이미지 열기로 들어가주세요... 벨로그가 gif를 지원하지 않는가봐요....)

(이 때 진짜 너무 뿌듯해서 날아가는 줄 알았어요;; 또 발생할 문제는 모른채…)

0개의 댓글