동작 완성

https://github.com/LeeHyoGeun96/react-zoom-pan-pinch-SwipeGallery

 

GitHub - LeeHyoGeun96/react-zoom-pan-pinch-SwipeGallery

Contribute to LeeHyoGeun96/react-zoom-pan-pinch-SwipeGallery development by creating an account on GitHub.

github.com

 

문제 배경

모든 기능을 화살표로 조작 가능하도록 구현하려고 했는데 중요한 키보드로 줌 하고 패닝으로 조작하는 것은 빼먹은 상태였습니다.

 

문제 분석

├───📁 components/
│   ├───📁 ImageViewer/
│   │   ├───📄 NavigationControls.tsx
│   ├───📁 SwiperGallery/
│   │   └───📄 SwiperGallery.tsx
│   ├───📁 reactZoomPanPinch/
│   │   ├───📄 TransformViwer.tsx
│   │   └───📄 ZoomControls.tsx
│   └───📄 ImageViewer.tsx
├───📁 hooks/
│   ├───📁 ImageViewer/
│   │   ├───📄 useFocusManagement.tsx
│   │   ├───📄 useImageSlider.tsx
│   │   ├───📄 useKeyboardNavigation.tsx
│   ├───📁 TransformViwer/
│   │   ├───📄 usePanningControl.tsx
│   │   ├───📄 useSwipeMessage.tsx
│   │   ├───📄 useTransformViewerShortcuts.tsx
│   │   └───📄 useZoomControl.tsx

 

<간소화한 폴더 구조>

imageViewer 형태 시각화 앞의 보이는 사진 이외에도 뒤에 미리 slide가 만들어져 있는 상태

 

  • 화살표 기능들중 좌, 우가 이미 슬라이드를 움직이는 키에 할당이 되어 있어서 해결책이 필요했습니다. 해당 기능은 useKeyboardNavigation 훅에 구현되어 있는데 isZoomed 상태는 하위 컴포넌트인  TransformVeiwer에 해당  컴포넌트에 줌 상태를 끌어와야합니다.
  • 패닝 그리고 줌과 관련된 구현을 해야하기 때문에 상태를 받아와야합니다.. 줌과 패닝은 react-zoom-pan-pinch의 뷰어에서 가져오는 것이기 때문에 react-zoom-pan-pinch의 컴포넌트인 TransformWrapper에서 값을 가져와야 합니다.
  • 상태를 받아와 움직임을 조작하는 함수를 만들어야 합니다, 줌한 상태에서 드래그 했을 때와 마찬가지로 줌한 상태에서 경계값에 드래그 시도가 있을 시 안내 문구를 보여줘야 합니다.
  • 숏컷을 등록해야합니다.

해결

과정 1. 좌, 우 화살표 스와이핑 막기

// ImageViewer
const [isZoomed, setIsZoomed] = useState(false);

useKeyboardNavigation({
    isZoomed: isZoomed,
  });

// TransformViwer
  useEffect(() => {
    setIsZoomed(isZoomed);
  }, [isZoomed, setIsZoomed]);

방법:

ImageViewer에서 isZoomed 상태를 만들고 setIsZoomed를 내려보내어 TransformViwer에서 zoom 상태가 업데이트 될 때마다 업데이트 되게 했습니다. 

그리고 그 값을 useKeyboardNavigation 훅에 전달하여 isZoomed가 true 일경우 스와이프 동작을 막았습니다.

 

과정 2. 상태 가져오기

 //TransformViwer
 
   const { debouncedHandlePanning, handlePanningStop, handleKeyboardPanning } =
    usePanningControl({
      transformRef: transformRef.current,
   });
 
 <TransformWrapper
  onInit={(ref) => {
    transformRef.current = ref;
	}}
 >

방법:

react-zoom-pan-pinch의 TransformWrapper의 onInit에서 처음 렌더링 될 때 상태를 제공합니다.

받은 상태를 이용하여 확대 축소 등을 하거나 현재 줌이 어떻게 되었는지 위치가 어디인지 등을 알 수 있습니다.

그래서 onInit을 ref값을 받아와 usePanningControl 훅에 넣어줬습니다.

 

과정 3. 기능 구현하기

// useZoomControl

const customZoomIn = useCallback(() => {
    if (!isCurrentImage) {
      return;
    }
    transformRef.current?.zoomIn(0.5);
    setTimeout(() => {
      if (transformRef.current) {
        setIsZoomed(transformRef.current.state.scale > 1.05);
      }
    }, 100);
  }, [isCurrentImage]);

  const customZoomOut = useCallback(() => {
    if (!isCurrentImage) {
      return;
    }
    transformRef.current?.zoomOut(0.5);
    setTimeout(() => {
      if (transformRef.current) {
        setIsZoomed(transformRef.current.state.scale > 1.05);
      }
    }, 100);
  }, [isCurrentImage]);

  const customResetTransform = useCallback(() => {
    if (!isCurrentImage) {
      return;
    }
    transformRef.current?.resetTransform();
    setTimeout(() => {
      setIsZoomed(false);
    }, 100);
  }, [isCurrentImage]);

  const customSetTransform = useCallback(
    (positionX: number, positionY: number) => {
      if (!isCurrentImage) {
        return;
      }
      transformRef.current?.setTransform(
        positionX,
        positionY,
        transformRef.current.state.scale,
        0.2
      );
    },
    [isCurrentImage]
  );
  
  // usePanningControl
const handleKeyboardPanning = useCallback(
    (direction: "left" | "right" | "up" | "down", event: KeyboardEvent) => {
      if (!isZoomed || isPanningRef.current || !transformRef || !isCurrentImage)
        return;

      const ref = transformRef;

      const { state, instance } = ref;

      if (!instance.wrapperComponent) return;

      // 이동 거리 계산 (확대 비율과 키 조합에 따라 조정)
      const panStep = calculatePanStep(state.scale, event) || 0;

      // 현재 위치
      const currentX = state.positionX;
      const currentY = state.positionY;

      // 이미지 경계 계산
      const wrapperWidth = instance.wrapperComponent.offsetWidth;
      const wrapperHeight = instance.wrapperComponent.offsetHeight;
      const contentWidth = wrapperWidth * state.scale;
      const contentHeight = wrapperHeight * state.scale;

      // 경계값 계산 (약간의 여유 공간 추가)
      const maxPositionX = 0;
      const minPositionX = Math.min(0, -(contentWidth - wrapperWidth));
      const maxPositionY = 0;
      const minPositionY = Math.min(0, -(contentHeight - wrapperHeight));

      // 새 위치 계산
      let newX = currentX;
      let newY = currentY;

      switch (direction) {
        case "left":
          newX = Math.min(maxPositionX, currentX + panStep);
          break;
        case "right":
          newX = Math.max(minPositionX, currentX - panStep);
          break;
        case "up":
          newY = Math.min(maxPositionY, currentY + panStep);
          break;
        case "down":
          newY = Math.max(minPositionY, currentY - panStep);
          break;
      }

      // 애니메이션 적용하여 부드럽게 이동 (라이브러리의 setTransform 함수 사용)
      if (setTransform) {
        setTransform(newX, newY); // 200ms 애니메이션
      } else if (instance.setTransformState) {
        // 대체 메서드가 있는 경우 사용
        instance.setTransformState(newX, newY, state.scale);
      }

      // 경계 도달 상태 업데이트
      const threshold = 1;
      if (Math.abs(newX - maxPositionX) < threshold) {
        boundaryReachedRef.current = "right";
      } else if (Math.abs(newX - minPositionX) < threshold) {
        boundaryReachedRef.current = "left";
      } else {
        boundaryReachedRef.current = null;
      }

      // 경계에서 계속 이동하려고 할 때 메시지 표시
      if (
        (direction === "right" && boundaryReachedRef.current === "left") ||
        (direction === "left" && boundaryReachedRef.current === "right")
      ) {
        showSwipeMessage();
      }
    },
    [
      isZoomed,
      showSwipeMessage,
      calculatePanStep,
      isCurrentImage,
      setTransform,
      transformRef,
    ]
  );

방법:

  • 줌과 관련된 코드는 useZoomControl 훅에 구현하였고 패닝과 관련된 코드는 usePanningControl에 넣어 구현하였습니다.
  • 줌과 관련된 코드 앞에 custom이 붙은 이유는 라이브러리에서 기본적으로 제공하는 함수가 있었기 때문입니다. 하지만 그 함수를 통해 zoom을 컨트롤 하면 휠을 이용해 줌을 했을 때와 달리 scale 값이 업데이트 되지 않아 줌 상태를 전달하는데 에러사항이 있었습니다. 그래서 setTimeout을 사용해 isZoomed 상태를 업데이트 해주었습니다.
  • setTimeout(() => { if (transformRef.current) { setIsZoomed(transformRef.current.state.scale > 1.05); } }, 100);

과정 4. 숏컷 등록하기

export function useTransformViewerShortcuts({
  zoomIn,
  zoomOut,
  resetTransform,
  isCurrentImage,
  handleKeyboardPanning,
}: TransformViewerShortcutProps) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        !isCurrentImage ||
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      switch (e.key) {
        case "+":
          zoomIn();
          break;
        case "-":
          zoomOut();
          break;
        case "0":
          resetTransform();
          break;
        case "ArrowLeft":
          handleKeyboardPanning("left", e);
          break;
        case "ArrowRight":
          handleKeyboardPanning("right", e);
          break;
        case "ArrowUp":
          handleKeyboardPanning("up", e);
          break;
        case "ArrowDown":
          handleKeyboardPanning("down", e);
          break;
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [zoomIn, zoomOut, resetTransform, isCurrentImage, handleKeyboardPanning]);
}

 

방법:

zoom과 panning에 관련된 단축키는 해당 파일에 따로 구성하였습니다

지금 프로젝트에는 존재하지 않지만 만약 이 이벤트가 input이나 textArea에서 작성중일 때 발생하는 이벤트일 경우 동작하지 않게 만들었습니다

 

성능 향상

imageViewer 형태 시각화 앞의 보이는 사진 이외에도 뒤에 미리 slide가 만들어져 있는 상태

isCurrentImage 활용

위의 그림을 보면 알 수 있듯이 SwiperSlide가 사진의 개수만큼 만들어집니다

마찬가지로 내부의 훅들도 사진의 개수만큼 등록이 됩니다.

처음에는 그 사실을 고려 안하고 코드를 짰는데 키를 입력했을 때 내부에 있는 모든 슬라이드가 작용을 해서 버벅임이 일어났었습니다.

그 이후 isCurrentImage를 활용하여 내 이미지가 아닐 경우 요청을 넘겨버리게 가드를 설치하여 성능 향상을 이뤘습니다.

+ Recent posts