본문으로 건너뛰기
2021. 8. 27
© WONKOOK LEE

Image Zoom on Hover 기능의 이미지 뷰어 바닐라 자바스크립트로 구현하기


쇼핑몰 상세페이지에서 흔히 볼 수 있는 확대 뷰어
쇼핑몰 상세페이지에서 흔히 볼 수 있는 확대 뷰어
정보

2021년 8월 27일에 작성된 포스트를 옮겨왔습니다. 원본 글 확인하기

Image Zoom on Hover 이미지 뷰어

Image Zoom on Hover 기능은 쇼핑몰 상세 페이지에서 흔히 볼 수 있는 기능입니다. 마우스 커서를 이미지 영역 위에 올리면 확대된 이미지를 보여주는 뷰포트가 생겨 별도의 모달이나 윈도우 없이도 이미지를 자세히 볼 수 있는 인터랙션입니다.

라이브러리 없이 자바스크립트를 활용하여 사용자 인터랙션을 직접 만들어보고 싶었고, 객체의 좌표를 활용한 효과는 어떻게 만들 수 있는지 학습할 목적으로 만들었습니다.



문제의 정의

문제 해결은 문제를 정의하고, 내가 원하는 것을 명확히 하고, 차근차근 해결해 나가는 과정이라고 생각합니다.

구현할 이미지 뷰어 구조
구현할 이미지 뷰어 구조

아래 다섯 개의 명제가 이미지 줌 기능이 정상적으로 작동되기 위한 조건입니다. 아래 명제들을 가정하고, 하나씩 풀어가는 과정을 설명해드립니다.

  • 이미지 영역 위에 마우스 커서를 올리면 이너 프레임과 확대창이 나타난다.
  • 마우스 커서가 영역 밖으로 나가면 이너 프레임과 확대창은 사라진다.
  • 이너 프레임은 마우스 커서를 따라 움직인다.
  • 이너 프레임은 이미지 영역 경계 안에서만 움직인다.
  • 확대 이미지는 정확히 이너 프레임 영역의 이미지가 보여야 하며, 크기가 달라도 움직임은 동기화되어야 한다.
const zoomFrame = document.querySelector(".zoomFrame");
const zoomLens = document.querySelector(".zoomLens");
const zoomWindow = document.querySelector(".zoomWindow");


문제 해결 과정


명제 1 - 이미지 영역 위에 마우스 커서를 올리면 이너 프레임과 확대창이 나타난다.

유저의 어떤 동작을 이벤트로서 인식할 것인지 정의하면 문제 해결이 쉬워집니다.

줌 프레임 영역에 커서가 진입했다 + 마우스가 움직인다 = mousemove

zoomFrame.addEventListener("mousemove", callbackFn);

mouseenter 영역 안으로 진입했는지 여부만 따지고, mouseover는 이벤트 입력 주기가 너무 띄엄띄엄이라 사용하지 않았습니다.
더 높은 FPS로 부드러운 동작이 가능한 mousemove를 사용했습니다. 아래 비교를 보면 차이가 확연하게 보입니다.

mousemove(좌)와 mouseenter(우) 이벤트의 동작 차이
mousemove(좌)와 mouseenter(우) 이벤트의 동작 차이

영역 내의 움직임을 제어하는 함수는 별도의 함수로 선언합니다.

function handleMouseMove(event) {
zoomLens.style.display = "block";
zoomWindow.style.display = "block";
}


명제 2 - 마우스 커서가 영역 밖으로 나가면 이너 프레임과 확대창은 사라진다.

줌 프레임 영역 밖으로 커서가 나간다 = mouseleave

zoomFrame.addEventListener("mouseleave", () => {
zoomLens.style.display = "none";
zoomWindow.style.display = "none";
});

마우스가 대상 밖으로 나가면 이벤트로 인식되는 mouseleave를 사용했습니다.
마우스가 창을 떠날땐 요소를 숨기는 역할밖에 안하기 때문에 이벤트 리스너 인자 속에 그대로 선언했습니다.



명제 3 - 이너 프레임은 마우스 커서를 따라 움직인다.

요소가 마우스 커서를 따라다니게 하려면 마우스의 좌표를 알아야 합니다.
뷰포트상의 좌표를 찾는 것은 쉽지만 제가 원하는 것은 프레임의 위치와 관계된 상대적 마우스 좌표입니다.

이 값을 얻기 위해서 내가 찾아야 할 것은 아래와 같습니다.

  1. 뷰포트 속 마우스 커서의 상대 좌표
  2. 뷰포트 속 프레임 영역의 상대적 위치

뷰포트 속 마우스 커서의 상대 좌표

이벤트 리스너의 이벤트 객체를 콘솔로 출력하면 아래와 같이 객체 프로퍼티가 표시됩니다. 여기서 눈여겨봐야 할 프로퍼티는 clientX, clientYoffsetX, offsetY입니다.

MouseEvent 객체
MouseEvent 객체

clientX와 offsetX의 차이

clientX는 클라이언트(브라우저) 기준의 커서 좌표값을 나타냅니다.
(X는 가로축 Y는 세로축 기준) 브라우저 페이지에서 X의 좌표 위치를 반환하고, 뷰포트 상단을 0으로 측정합니다. (문서 전체 기준은 pageX)

offsetX는 이벤트가 걸려있는 DOM 요소를 기준으로 한 좌표값을 나타냅니다.
이론적으로는 offsetX를 사용하는 것이 그럴싸하지만 어째서인지 clientX와 offsetX의 값이 같았습니다.

어차피 뷰포트를 기준으로 프레임 영역의 위치를 찾아내어 값을 보정할 것이기 때문에 client 좌표를 사용하기로 했습니다.


화면에서 보여지는 요소의 상대적 위치와 크기정보

getBoundingClientRect()라는 메소드는 화면에서 보여지는 요소의 상대적 위치와 크기 정보가 담긴 객체를 반환합니다.
이 내용은 이전 포스팅에서 다룬 적이 있습니다.

DOMRect 객체의 구조
DOMRect 객체의 구조

영점 조절

3D 모델링과 관련된 프로그램을 다뤄본 사람이라면 누구나 알 것입니다. 모든 것은 영 콤마 영에서 시작된다는 것을. 다른 점이라면 y의 포지티브 방향을 반전해서 사용한다는 점입니다.

좌표와 위치 정보를 얻었으니 좌상단을 (0, 0)으로 보정해야합니다.
프레임의 좌상단 모서리 끝에 마우스가 있으면 마우스의 (x, y)가 프레임의 Rect 프로퍼티의 (left, top) 값과 일치할 것입니다. (둘 다 프레임에 대비한 상대적 값이기 때문)

따라서 영점 조절된 마우스 좌표를 아래와 같이 계산할 수 있게 됩니다.

// DOMRect 객체에서 left, top값을 분해하여 할당
const { left, top } = zoomFrame.getBoundingClientRect();

// 마우스의 client 좌표에서 프레임의 위치 좌표를 제거하여 영점 맞추기
const x = event.clientX - left;
const y = event.clientY - top;

아래와 같이 값이 잘 들어오는 것을 확인할 수 있습니다. 요즘은 원하는 값이 들어오는것을 보는 것만큼 짜릿한게 또 없습니다.

DOMRect 객체의 구조
DOMRect 객체의 구조

마우스 커서 좌표와 이너 프레임 위치 연동하기

위에서 구조 분해 할당으로 좌표값을 변수 x와 y에 할당했습니다. 이너 프레임의 left와 top 값에 좌표를 연동해주면 아래와 같이 표현됩니다.
여기서 주의해야 할 것은 값이 사용될 곳이 CSS이기 때문에 단위 값을 문자열로 붙여줘야 한다는 점입니다.

zoomLens.style.left = x + "px";
zoomLens.style.top = y + "px";
DOMRect 객체의 구조
DOMRect 객체의 구조

위와 같이 마우스의 좌표와 이너 프레임 위치의 영점이 연동된 것을 볼 수 있습니다. 하지만 마우스 커서는 이너 프레임의 중앙에 위치해야 하기 때문에 이너 프레임의 너비와 높이의 절반을 보정값으로 넣어줍니다.

zoomLens.style.left = x - 153 + "px";
zoomLens.style.top = y - 117 + "px";
DOMRect 객체의 구조
DOMRect 객체의 구조

아름답습니다. 이제 값이 헷갈리지 않도록 객체 안에 정리하면 됩니다. 객체의 프로퍼티로 언제든지 값이 필요할때마다 접근하여 사용할 수 있습니다.

const coord = { x: x - 153 + "px", y: y - 117 + "px" };


명제 4 - 이너 프레임은 이미지 영역 경계 안에서만 움직인다.

기능 구현 중 가장 헷갈리고 까다로운 부분이었습니다.
이너 프레임이 외부 프레임을 경계로 인식하는 모습은 어떻게 로직으로 풀어낼 수 있을까요?
Figma로 한땀 한땀 그려가며 영역별 픽셀 값을 구해봤습니다.

이미지 구획 경계별 좌표값
이미지 구획 경계별 좌표값

커서가 특정 영역에 진입하면 이너 프레임의 위치 값은 특정 축이 고정 값(Static) 또는 변동 값(Dynamic)으로 변환되는지의 여부에 힌트가 있다고 생각했습니다. 영역별 움직임의 변화는 아래 네 가지로 분류할 수 있습니다.

  • X축과 Y축이 정적인 영역
  • X축이 동적, Y축은 정적인 영역
  • X축은 정적, Y축은 동적인 영역
  • X축과 Y축이 동적인 영역 (Center)
이미지 구획 경계별 좌표값

결론적으로 X축과 Y축이 동적인 Center 영역의 네 모서리 좌표를 기점으로 값의 성질이 판가름됩니다.
마우스 커서의 좌표가 어떤 영역에 진입했는지 판단하려면 영역별 조건문을 정리해야합니다.

이미지 구획 경계별 좌표값

커서의 좌표 값을 영역의 좌표 값과 비교하여 크고 작음을 따지면 커서가 어느 섹터에 진입했는지 알 수 있고, 해당 섹터에 진입했을때 이너 프레임의 좌표를 동적으로, 또는 고정값으로 바꿔주면 됩니다.

이미지 구획 경계별 좌표값

숫자를 남발하게 되면(매직 넘버) 나중에 프레임의 크기가 달라졌을때 수정하기 귀찮고, 누락 또는 오류가 발생하기 쉬워서 변수로 치환해줍니다.
참조해야 할 경계선은 총 네 개, x의 최소값, 최대값과 y의 최소값 최대값입니다.

이미지 구획 경계별 좌표값

값을 변수로 바꿔주면 언제든지 유동적으로 값을 바꿀 수 있고, 비교되는 값의 의미를 명확히 알 수 있습니다.
이 값들은 객체에 저장하여 프로퍼티로 관리하는 것이 바람직 해 보입니다.
의미와 목적이 비슷한 것들은 하나로 묶어서 관리하는 것이 여러모로 깔끔하고 간편합니다.

const boundary = { xMin: 153, xMax: 297, yMin: 117, yMax: 353 };

이제 위 도식을 참고하여 조건문을 만들어 해당 영역에 진입했음을 제대로 표현하는지 시험해봅니다.

if (x <= boundary.xMin && y <= boundary.yMin) {
console.log(5);
} else if (x > boundary.xMin && x < boundary.xMax && y <= boundary.yMin) {
console.log(1);
} else if (x >= boundary.xMax && y <= boundary.yMin) {
console.log(6);
} else if (x <= boundary.xMin && y > boundary.yMin && y < boundary.yMax) {
console.log(3);
} else if (x <= boundary.xMin && y >= boundary.yMax) {
console.log(7);
} else if (x > boundary.xMin && x < boundary.xMax && y >= boundary.yMax) {
console.log(2);
} else if (x >= boundary.xMax && y >= boundary.yMax) {
console.log(8);
} else if (x >= boundary.xMax && y > boundary.yMin && y < boundary.yMax) {
console.log(4);
} else {
console.log("center");
}
이미지 구획 경계별 좌표값

위와 같이 커서가 영역에 진입할 때마다 해당 영역의 숫자가 잘 출력되는 걸 확인할 수 있습니다.
if..else 문은 너무 못생겼으니 switch문으로 바꾸고, 콘솔 대신 이너 프레임의 좌표 값을 조건에 맞춰 바꿔 넣으면 아래와 같습니다.

switch (true) {
case x <= boundary.xMin && y <= boundary.yMin:
zoomLens.style.left = "0";
zoomLens.style.top = "0";
break;

case x > boundary.xMin && x < boundary.xMax && y <= boundary.yMin:
zoomLens.style.left = coord.x;
zoomLens.style.top = "0";
break;

case x >= boundary.xMax && y <= boundary.yMin:
zoomLens.style.left = "145px";
zoomLens.style.top = "0";
break;

case x <= boundary.xMin && y > boundary.yMin && y < boundary.yMax:
zoomLens.style.left = "0";
zoomLens.style.top = coord.y;
break;

case x <= boundary.xMin && y >= boundary.yMax:
zoomLens.style.left = "0";
zoomLens.style.top = "236px";
break;

case x > boundary.xMin && x < boundary.xMax && y >= boundary.yMax:
zoomLens.style.left = coord.x;
zoomLens.style.top = "236px";
break;

case x >= boundary.xMax && y >= boundary.yMax:
zoomLens.style.left = "145px";
zoomLens.style.top = "236px";
break;

case x >= boundary.xMax && y > boundary.yMin && y < boundary.yMax:
zoomLens.style.left = "145px";
zoomLens.style.top = coord.y;
break;

default:
zoomLens.style.left = coord.x;
zoomLens.style.top = coord.y;
}

정적인 값은 고정된 픽셀 값을 넣고, 동적인 값은 좌표 정보를 담은 객체 coord의 프로퍼티 값을 참조합니다.
위 조건문을 넣으면 아래와 같이 의도한 바가 실현되는 아름다은 광경을 목격하게 됩니다.

이미지 구획 경계별 좌표값


명제 5 - 확대 이미지는 정확히 이너 프레임 영역의 이미지가 보여야 하며, 크기가 달라도 움직임은 동기화되어야 한다.

헷갈리지만 않으면 해결할 수 있습니다. 이미지 프레임과 관계된 이너 프레임의 상대적 위치와 확대 뷰포트 백그라운드 포지션을 퍼센테이지로 연동하면 됩니다. 이너 프레임의 위치는 어떻게 구할 수 있을까요? 이 것 또한 바깥 프레임의 좌표 값과 DOMRect와 이너 프레임의 좌표 값을 산술하면 금방 풀립니다.

외부 프레임이 getBoundingClientRect() 메소드로 객체 크기, 위치 정보를 가져왔던 것처럼 이너 프레임도 똑같이 좌표를 가져와 분해 할당합니다.

const { left, top } = zoomFrame.getBoundingClientRect();
const { x: lensLeft, y: lensTop } = zoomLens.getBoundingClientRect();

뷰포트 대비 바깥 프레임의 좌표를 이너 프레임의 좌표에서 빼준 값으로, 이너 프레임의 이동 가능한 최대값을 나누어 백분율을 구하면 됩니다.

프레임 위치 계산

이너 프레임의 x축 위치 백분율 = (lensLeft - left) x 100 / 145
이너 프레임의 y축 위치 백분율 = (lensTop - top) x 100 / 236

이 수식을 CSS 포지션 값으로 적용하려면 아래와 같이 표현하면 됩니다.

zoomWindow.style.backgroundPosition = `${((lensLeft - left) * 100) / 145}% ${
((lensTop - top) * 100) / 236
}%`;

아래와 같이 매우 잘 작동합니다.

이미지 구획 경계별 좌표값

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자