모바일 웹 환경에서 pinch-zoom 구현하기 (4) - 핀치 줌 기능 구현

기운찬곰·2023년 5월 12일
3
post-thumbnail

💻 카카오엔터테이먼트 FE 블로그를 참조하여 실습 및 정리한 내용입니다.

🧑🏻‍💻 코드 참고 : https://github.com/ckstn0777/pinch-zoom-practice

Overview

이번 시간에는 pinch zoom 기능 구현에 초점을 맞춰서 진행하도록 하겠습니다. 앞에 1~3장은 오늘을 위한 빌드업이었습니다. 😂


HTML, CSS 기본 구조

다시 한번 화면을 보면서 HTML과 CSS 를 보면서 기본 구조에 대해 짚고 넘어가도록 하겠습니다.

먼저 HTML 구조입니다. 3가지 요소로 되어있습니다.

<div id="screen">
  <div id="img-wrapper">
    <img src="album.jpeg" alt="album" class="album-img" />
  </div>
</div>

아래 그림은 약간 핀치 줌이 된 상태에 그림이라고 보면 됩니다. 처음에는 물론 겹쳐있습니다.

  • 스크린(screen) 영역 : 고정 영역이고, 핀치 줌을 할 수 있는 영역입니다.
  • 타겟(img-wrapper) 영역 : transform: translateX, translateY, scale에 따라 위치와 스케일이 조정되는 영역입니다.
  • 이미지(img) 요소 : 타겟 영역을 100% 사용하고 있는 이미지 요소입니다.

다음은 CSS 스타일에 대한 내용입니다.

/********** Style 추가 ********/
body {
  touch-action: pan-y;
}

#screen {
  margin: 16px;
  background-color: #ececec;
  overflow: hidden;
}

#img-wrapper {
  transform: scale(1);
}

.album-img {
  width: 100%;
  height: 100%;
}
  • body에 touch-action을 사용해서 기본 브라우저 핀치 줌 동작을 막을 수 있습니다.
  • screen 에서 overflow를 통해 스크린 영역 밖에 타겟(img-wrapper)가 있더라도 안보이게 해줍니다.

기본적인 pinch-zoom 기능 구현

main.ts

main.ts는 가장 먼저 실행되는 스크립트 파일입니다.

import "./style.css";
import { touchInit } from "./touch";
import { preventBrowserZoom } from "./utils";

function init() {
  // 브라우저 기본 pinch zoom 비활성화
  preventBrowserZoom();

  const screen = document.getElementById("screen");
  const target = document.getElementById("img-wrapper");

  if (!screen || !target) {
    throw new Error("Element not found");
  }

  // 터치 이벤트 리스너 제어
  touchInit(screen, target);
}

init();

touchInit 함수

touchInit 은 touch.ts에서 작성했습니다. scale에 대한 상태값을 가지며, getState, setState를 만들어서 useState 처럼 사용가능하게 구조화했습니다. 이제 이걸 가지고 터치이벤트 조절을 통해 상태를 조절할 수 있게 만들면 됩니다.

import { TransformState } from "./types";

export function touchInit(screen: HTMLElement, target: HTMLElement) {
  // 타겟의 상태 값
  const state: TransformState = {
    scale: 1,
  };

  // 타겟의 상태 값 수정 및 렌더링
  const setState = ({ scale }: TransformState) => {
    state.scale = scale;
    target.style.transform = `scale(${scale})`;
  }

  // 상태 값을 가져오는 함수
  const getState = () => {
    return state;
  }

  pinchZoom({ screen, target, setState, getState });
}

터치 정보 기록하기

touchstart를 통해 터치를 시작할 때 정보를 evHistory에 기록합니다. 만약 멀티 터치를 한 경우라면 evHistory에는 Touch 정보가 2개가 있을 겁니다. 그리고 touchend를 통해 터치가 끝나면 Touch 정보 중에 identifier 가 동일한 터치를 찾아 evHistory에서 제거해줍니다.

여기서 touches, targetTouches 말고 changedTouches 를 사용했냐면 터치를 유발한 경우에 대한 정보를 알고 싶기 때문입니다. 즉, 동적인 정보입니다. 반면, touches, targetTouches는 정적인 정보입니다. 현재 상황에 대해서 터치 정보를 알려줍니다. (이해가 안된다면 2장 참고)

import { PinchZoomParameters } from "./types";

const evHistory: Touch[] = [];

// 터치 시작
function touchStartHandler({ event }: { event: TouchEvent }) {
  const touches = event.changedTouches;
  if (evHistory.length + touches.length <= 2) {
    for (let i = 0; i < touches.length; i++) {
      const touch = touches[i];
      evHistory.push(touch);
    }
  }
}

// 터치 끝
function touchEndHandler({ event }: { event: TouchEvent }) {
  const touches = event.changedTouches;
  for (let i = 0; i < touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.findIndex((ev) => ev.identifier === touch.identifier);
    if (index !== -1) {
      evHistory.splice(index, 1);
    }
  }
}

export default function pinchZoom({
  screen,
  target,
  setState,
  getState,
}: PinchZoomParameters) {
  screen.addEventListener("touchstart", (event) => touchStartHandler({ event }));
  screen.addEventListener("touchend", (event) => touchEndHandler({ event }));
}

다음은 실제 터치를 해봤을 때 결과입니다. 터치 한 개랑 두 개 할 때 배열에 기록되고 3개할 때는 기록이 더 이상 기록이 안되도록 막았기 때문에 2개가 최대입니다. 그리고 터치를 떼면 해당 터치 정보만 제거되는 것을 알 수 있습니다.

touchmove 를 통해 pinch zoom 여부 판단하기

여기까지 했을 때 아직 뭔가 부족합니다. 바로 touchmove를 통해 pinch zoom 여부를 판단해서 동적으로 zoom을 시켜줘야 합니다. 즉, touchmove에 대해서도 판단해서 시시각각 줌인 or 줌아웃이 되도록 해줍니다.

  1. 터치 이동이 발생하면 터치 기록을 업데이트 해주고,
  2. 두 개의 터치 정보를 확인해 핀치 줌 발생 여부를 판단합니다. 이 때 유클리드 거리 공식을 이용합니다
  3. 이전 거리와 현재 거리 차이를 계산해서 zoom 값을 구합니다.
// 터치 이동 - 핀치 발생 체크 (사실 여기가 핵심)
function touchMoveHandler({ event, onPinch }: TouchMove) {
  const touches = event.changedTouches;
  for (let i = 0; i < touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.findIndex((ev) => ev.identifier === touch.identifier);
    if (index !== -1) {
      evHistory[index] = touch; // update

      // 두 개의 터치에 대해 확인해서 핀치 줌 발생 여부를 판단한다
      if (evHistory.length === 2) {
        const xDiff = evHistory[0].clientX - evHistory[1].clientX;
        const yDiff = evHistory[0].clientY - evHistory[1].clientY;

        // 유클리드 거리 공식 : (x1 - x2)^2 + (y1 - y2)^2
        const distance = Math.sqrt(xDiff * xDiff + yDiff * yDiff);

        // 첫 핀치의 경우 비교군이 없으므로 prevDiff가 -1인 경우 생략한다.
        if (prevDistance > 0) {
          const zoom = distance - prevDistance;
          onPinch({ zoom });
        }

        prevDistance = distance;
      }
    }
  }
}


export default function pinchZoom({
  screen,
  target,
  setState,
  getState,
}: PinchZoomParameters) {
  const handlePinch = ({ zoom }: { zoom: number }) => {
    console.log("zoom :", zoom);
  };

  screen.addEventListener("touchstart", (event) => touchStartHandler({ event }));
  screen.addEventListener("touchmove", (event) =>
    touchMoveHandler({ event, onPinch: handlePinch })
  );
  screen.addEventListener("touchend", (event) => touchEndHandler({ event }));
}

이제 zoom을 가지고 pinch 줌인 줌아웃 처리를 해보도록 해봅시다.

const handlePinch = ({ zoom }: { zoom: number }) => {
  if (zoom === 0) return;

  const { scale } = getState();
  const zoomWeight = 0.02; // 적절하게 조절
  const nextScale = scale + (zoom > 0 ? zoomWeight : -zoomWeight);
  
  setState({ scale: nextScale });
};

현재는 transform-origin이 기본 중심점(center, center)로 되어있기 때문에 scale 적용 시 가운데를 중심으로 줌 및 줌 아웃이 되는 것을 알 수 있습니다.

너무 작아져버리거나 커져버리면 문제가 있을 수 있지만, 일단 가장 기본적인 핀치 줌 구현이 완성되었습니다.


pinch-zoom 기능 개선하기

현재 문제점

위에서 가장 시급하게 개선해야 할 부분이 있다면 사용자가 핀치 줌을 사용할 때는 확대하는 중심 지점을 기준으로 확대하길 원하는데 지금은 원점이 고정되어있습니다. 따라서 이 부분을 해결해보도록 하겠습니다.

transform-origin 변경

일단 transform-origin을 통해 transformations의 원점을 기본 중심점에서 top, left로 바꿔줍니다.

#img-wrapper {
  transform-origin: top left;
  transform: scale(1);
}

현재 이 상태에서 우상단을 핀치줌을 하면 좌상단 (0, 0)은 고정되어있고, 오른쪽 하단으로 scale 이 늘어나는 것을 알 수 있습니다. 좀 이상하겠죠?

생각(또는 시뮬레이션)을 해보면 아래처럼 동작해야 할 것입니다.

어쨋거나 목표는 x, y를 얼마만큼 이동 시켜야 하는지에 대한 bx(biasX), by(biasY)를 구해야 합니다.

  • x = x - bx
  • y = y - by

x(translateX), y(translateY) 상태 추가

그러기 위해서 먼저, x(translateX), y(translateY) 에 대한 상태를 추가해주도록 합니다. 해당 값을 통해 타겟 영역 위치를 조절할 것입니다.

export function touchInit(screen: HTMLElement, target: HTMLElement) {
  // 타겟의 상태 값
  const state: TransformState = {
    x: 0,
    y: 0,
    scale: 0.5,
  };

  // 타겟의 상태 값 수정 및 렌더링
  const setState = ({ x, y, scale }: TransformState) => {
    state.x = x;
    state.y = y;
    state.scale = scale;
    target.style.transform = `translateX(${x}px) translateY(${y}px) scale(${scale})`;
  };

  // 상태 값을 가져오는 함수
  const getState = () => {
    return state;
  };

  pinchZoom({ screen, setState, getState });
}

참고로 다 아시겠지만 브라우저는 좌측 상단이 (0, 0)으로 시작되며, 우측 상단으로 갈수록 (+, +) 값이 커집니다. 이를 translate로 생각해봤을 때 다음과 같습니다.

  • translateX : 양수이면 오른쪽으로 이동, 음수이면 왼쪽으로 이동
  • translateY : 양수이면 아래로 이동, 음수이면 위로 이동

centerX, centerY 좌표 구하기

pinch zoom을 했을 때 두 터치간의 중심점(centerX, centerY) 좌표를 구해야 x, y를 어디로 얼마나 이동 시킬지 구할 수 있게 됩니다. 따라서 touchMoveHandler에 centerX, centerY를 구하는 로직을 추가해줍니다.

function touchMoveHandler({ event, onPinch }: TouchMove) {
	...
		if (prevDistance > 0) {
			// 두 터치의 중심점을 구한다
			const x = (evHistory[0].clientX + evHistory[1].clientX) / 2;
			const y = (evHistory[0].clientY + evHistory[1].clientY) / 2;
          
          	// 현재 브라우저 화면을 기준으로 타겟 요소의 위치 top, left를 가져옵니다. 
          	const { top, left } = (event.currentTarget as HTMLElement).getBoundingClientRect();
          
          	// x - left, y - top을 통해 screen 내부의 좌표로 변환한다 (상관없는 screen 기준을 제거하는 느낌)
            // 고정축을 (0, 0)으로 변환하는 로직인거 같다
            onPinch({ zoom, x: x - left, y: y - top });
		}
	...
}

두 터치간의 중심점(centerX, centerY)을 구할 때는 screen의 top, left는 빼주도록 합니다. 이러한 이유는 screen 내부의 좌측 상단 기준을 (0, 0)으로 잡은 상태로 centerX, centerY를 구하기 위한 것입니다.

x와 y를 얼만큼 움직이면 되는지 판단 - bx, by 구하기

자, 이제 x와 y를 얼만큼 이동시키면 되는지에 대한 bx(biasX), by(biasY)를 구하면 됩니다. 이 때 수식을 제대로 세워서 구해야 하는데 이 과정이 만만치 않더군요... 카카오엔터테이먼트 FE 블로그에 나와있는 수식을 이해하기 쉽지 않았고 저 나름대로 생각을 많이 해봤고 드디어 이해했습니다.

차근차근 생각을 해봅시다. 아직 x, y를 업데이트 하지 않은 상태에서 빨간점(cx, cy)을 pinch zoom 하면 아래처럼 될 것입니다.

저 빨간점(cx, cy)은 얼마나 이동을 했을지 생각을 해볼까요? 이걸 구할 수 있다면 bx, by도 구할 수 있기 때문입니다. 아래 그림처럼 말이죠. 현재 빨간점의 위치는 2번이지만, 생각해봤을 때 1번 위치 그대로 변하지 않아야 합니다. 그래야 원하는 곳에 핀치 줌이 되기 때문이죠. (!!)

즉, 저희가 pinch-zoom 한 빨간점이 1번에서 2번으로 이동한만큼 x, y는 반대로 이동해야 합니다.

이걸 비례식으로 생각하면 쉽습니다. 크기 변화(scale)에 따라 빨간점은 이동할 것입니다.

근데 저는 변화량을 알고 싶습니다. bx, by를 변화량이라고 한다면 (다음 위치 - 현재 위치)가 변화량이 될 것입니다. 이를 x, y 기준으로 바꿔서 생각해서 코드를 작성하면 됩니다.

const biasX = ((centerX - x) * (nextScale / scale)) - (centerX - x);
const biasY = ((centerY - y) * (nextScale / scale)) - (centerY - y);

const nextX = x - biasX;
const nextY = y - biasY;
  • centerX - x : x를 기준으로 centerX의 현재 위치
  • (centerX - x) * (nextScale / scale) : x를 기준으로 centerX의 다음 위치
  • biasX : 다음 위치 - 현재 위치 => 즉, 변화량

실행 결과 원하는 곳에 정확히 pinch zoom이 되는 것을 알 수 있습니다.


마치면서

휴... x와 y를 얼만큼 움직이면 되는지 판단하는 bx, by 구하기가 제일 어려웠던 거 같습니다. 지금은 어느정도 이해할 수 있을 거 같은데... 수학과 공간능력이 요구되는 문제였던거 같습니다. ㅠㅠ

근데 이를 계기로 여러가지 재밌는 실습도 진행해볼 수 있을 거 같습니다.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

5개의 댓글

comment-user-thumbnail
2023년 5월 19일

안녕하세요
올려주신 글 잘보았습니다

vue나 리액트에 적용하는 방법과 이미지를 클릭했을때 새로 팝업을 띄운뒤에 하고싶은데

main쪽에 올려주신 코드를 적용해보니 document.getElementById가 undifined가 나와서 에러 발생을 하더라구요

혹시 방법이 따로 있을까요?

1개의 답글
comment-user-thumbnail
2023년 6월 27일

안녕하세요~ 저도 프로젝트를 진해중인데..쇼핑몰에서 상세이미지를 확대,축소하려고합니다. 근데 기본적으로 좀 어렵네요 ㅜㅜ 참고로..하이브리드 앱입니다~제가 잘몰라서 그런데 .ts파일을 script src로 불러올수있나요? 일반 js파일로 만들수없을까요?

1개의 답글