원본 링크: React에서 직접 구현하는 이미지 미리보기 컴포넌트
최근 블로그에 이미지 미리보기 기능을 추가하려고 했는데, 적합한 라이브러리를 찾지 못해 직접 구현하게 되었습니다.
최종 구현 결과는 다음과 같습니다:
구현 원리
이미지를 클릭할 때 반투명 마스크를 생성하고, 클릭한 이미지와 동일한 위치와 크기의 이미지를 추가한 후 CSS를 통해 이미지 확대 및 중앙 정렬을 구현합니다. 다시 클릭하면 스타일을 제거하여 원래 이미지로 돌아갑니다.
구현 단계
마스크와 이미지 추가
여기서 ReactDom의 createPortal() 메소드를 사용하여 요소를 지정된 위치에 렌더링합니다. 이미지가 원래 위치로 돌아가야 하므로 getBoundingClientRect()로 제공되는 뷰포트 기준 좌표 대신 offsetTop과 offsetLeft으로 제공되는 offsetParent 기준 좌표를 사용해야 합니다. 따라서 마스크와 이미지를 body 요소에 렌더링하고 동일한 레벨에 있어야 합니다. 구현 코드는 다음과 같습니다:
import { createPortal } from 'react-dom';
import { useState, useRef } from 'react';
function OverlayLayer({ imgProps, setShowPreview, imageRef }) {
const closePreview = () => {
setShowPreview(false);
};
return createPortal(
<div onClick={closePreview} className='cursor-zoom-out'>
<div className='fixed bottom-0 left-0 right-0 top-0 bg-black/75'></div>
![]()
</div>,
document.body
);
}
export default function PreviewImage(imgProps) {
const [showPreview, setShowPreview] = useState(false);
const imageRef = useRef(null);
return (
<>
![]() {
setShowPreview(true);
}}
loading='lazy'
/>
{showPreview && <OverlayLayer imgProps={imgProps} setShowPreview={setShowPreview} imageRef={imageRef} />}
</>
);
}
이제 이미지를 클릭하면 body에 마스크와 동일한 위치에 이미지가 생성되며, 다시 클릭하면 닫힙니다.
애니메이션 효과 추가
애니메이션 효과는 주로 CSS의 transition과 transform으로 구현하며, transform은 scale()과 translate 함수를 사용합니다.
scale() 값은 이미지 확대 비율이며, 이미지를 가능한 원래 크기로 확대하지만 화면을 초과하지 않도록 해야 합니다. 따라서 이미지 너비와 높이의 최대 확대 비율을 각각 구한 후 최소값을 비교하여 선택합니다. 하지만 이미지 목표 크기를 계산할 때는 화면 크기와 비교하여 최소값을 선택해야 합니다.
const scaleX = Math.min(naturalWidth, viewportWidth) / width;
const scaleY = Math.min(naturalHeight, viewportHeight) / height;
const scale = Math.min(scaleX, scaleY);
translate() 값은 X축과 Y축에서의 이미지 이동량이며, 이미지를 화면 중앙으로 이동시켜야 하므로 이미지 중심점과 화면 중심점의 가로 및 세로 거리를 구해야 합니다.
const translateX = ((viewportWidth - width) / 2 - left) / scale;
const translateY = ((viewportHeight - height) / 2 - top) / scale;
구체적인 계산 함수는 다음과 같습니다:
const calculateOptimalScale = imageElement => {
const { top, left, width, height } = imageElement.current.getBoundingClientRect();
const { naturalWidth, naturalHeight } = imageElement.current;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const scaleX = Math.min(Math.max(width, naturalWidth), viewportWidth) / width;
const scaleY = Math.min(Math.max(height, naturalHeight), viewportHeight) / height;
const scale = Math.min(scaleX, scaleY);
const translateX = ((viewportWidth - width) / 2 - left) / scale;
const translateY = ((viewportHeight - height) / 2 - top) / scale;
return `scale(${scale}) translate(${translateX}px, ${translateY}px)`;
};
여기서 이동량을 계산할 때 확대 비율로 나누는 이유는 CSS에서 transform의 실행 순서가 있기 때문입니다. 이미지가 scale()로 확대된 후 translate()의 이동 거리도 변하기 때문에 계산 시 미리 고려해야 합니다. 먼저 이동 후 확대하는 경우에는 이 요소를 고려하지 않아도 됩니다.
const translateX = (viewportWidth - width) / 2 - left;
const translateY = (viewportHeight - height) / 2 - top;
return `translate(${translateX}px, ${translateY}px) scale(${scale})`;
최종 코드
마지막으로 스크롤 감지, 화면 크기 변화 감지, 마스크 투명도 변화를 추가하면 최종 함수를 얻을 수 있습니다:
import { createPortal } from 'react-dom';
import { useState, useRef, useEffect } from 'react';
function ImageOverlay({ imgProps, setShowPreview, imageRef }) {
const [transformValue, setTransformValue] = useState('');
const [overlayOpacity, setOverlayOpacity] = useState(0.7);
const closePreview = () => {
setOverlayOpacity(0);
setTransformValue('');
setTimeout(() => {
setShowPreview(false);
}, 300);
};
useEffect(() => {
const handleResize = () => {
setTransformValue(calculateOptimalScale(imageRef));
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
window.addEventListener('scroll', closePreview);
return () => window.removeEventListener('scroll', closePreview);
}, []);
return createPortal(
<div onClick={closePreview} className="cursor-zoom-out">
<div
className="fixed bottom-0 left-0 right-0 top-0 bg-black"
style={{
opacity: overlayOpacity,
transition: 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
></div>
![]()
</div>,
document.body
);
}
export default function ImageWithPreview(imgProps) {
const [showPreview, setShowPreview] = useState(false);
const imageRef = useRef(null);
return (
<>
![]() {
setShowPreview(true);
}}
loading="lazy"
/>
{showPreview && <ImageOverlay imgProps={imgProps} setShowPreview={setShowPreview} imageRef={imageRef} />}
</>
);
}
/**
* 이미지 최적 확대 비율 계산
*/
const calculateOptimalScale = imageElement => {
const margin = 5;
const { top, left, width, height } = imageElement.current.getBoundingClientRect();
const { naturalWidth, naturalHeight } = imageElement.current;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const scaleX = Math.min(Math.max(width, naturalWidth), viewportWidth) / width;
const scaleY = Math.min(Math.max(height, naturalHeight), viewportHeight) / height;
const scale = Math.min(scaleX, scaleY) - margin / Math.min(width, height) + 0.002;
const translateX = ((viewportWidth - width) / 2 - left) / scale;
const translateY = ((viewportHeight - height) / 2 - top) / scale;
return `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0)`;
};
transformValue의 초기값은 calculateOptimalScale()에서 직접 가져오지 않고 useEffect()에서 할당합니다. 왜냐하면 처음부터 이미지에 transform을 정의하면 애니메이션 효과가 발생하지 않기 때문입니다.
참고 링크
CSS transform에서 scale 이후 translate 이해하기 transform 순서의 중요성: rotate/scale과 scale/rotate의 결과가 다른 이유