본문으로 건너뛰기
wooncloud

모바일 웹 핀치 줌 직접 구현, user-scalable=no 함정과 focal point 보정 수식

user-scalable=no 가 있으면 touchmove 가 passive 로 강제되어 커스텀 핀치가 막히는 함정과, 두 손가락 중점을 고정하는 translate 보정 수식을 정리했습니다.

5분 분량

이미지 뷰어에서 이미지만 핀치 줌되는 기능을 넣다가, 터치 핸들러를 붙였는데도 아무 반응이 없어서 한참 헤맸습니다. 페이지 전체가 확대되면 안 되고, 두 손가락 제스처로 이미지 요소만 transform: scale 되어야 하는 요구였습니다.

범인은 viewport 메타 태그였습니다

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

user-scalable=no(또는 maximum-scale=1.0)가 있으면 브라우저 기본 핀치 줌만 꺼지는 게 아닙니다. Chrome 55 이상은 touchmove 리스너를 passive 로 강제해서 preventDefault() 까지 무시합니다.

그러니 "기본 줌은 끄고 내가 직접 구현하지"가 아예 안 되는 구조입니다. JS 로 만든 커스텀 핀치가 통째로 막힙니다.

이렇게 풀었습니다

순서는 세 단계입니다.

  1. viewport 에서 user-scalable=no 를 빼고, maximum-scale 은 허용치(예: 5.0)로 둡니다.
  2. 페이지 전체가 다시 핀치되는 부작용은 CSS 로 막습니다. body { touch-action: pan-x pan-y } 면 스크롤은 되고 핀치만 금지됩니다.
  3. 줌 대상 컨테이너에만 touch-action: none 을 주고 PointerEvents 로 핀치를 구현합니다.

두 손가락 중점이 고정되려면 translate 보정이 필요합니다

핀치하는 동안 두 손가락의 중점(focal point)이 화면에서 그대로인 것처럼 보이려면, scale 이 바뀔 때 translate 도 같이 보정해야 합니다. transform-origin: center 기준이면 이렇습니다.

// ratio = nextScale / prevScale
// focal = 두 포인터의 중점, center = 컨테이너 중심 (getBoundingClientRect 기반)
nextOffset.x = (focal.x - center.x) * (1 - ratio) + prevOffset.x * ratio;
nextOffset.y = (focal.y - center.y) * (1 - ratio) + prevOffset.y * ratio;

흔히 하는 실수 두 가지

하나는 center 항을 빼먹는 겁니다. 화면 좌표(focal)만 쓰면 컨테이너가 화면 중앙에 있지 않을 때 수십 px 씩 어긋납니다. 컨테이너 중심을 기준으로 하는 좌표계로 바꿔서 계산해야 합니다.

다른 하나는 setPointerCapture 를 빼먹는 겁니다. 손가락이 요소 밖으로 살짝만 나가도 pointermove 가 끊겨서 핀치가 뚝뚝 끊깁니다. pointerdown 에서 capture 해 두면 요소 밖에서도 이벤트가 계속 들어옵니다.

보너스, 수치 로직은 순수함수로 뺍니다

거리에서 배율을 구하는 계산, focal 보정, 팬 클램프를 DOM 없는 순수함수로 분리하면 실기기 없이도 테스트가 됩니다. "핀치 중점은 줌 전후에 같은 콘텐츠 지점을 가리킨다" 같은 좌표 불변식을 단위 테스트로 박아 둘 수 있어서, 수식을 고칠 때 마음이 한결 편합니다.

참고