next js 웹뷰 app 에서 줌 확대 기능 구현 하기

이명진·2023년 10월 16일
0

앱에서 웹뷰로 페이지를 구성하는 작업을 맡게 되었다.

그냥 페이지를 뿌려주면 되는데 페이지가 기사형식이어서 이미지가 디테일한데 폰에서는 작게 보여서 확대 기능을 추가해야 했다. 그래서 확대하는 방법에 대해서 찾아보다가 적용하게 되었다.

두가지 방법이 있다.

첫번째 방법

meta태그 이용
next js를 활용하고 있어서 meta 태그를 활용하는 방법에 대해서 적용하게 되었다.

단순하게 한페이지를 사용했기 때문에 기본 page.tsx 에 태그를 이용해서 태그를 넣어주었다 .

<Box sx={containerCss}>
    <Head>
      <meta
        name="viewport"
        content="initial-scale=1.0, width=device-width maximum-scale=2.0, minimum-scale=1.0, user-scalable=yes,target-densitydpi=medium-dpi"
      />
    </Head>
    <ArticleBody />
  </Box>

이런식으로 maximum scale을 조정해주면서 확대가 되도록 설정해주었다.

단점이 있다면 코틀린과 연계를 했는데 코틀린 자체 코드에 확대를 막는 코드가 있었을 경우 작동하지 않았다.
또한 이미지만 확대를 하고 싶은데 기사글까지 확대 되어 버리는 이쁘지 않은 상황이 발생하게 되어서 이 방법은 적용했지만 반려가 되었다.

그래서 이미지만 확대할수 있는 방법에 대해서 검색하다가 카카오 기술블로그에서 작성하신 분의 글을 보고 많은 도움을 받았다.

출처 : https://fe-developers.kakaoent.com/2023/230310-webview-pinch-zoom/
원문 글은 위의 출처에서 볼수 있다.
이분은 html + ts 로 구현을 하였는데 쭉 글을 읽어보고 next.js에 맞춰서 개발하게 되었다.

한번 쭉 읽었는데 글 작성하신분이 대단하다고 느꼈다. 공식들도 활용하고 기본 원리를 알기 위해서 html로 작동하는 코드를 작성하시다니 .. ㄷ..데ㅐ단하다.

한번쭉읽었을때는 완전히 이해하지 못하였는데 코드로 직접 쳐보면서 콘솔을 찍어서 확인해보니 그래도 이해할수 있었다.

원문글에서는 1단계 2단계로 나눠서 작업을 했다.
2단계는 확대하는 꼭지점 부분을 다시 점검해서 계산을 다시하는 보정 작업이었는데 여기까지는 필요 없었기 때문에 1단계로 마무리 할수 있었다.

확인 해보니 잘 작동되었다!! 보이는가 글자는 확대가 안되고 사진만 확대가 되는 모습이!

완성 코드는 아래에 작성해보도록 하겠다.

코드를 작성하다가 문제를 만났던 것을 작성해본다.

문제 직면

코드를 쭉 훑어서 어떻게 작동하는지 작동방법에 대해서 알게 되었다.
자바스크립트에서 touchstart, touchmove,touchend,touchcancel 를 캐치해서 CSS를 조절해주는 방향이 목표다. 쭉 구동 방법을 익히면서 코드를 짜는데 나는 확대가 한단계 밖에 되지 않는 것이었다.

문제의 코드

const handlePinch = (zoom: number) => {
  if (zoom === 0) {
    return;
  }
  // ! touchState.sclae 이 계속 1임
  // const { scale } = touchState;
  // const zoomWeight = 0.02;
  // const nextScale = scale + (zoom > 0 ? zoomWeight : -zoomWeight);
  // setTouchState((prev) => ({ ...prev, scale: nextScale }));

// 해결 한 코드 
  setTouchState((prev) => {
    const { scale } = prev;
    const zoomWeight = 0.02;
    const nextScale = scale + (zoom > 0 ? zoomWeight : -zoomWeight);
    return { ...prev, scale: nextScale };
  });
};

handlePinch 코드가 결국 scale을 변경해줘서 CSS의 scale을 바꿔주는 코드였다.

handlePinch는 콜백함수로 계속 호출되었는데 콘솔을 찍어 보니 계속 1이 나와서 줌인을 해도 1.02가 되고 줌아웃을 해도 0.98이 되고 안움직였다.
이유를 확인해보니 setTouchState에서 상태를 업데이트할 때 이전 상태에 대한 클로저 문제 때문이라고 한다.
클로저로 함수가 종료가 되었는데 다시 콜백함수로 호출되니 내부의 값을 계속 사용해서 1로 계속 메모리에 남아있다는 것으로 이해할수 있었다.

그래서 해결한 코드는 setState 값으로 바로 변경하도록 함수 호출 하는 방법으로 수정하였다.
수정하였더니 잘 구동되는 것을 확인할수 있었다.

완성 코드

목적이 컴포넌트화 시켜서 작동하도록 구현하는 것이 목적이었기 때문에 컴포넌트화 시켜주었다.


import { useEffect, useRef, useState } from "react";
type Props = {
children: JSX.Element;
};
const PinchZoom = ({ children }: Props) => {
const [touchState, setTouchState] = useState({
  scale: 1,
});
const prevDiff = useRef<number>(-1);
// const [evHistory, setEvHistory] = useState<Touch[]>([]);
const evHistory = useRef<Touch[]>([]);
const screen = useRef<HTMLDivElement>(null);
const imgWrapper = useRef<HTMLDivElement>(null);
const preventZoom = () => {
  function listener(event: TouchEvent) {
    // 핀치 줌의 경우 두개 이상의 이벤트가 발생한다
    if (event.touches.length > 1) {
      event.preventDefault();
    }
  }
  document.addEventListener("touchmove", listener, { passive: false });
};
const handlePinch = (zoom: number) => {
  if (zoom === 0) {
    return;
  }
  // ! touchState.sclae 이 계속 1임
  // const { scale } = touchState;
  // const zoomWeight = 0.02;
  // const nextScale = scale + (zoom > 0 ? zoomWeight : -zoomWeight);
  // setTouchState((prev) => ({ ...prev, scale: nextScale }));
  setTouchState((prev) => {
    const { scale } = prev;
    const zoomWeight = 0.02;
    const nextScale = scale + (zoom > 0 ? zoomWeight : -zoomWeight);
    return { ...prev, scale: nextScale };
  });
};
const touchStartHandler = (e: TouchEvent) => {
  const touches = e.changedTouches;
  if (evHistory.current.length + touches.length <= 2) {
    for (let i = 0; i < touches.length; i++) {
      const touch = touches[i];
      evHistory.current.push(touch);
    }
  }
};
const touchEndHandler = (e: TouchEvent) => {
  const touches = e.changedTouches;
  for (let i = 0; i < touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.current.findIndex(
      (cachedEv) => cachedEv.identifier === touch.identifier
    );
    if (index > -1) {
      evHistory.current.splice(index, 1);
    }
  }
};
const touchMoveHandler = (e: TouchEvent, onPinch: (zoom: number) => void) => {
  // console.log(evHistory, "evHistopry", touchState);
  const touches = e.changedTouches;
  for (let i = 0; i < touches.length; i++) {
    const touch = touches[i];
    const index = evHistory.current.findIndex(
      (cachedEv) => cachedEv.identifier === touch.identifier
    );
    if (index !== -1) {
      evHistory.current[index] = touch;

      // 두 개의 터치가 진행중인 경우 핀치 줌으로 판단한다
      if (evHistory.current.length === 2) {
        const xDiff =
          evHistory.current[0].clientX - evHistory.current[1].clientX;
        const yDiff =
          evHistory.current[0].clientY - evHistory.current[1].clientY;
        const curDiff = Math.sqrt(xDiff * xDiff + yDiff * yDiff);
        // 첫 핀치의 경우 비교군이 없으므로 prevDiff가 -1인 경우 생략한다.
        if (prevDiff.current > 0) {
          const zoom = curDiff - prevDiff.current;
          onPinch(zoom);
        }
        prevDiff.current = curDiff;
      }
    }
  }
};
useEffect(() => {
  preventZoom();
  screen.current?.addEventListener("touchstart", (e) => touchStartHandler(e));
  screen.current?.addEventListener("touchmove", (e) =>
    touchMoveHandler(e, handlePinch)
  );
  screen.current?.addEventListener("touchend", (e) => touchEndHandler(e));
  screen.current?.addEventListener("touchcancel", (e) => touchEndHandler(e));
}, []);
return (
  <>
    <div id="screen" ref={screen} style={{ overflow: "hidden" }}>
      <div
        id="img-wrappper"
        ref={imgWrapper}
        style={{ transform: `scale(${touchState.scale})` }}
      >
        {children}
      </div>
    </div>
  </>
);
};

export default PinchZoom;

기본 계산으로 바뀌는 값이 많아서 전부 useState값으로 변경하려고 했는데 계산으로 무한 렌더링이 발생할것이 우려가 되어서 계산하는 값들은 useRef 값으로 뺐다. 더 효율적이기 위해 useMemo,useCallback 등을 활용할수 있을것 같은데 리팩토링은 다음에 하기로 하자. (코드를 이해하고 코드를 짜는데만 반나절이 소요가 되었다 )

콘솔로 이벤트들을 다 찍어보니 확실히 터치한 값을 잘 찾아내고 위치 값을 찾아내서 조정을 하는 듯 작동하는 것을 알수 있었다.

카카오 기술 블로그 작성자 분 덕분에 일을 그래도 수월하게 마무리 할수 있었다. 감사의 말을 전해드린다. 성장을 위해서 계속 공부하며 나가야 겠다.

profile
프론트엔드 개발자 초보에서 고수까지!

0개의 댓글