[React] Zoom and Pan 기능 구현하기

dosilv·2021년 7월 1일
29
post-thumbnail
post-custom-banner

드래그 앤 드랍 이후 추가로 구현한 기능들 🌟

  • 휠 조작 or 돋보기 아이콘 클릭으로 zoom in/out
  • 배경 드래그하여 움직이기 (pan)
  • 요소 선택 후 ctrl+v => 복제
  • ctrl+z => 실행 취소

젤 힘들었던 zoom이랑 pan.....🤮🤮🤮 까먹기 전에 기록해 놓기



💻 기본 구조&원리 설명

우선 기본적인 이해를 돕기 위해...! 노란 선으로 표시된 영역이 코드에서 Frame이라는 태그(스타일드 컴포넌트)이고, 파란 선으로 표시된 숨겨진 부분 전체가 StockContaier라는 태그(이하 컨테이너)이다. Frame이 StockContainer를 감싸고 있는 구조인데, Frame에는 overflow: hidden 속성을 주고 StockContainer의 transform: Scaletop, left값을 조정해서 zoom, pan 기능을 구현했다.



💻 Zoom in/out

🔍 비율 조작을 위한 state 변수 생성

우선 ratio라는 이름으로 state 변수를 생성하고, 초기값은 1로 주었다. (아무 조작도 안 했을 땐 1배이기 때문)

ratio는 비율 변경할 때 뿐만 아니라 이동할 때나 아이템 위치 잡을 때도 필요하기 때문에 매우 매우 중요한 변수다!

const [ratio, setRatio] = useState(1);

그리고 zoom in/out할 대상(컨테이너)에 ratio를 Prop으로 전달하고 (저거 다 필요한 props....🌚)

<StockContainer
  ref={stockContainer}
  ratio={ratio}
  onWheel={wheelHandler}
  onDragStart={moveScreenStart}
  onDrag={moveScreen}
  onDragEnd={moveScreenEnd}
  draggable
>

스타일트 컴포넌트를 정의한 부분에서 아래처럼 ratio를 사용한다.

const StockContainer = styled.div`
  position: relative;
  top: 0;
  left: 0;
  width: ${props => 200 / props.ratio}%;
  height: ${props => 200 / props.ratio}%;
  transform: scale(${props => props.ratio});
  transform-origin: left top;
`;

🔧 transform: scale()

제일 기본적으로 필요한 것! 괄호 안에 넣은 숫자의 배율만큼 요소의 크기를 증가 또는 감소시키기 때문에 zoom in/out을 구현하는 가장 근본적인 속성

🔧 width/height

처음에는 마우스로 이동할 영역을 고려해서 부모의 두 배(200%)로 설정했는데, zoom-out을 계속 하다 보면 그것도 모자라서 200%를 ratio로 나눈 값으로 수정했다. (비율이 1보다 작아지면 크기는 점점 커지도록)

🔧 transform-origin

이건 트랜스폼이 일어날 때 어디를 기준점으로 잡고 요소를 변화시키는지 지정하는 속성! 디폴트 값center centre(50% 50%)인데, 컨테이너가 프레임보다 커서 '컨테이너의 중앙=프레임의 right bottom'이기 때문에... 그 지점을 기준으로 zoom-in이 되면 요소가 프레임 밖으로 사라져 버려서 left top으로 설정해 줌.



🔍 휠 이벤트로 비율 조작하기

그러면 이 ratio를 클라이언트의 요청에 따라 변경해 줘야 하는데! 우선 휠의 움직임을 이용하는 방법.

우선 위에서 본 것처럼 배경 요소에 onWheel 이벤트를 걸어 준다.

<StockContainer
  ref={stockContainer}
  ratio={ratio}
  onWheel={wheelHandler}
  onDragStart={moveScreenStart}
  onDrag={moveScreen}
  onDragEnd={moveScreenEnd}
  draggable
>

휠 이벤트의 속성 중 deltaY라는 값이 있는데, deltaY는 휠을 아래로 굴리면 양수, 위로 굴리면 음수로 출력된다. (절댓값은 개인이 마우스 설정에서 설정한 값에 따라 다르다고 함!)

따라서 콜백함수에서 deltaY를 이용해 휠의 방향에 따라 ratio를 조금씩 증가/감소시키되, 최소 0.2배까지만 줄어들도록 하기 위해 삼항연산자로 조건을 걸었다. 최대 비율까지 제한하려면 그냥 if문으로 써야할 듯..!

const wheelHandler = e => {
  setRatio(ratio => (ratio >= 0.2 ? ratio + 0.001 * e.deltaY : 0.2));
};


🔍 클릭 이벤트로 비율 조작하기

클릭은 휠보다 더 쉬움! 돋보기 아이콘에 마우스를 올리면 -, 1.0, + 버튼이 나타나도록 하고 각 요소에 onClick으로 setRatio를 호출했다.

<Ratio>
  <li onClick={() => {
    setRatio(ratio => ratio - 0.25);
  }}></li>
  <li onClick={() => {
    setRatio(1);
  }}>1.0</li>
  <li onClick={() => {
    setRatio(ratio => ratio + 0.25);
  }}>+</li>
</Ratio>;

-/+를 클릭하면 ratio를 0.25씩 증감시키고, 1.0을 클릭하면 1.0으로 만들어 줌!




💻 Pan

🔍 컨테이너 스타일 설정

앞서 봤듯이 컨테이너에는 position: relative속성을 부여하고, topleft의 초기값은 0으로 주었다.

const StockContainer = styled.div`
  position: relative;
  top: 0;
  left: 0;
  width: ${props => 200 / props.ratio}%;
  height: ${props => 200 / props.ratio}%;
  transform: scale(${props => props.ratio});
  transform-origin: left top;
`;

🔧 position: relative 속성을 부여한 이유

top, left값에 변화를 주며 컨테이너를 이동시키려고 하는데, top과 left 속성이 유효하려면 position이 relative, absolute, fixed중 하나여야 한다! fixed를 쓸 경우 요소들이 프레임에 적용한 overflow: hidden을 무시해 버려서 제외하고, relative와 absolute 중... 사실 두 가지 다 시도했는데 작동상 큰 차이는 못 느꼈다. 그래서 그냥 relative로 줌(ㅎㅎ;;)
그리고! frame의 position도 relative/absolute 중 하나로 설정해야 컨테이너를 움직일 수 있다.

🔧 여기서 발생한 문제점

그런데! frame과 container에 relative속성을 주다 보니까.... 아이템을 드래그&드랍할 때 전달한 좌표는 전체 스크린을 기준으로 한 좌표인데, 드랍 후에는 컨테이너를 기준으로 해당 좌표에 요소가 생성되는 문제가 있었다. absolute로 수정해도 마찬가지... 그래서 드랍한 위치와 실제 생성된 위치의 좌표 차이를 구해서 좌표 전달 시 증감시키는 방식으로 어떻게 어떻게 수정하긴 했는데 뭔가 코드가 조잡해져서.... 더 좋은 방법이 없는지 리팩토링해봐야겠다. (언젠가....)


🔍 컨테이너에 drag 이벤트 걸기

이건 드래그 앤 드랍이랑 거의 비슷한 부분... pan도 결국 컨테이너를 드래그해서 이동시키는 것이기 때문에!

onDragStart, onDrag, onDragEnd 이벤트를 모두 걸어줘야 한다.

<StockContainer
  ref={stockContainer}
  ratio={ratio}
  onWheel={wheelHandler}
  onDragStart={moveScreenStart}
  onDrag={moveScreen}
  onDragEnd={moveScreenEnd}
  draggable
>

차이가 있다면 컨테이너의 끝이 프레임 안으로 들어오지 않도록 (left, top이 0보다 커지지 않도록) 제한 조건을 걸어줌!

🔧 onDragStart 콜백함수

const moveScreenStart = e => {
  const img = new Image();
  e.dataTransfer.setDragImage(img, 0, 0);

  posX = e.clientX;
  posY = e.clientY;
};

🔧 onDrag 콜백함수

const moveScreen = e => {
  const limitX = e.target.offsetLeft + (e.clientX - posX) <= 0;
  const limitY = e.target.offsetTop + (e.clientY - posY) <= 0;

  e.target.style.left = limitX
    ? `${e.target.offsetLeft + (e.clientX - posX)}px`
    : '0px';
  e.target.style.top = limitY
    ? `${e.target.offsetTop + (e.clientY - posY)}px`
    : '0px';

  posX = limitX ? e.clientX : 0;
  posY = limitY ? e.clientY : 0;
};

🔧 onDragEnd 콜백함수

const moveScreenEnd = e => {
  const limitX = e.target.offsetLeft + (e.clientX - posX) <= 0;
  const limitY = e.target.offsetTop + (e.clientY - posY) <= 0;

  e.target.style.left = limitX
    ? `${e.target.offsetLeft + (e.clientX - posX)}px`
    : '0px';
  e.target.style.top = limitY
    ? `${e.target.offsetTop + (e.clientY - posY)}px`
    : '0px';

  setScreen({ top: e.target.style.top, left: e.target.style.left });
};


💻 Zoom으로 인해 발생한 문제

그런데! zoom-in/out 상태일 때 컨테이너 내에서 아이템을 이동시키려면 실제 커서의 좌표 변화값요소의 left, top에 줘야 하는 변화값이 달라지는 문제가 있었다.

🔺 zoom-out 상태에서 드래그 시, 아이템이 커서를 완전히 따라오지 않음


🔧 해결한 방법

zoom-in 상태에서는 커서 이동보다 더 적게, zoom-out 상태에서는 커서 이동보다 더 많이 요소의 위치값을 수정해 줘야 하기 때문에 커서의 변화값(e.clientX/Y - posX/Y)ratio로 나누어서 배율을 반영해 줌! 그러니까 딱 정확하게 움직였다.

const dragHandler = e => {
  e.stopPropagation();

  e.target.style.left = `${
    e.target.offsetLeft + (e.clientX - posX) / ratio
  }px`;
  e.target.style.top = `${e.target.offsetTop + (e.clientY - posY) / ratio}px`;
  
  posX = e.clientX;
  posY = e.clientY;
};


어떻게 구현은 했지만.... 정리하다 보니까 리팩토링이 절실함을 더더욱 느낌 😓

profile
DevelOpErUN 성장일기🌈
post-custom-banner

6개의 댓글

comment-user-thumbnail
2021년 7월 5일

와.... dom을 진짜 잘다루시네요
저도 한번 따라해보면서 배워보겠습니다!

1개의 답글
comment-user-thumbnail
2021년 7월 6일

안녕하세요. 오늘 글 처음 보게됐는데 엄청 깔끔하네요! 썸네일 이미지는 직접 만드신 건가요?

1개의 답글
comment-user-thumbnail
2021년 7월 8일

우와..

답글 달기
comment-user-thumbnail
2022년 11월 20일

깃허브 혹시 주소 구경하고 싶은데 공유 해주실 수 있나요 ?

답글 달기