리액트로 이미지 확대 기능 구현하기

이나리·2022년 8월 12일
1

동기

제가 ebay 사이트를 이용하던서 자주 이용하던 기능이 하나 있습니다. 기능 이름이 정확히 맞는지는 모르겠지만, 어떤 기능이냐면 바로 이미지 확대 기능입니다.

이미지를 따로 클릭해서 확대해서 큰 창에서 볼 수도 있지만, 마우스 움직임만으로 내가 원하는 부분만 확대하여 볼 수 있다는 장점이 있습니다.

중고 제품을 파는 사이트여서 사진을 확대해서 자주 들여다보는 편인데, 이 기능을 직접 만들어 제가 만든 프로젝트에도 적용해볼 수 있으면 좋겠다는 생각이 들어 이 기능을 구현하게 되었습니다.

구현 조건

해당 기능을 구현하려면, 일단 무엇이 필요한지 생각해봐야 합니다.

  1. 이미지에 마우스 오버시, 확대할 영역을 설정할 박스가 가장 먼저 필요합니다. 앞으로는 이 박스를 Scanner 라고 부르겠습니다.

  2. Scanner 의 각 모서리는 해당 이미지의 보더라인을 넘어가면 안됩니다. 다시 말하면, 해당 이미지 안에서만 움직여야 합니다.

  3. 이미지 위에서 마우스를 움직일 때, 마우스 커서는 항상 Scanner 의 가운데에 위치해야 합니다. (이미지의 각 모서리에 닿아서 더이상 박스를 움직일 수 없을 때는 가운데에 위치하지 않을 수 있습니다.)

  4. Scanner 가 생성될 때, 이 박스가 가리키는 이미지 영역을 확대해서 보여주는 박스도 필요합니다. 설명을 위해, 이 박스는 View 라고 표현하겠습니다.
    ViewScanner 가 가리키는 영역을 2배 이상 확대하여 보여줘야 하며, Scanner 가 움직일 때마다 그 안에서 보여지는 이미지가 같이 움직여야 합니다.

이제 위와 같은 조건을 갖고, 세부적인 조건을 추가해가며 하나씩 기능을 추가해보겠습니다.

기본 마크업

import { css } from '@emotion/react';

interface Props {
  img: string; // img url
  alt: string;
}

const imageStyle = css({
  width: 500,
  height: 500,
  img: {
  	width: '100%',
  },
});

function ProductImage({ img , alt }: Props) {
  return (
    <div css={imageStyle}>
      <img src={img} alt={alt} />
    </div>
  );
}

이 컴포넌트는 이미지 주소를 props로 전달 받아, 이미지를 렌더링합니다.
img 요소를 div 요소로 감싼 이유는, 이미지의 크기가 얼마나 되는지 모르기 때문에 상위 요소로 한번 감싸준 뒤, 상위 요소의 크기에 이미지의 크기를 맞추기 위해서입니다.

MouseEvent

MouseEnter, MouseOver 이벤트는 이미지에 마우스를 처음 댄 순간에만 실행되기 때문에, 이미지 위에서 움직이는 마우스의 움직임을 포착할 수 없습니다.
따라서, 마우스를 움직일 때마다 실행되는 MouseMove 이벤트가 필요합니다.

function ProductImage({ img , alt }: Props) {
  const onMouseMove = (e: React.MouseEvent) => {};
 
  return (
    <div css={imageStyle} onMouseMove={onMouseMove}>
      <img src={img} alt={alt} />
    </div>
  );
}

그리고 이 이벤트를 img 요소에 부착하면 안됩니다. img 요소에 이벤트를 부착하게 되면, Scannerimg 요소의 자식 요소로 추가해야 이벤트 버블링이 발생하면서 박스를 커서 아래 두고 움직일 수 있습니다.

그런데 img 요소의 자식 요소를 만드는 것은 불가능하기 때문에, 부모 요소에 이벤트 핸들러를 부착합니다.

조건1. Scanner 생성

이제 마우스 오버시에 나타날 Scannerimg 요소의 형제 요소로서 생성할 차례입니다.

그런데 이 Scanner 는 화면에 표시될 위치가 고정되어 있지 않습니다. 이미지 위에서 마우스 커서가 움직일 때마다 그 위치가 변경되기 때문에, 동적으로 위치를 설정해줘야 합니다.

따라서, 부모 컴포넌트에서 그 위치에 대한 상태값을 만들고, 이를 자식 컴포넌트인 Scanner에게 전달하여 동적으로 움직일 수 있도록 합니다.

interface Position {
  left: number;
  top: number;
}

interface ScannerProps {
  position: Position;
}

const scannerStyle = (position: Position) => css({
  position: 'absolute',
  top: position.top,
  left: position.left,
  width: 250,
  height: 250,
  border: '1px solid #000',
  backgroundColor: 'rgba(255,255,255,0.7),
  cursor: 'pointer',
});

function Scanner({ position }: ScannerProps) {
  return (
  	<span css={scannerStyle(position)} />
  );
}

function ProductImage({ img , alt }: Props) {
  const [scannerPosition, setScannerPosition] = useState<Position | null>();
  const onMouseMove = (e: React.MouseEvent) => {};
 
  return (
    <div css={imageStyle} onMouseMove={onMouseMove}>
      <img src={img} alt={alt} />
      {scannerPosition && <Scanner position={scannerPosition} />}
    </div>
  );
}

이때, Scanner 위치의 기준점을 페이지 최상단이 아닌, 가장 가까운 부모 요소인 div 요소로 잡아주는 것이 좋습니다.
중간에 어느 한 요소가 position: relative 값이 적용되어 있으면 Scanner가 원하지 않는 곳에 생성될 수 있기 때문입니다.

조건2. Scanner를 이미지 안에서만 움직이게 하기

이미지 안에서 마우스 커서를 움직였을 때, Scanner 의 모서리가 닿아야 하는 영역을 한번 생각해봐야 합니다.

그림을 잘 보시면, 이미지 안에서만 Scanner가 존재하려면, Scanner의 왼쪽위 모서리 좌표가 왼쪽에서부터 최대 250px, 위에서부터 최대 250px 까지여야 합니다. 회색으로 동그라미 표현된 부분이 바로 이를 가리킵니다.

그 외의 값을 갖게 되면, 이미지 요소를 조금이라도 벗어나게 될 겁니다.

이를 코드로 표현해보면 아래와 같이 작성할 수 있습니다.

const allowedPosLeft = scannerPosLeft >= 0 && scannerPosLeft <= imageWidth - scannerWidth;
const allowedPosTop = scannerPosTop >= 0 && scannerPosTop <= imageHeight - scannerHeight;

조건3. 마우스 커서를 Scanner의 가운데에 위치하기

마우스 커서를 Scanner의 가운데로 위치하고자 할 경우, Scanner의 left, top 값을 현재 커서 위치에서 Scanner 크기의 절반만큼 빼주면 됩니다.

const scannerPosLeft = e.clientX - scannerWidth / 2;
const scannerPosTop = e.clientY - scannerHeight / 2;

그런데 현재 Scanner의 위치는 페이지 최상단이 아니라, 가장 가장 가까운 부모 요소인 div 를 기준으로 하고 있기 때문에, 이렇게 설정해버리면 Scanner가 엉뚱한 곳에 생성되겠죠?

이때 필요한 것이 이미지 요소의 좌표입니다.

elem.getBoundingClientRect 메서드를 사용하면, 해당 요소의 크기와 현재 브라우저 창 기준 좌표를 알 수 있습니다. 아래는 그림으로 이를 간략하게 표현해봤습니다.

앞선 코드에 현재 요소의 좌표 값을 추가적으로 빼주게 되면, 이제 부모 요소인 div를 기준으로 Scanner의 위치가 정확하게 그려지고, 마우스 커서도 Scanner의 가운데로 위치하게 됩니다.

const scannerPosLeft = e.clientX - scannerWidth / 2 - imageRect.x;
const scannerPosTop = e.clientY - scannerHeight / 2 - imageRect.y;

이제 Scanner가 어떤 식으로 위치해야 하는지 원리는 어느 정도 파악했으니, 이 메서드를 이용해 리액트에서 요소의 좌표 값을 갖고 오는 방법에 대해서 간략히 알아보겠습니다.

elem.getBoundingClientRect

이 메서드는 요소에 적용해야 하기 때문에, 리액트에서 돔 요소에 직접 접근하려면 참조값이 필요합니다.
저는 callback ref 를 이용해 요소를 참조했는데요. 이 방식을 이용하면, 컴포넌트가 돔에 마운트되기 직전에 요소의 참조를 갖고 올 수 있습니다.

또, 훅을 사용하여 요소를 참조하는 것과 동시에 메서드의 리턴값을 ref 객체에 저장했습니다.
state 를 사용해도 상관 없었지만, 이 값을 변경시켜서 리렌더링 할 일이 없어 ref 에 저장했습니다.

function useClientRect() {
  const rectRef = useRef<DOMRect>();
  const setRectRef: RefCallback<Element> = element => {
    if (element) {
      rectRef.current = element.getBoundingClientRect();
    }
  };
  
  return [rectRef.current, setRectRef] as const;
}
function ProductImage({ img , alt }: Props) {
  const [imageRect, setImageRectRef] = useClientRect();
  const [scannerPosition, setScannerPosition] = useState<Position | null>();
  const onMouseMove = (e: React.MouseEvent) => {};
 
  return (
    <div css={imageStyle} onMouseMove={onMouseMove}>
      <img src={img} alt={alt} ref={setImageRectRef} />
      {scannerPosition && <Scanner position={scannerPosition} />}
    </div>
  );
}

Scanner 구현 마무리

이제 위에서 구한 값들을 가지고 Scanner의 위치를 결정할 수 있습니다.

const onMouseMove = (e: React.MouseEvent) => {
  // ...코드 생략
  const scannerPosition = { left: 0; top: 0; };
  
  if (allowedPosLeft) {
    scannerPosition.left = scannerPosLeft;
  } else {
    if (scannerPosLeft < 0) {
      scannerPosition.left = 0;
    } else {
      scannerPosition.left = imageRect.width - scannerWidth;
    }
  }

  if (allowedPosTop) {
    scannerPosition.top = scannerPosTop;
  } else {
    if (scannerPosTop < 0) {
      scannerPosition.top = 0;
    } else {
      scannerPosition.top = imageRect.height - scannerHeight;
    }
  }

  setScannerPosition(scannerPosition);
};

중간에 if문에 대해 추가적으로 설명을 드리면, scannerPos 는 Scanner의 왼쪽위 모서리 위치 값을 뜻하는데, 이 값이 항상 allowedPos 에 허용되는 값은 아닙니다.

쉽게 말해, Scanner가 이미지 요소에 일부는 걸쳐 있고 일부는 밖으로 튀어 나와있는 경우를 생각하면 되는데요. 이렇게 되버리면, Scanner가 항상 이미지의 보더라인 안에서만 존재해야 한다는 조건이 깨지게 됩니다.

따라서, 중첩된 if문을 추가한 이유는 allowedPos 에 일치하지 않는 Scanner의 왼쪽위 모서리 값일 경우, 가장 가까운 모서리에 붙도록 만듦으로서 Scanner가 항상 이미지의 보더라인 안에서만 움직일 수 있도록 해주는 것입니다.

그리고 rect 값의 경우, 첫 렌더링 시에는 undefined 값이기 때문에 조건을 추가해줘야 합니다.

function ProductImage({ img , alt }: Props) {
  const [imageRect, setImageRectRef] = useClientRect();
  const [scannerPosition, setScannerPosition] = useState<Position | null>();
  const onMouseMove = (e: React.MouseEvent) => {
    if (imageRect) {
      // ...코드 생략
    }
  };
  
  const onMouseLeave = () => {
    if (imageRect) {
      setScannerPosition(null);
    }
  };
 
  return (
    <div css={imageStyle} onMouseMove={onMouseMove}>
      <img src={img} alt={alt} ref={setImageRectRef} />
      {imageRect && scannerPosition && (
        <Scanner position={scannerPosition} />
      )}
    </div>
  );
}

아래는 위에서 설명한 것들을 토대로, 제 프로젝트에서 완성한 Scanner 입니다.

조건4. ZoomView 생성

사실 Scanner가 생성되었다면, View 는 생각보다 어렵지 않습니다.
물론 저는 이를 처음 구현하는 과정에서 시행착오를 많이 거치는 바람에 헤맸는데, 완성을 하고 코드를 다시 봤을 때는, 이게 이렇게 간단하게 해결될 거였나 싶었습니다.

View 부분이 어렵지 않은 이유는, Scanner를 생성할 때 마우스 움직임에 관한 로직을 모두 작성했기 때문에 그것을 이용하기만 하면 됩니다.

function ProductImage({ img , alt }: Props) {
  const [imageRect, setImageRectRef] = useClientRect();
  const [scannerPosition, setScannerPosition] = useState<Position | null>();
  const [viewPosition, setViewPosition] = useState<Position | null>();
  const onMouseMove = (e: React.MouseEvent) => {
    if (imageRect) {
      const scannerPosition = { left: 0, top: 0 });
      // ...코드 생략
      setScannerPosition(scannerPosition);
      setViewPosition({
        left: scannerPosition.left * -2,
        top: scannerPosition.top * -2,
      }),
    }
  };
  
  const onMouseLeave = () => {
    if (imageRect) {
      setScannerPosition(null);
      setViewPosition(null);
    }
  };
 
  return (
    <div css={imageStyle} onMouseMove={onMouseMove}>
      <img src={img} alt={alt} ref={setImageRectRef} />
      {imageRect && scannerPosition && (
        <Scanner position={scannerPosition} />
      )}
      {imageRect && viewPosition && (
        <ZoomView
          position={viewPosition}
		  img={img}
		  left={imageRect.width + 20}
		/>
      )}
    </div>
  );
}

View 역시 Scanner처럼 동적으로 이미지가 움직여야 하기 때문에, viewPosition 이라는 또다른 상태값을 만듭니다. 그런데 이 값을 scannerPosition 과는 달리 -2 를 곱해줬는데요.

View 의 크기는 이미지 요소의 크기와 동일하게 그릴 것이지만, 2배 확대한 이미지를 보여줘야 하기 때문에, 위치 이동을 Scanner와 동일하게 해버리면 확대한 이미지 부분이 끝까지 이동하지 않고 중간까지만 이동하는 현상이 발생합니다. 그래서 이를 확대한 비율만큼 곱해서 이동해주는 것입니다.

음수 값을 곱하는 이유 역시 비슷합니다. 코드를 먼저 보여드리고 설명드리겠습니다.

이번에는 이미지를 요소로 만들지 않고 백그라운드 이미지로 적용해봤습니다.

interface Position {
  left: number;
  top: number;
}

interface Props {
  img: string;
  position: Position;
  left: number;
}

const viewStyle = ({ img, position, left }: Props) => css({
  zIndex: 1,
  position: 'absolute',
  top: 0,
  left,
  width: 500,
  height: 500,
  backgroundImage: `url(${img})`,
  backgroundRepeat: 'no-repeat',
  backgroundPosition: `${position.left}px ${position.top}px`,
  backgroundSize: '200% 200%',
});


function ZoomView({ img, position, left }: Props) {
  return (
    <div css={viewStyle(img, position, left)} />
  );
}

background-size 속성을 이용해 백그라운드 이미지의 크기를 2배 확대하여 요소의 크기가 이미지의 크기보다 작은 상태이기 때문에, 전체 이미지가 부모 요소에 일부 가려져 있습니다.

이때, 음수값을 background-position 에 동적으로 전달하게 되면, 부모 요소에 가려졌던 이미지의 특정 부분을 볼 수 있게끔 할 수 있습니다.

(양수값을 전달하게 되면, 요소로부터 왼쪽위에서부터 백그라운드 이미지가 멀어지게 되므로 원하지 않은 결과를 만들 수 있기 때문에 음수값을 전달합니다.)

최종 결과물

어떤가요? 데이터가 많이 없어 단조롭다고 생각했던 저의 프로젝트에 한줄기 빛이 되어준 기능입니다.

그리고 설명이 길어지면서 일부 설명을 생략한 부분이 있는데요. 여기서 조금 적어보자면,

Scanner의 left, top 값을 계산하는데 pageX, pageY 도 있는데 왜 clientX, clientY 를 사용했는지 궁금하지 않으셨나요?

현재는 밑에 다른 컨텐츠가 없어서 스크롤이 생성되지 않습니다만, 만약 다른 컨텐츠로 인해 스크롤이 생겼을 때 한가지 차이가 발생합니다.

pageX, pageY 의 경우에는 페이지를 기준으로 하기 때문에, 스크롤이 변경돼도 값이 그대로이지만,
clientX, clinetY 는 브라우저 창을 기준으로 하기 때문에, 스크롤이 변경되면 그 값도 변경됩니다.
이게 경우에 따라서는 어떤 것이 더 유용할 수도 있고, 아닐 수도 있습니다.

제 경우에는 세로 스크롤이 생겨서 스크롤을 조금 내린 상태로 Scanner를 움직이려고 하면 page 를 사용하는 경우, Scanner의 top 속성값이 고정되어 Scanner가 움직이지 않는 버그가 발생했습니다. 따라서 값이 유동적으로 변경되는 client 를 사용한 것입니다.

1개의 댓글

comment-user-thumbnail
2023년 11월 16일

정리 깔끔하게 잘해주셨네요! 덕분에 잘 보고 갑니다!!

답글 달기