[JS] Screen Dragging 구현하기

NB·2020년 12월 6일
6
post-thumbnail

최근에 Screen Dragging에 대해서 구현해야 하는 작업이 생겼다. 이 Screen Dragging은 Trello와 Notion 등, 정말 많은 곳에서 사용되며 다양한 방법으로 사용될 수 있다. 그래서 이번에 구현한 Dragging 기능에 대해서 글로 남겨보려고 한다. 해당 기능은 크게 3가지로 나뉜다. 아래 GIF을 확인해보자.

1. Element Dragging

2. Screen Dragging

3. Screen Dragging By Element

※ 위 데모사이트는 여기서 확인해볼 수 있으며, 전체 코드는 여기서 확인해볼 수있습니다.


먼저 알아야할 것

위 기능들을 구현하기 전에 필수적으로 숙지해야할 것은 다음과 같다.

Mouse Event

  • clientX, clientY
    : 스크롤을 무시한 브라우저 페이지에서의 X | Y 좌표
  • pageX, pageY
    : 스크롤을 포함한 전체 문서의 기준으로 X | Y 좌표

DOM Element

  • offsetLeft, offsetTop
    : 해당 Element의 부모요소로부터의 상대적인 거리
  • scrollLeft, scrollTop
    : 해당 Element의 스크롤의 위치
  • offsetWidth, offsetHeight
    : 해당 Element의 가로 및 세로 길이

Window Object

  • innerWidth, innerHeight
    : 현재 브라우저의 가로 및 세로 길이
  • requestAnimationFrame
    : 화면 주사율에 최적으로 맞추어 실행하는 함수

잠깐! requestAnimationFrame(callback)를 쓰는 이유는?

사용방식은 재귀함수랑 똑같으며, 돌아가는 원리 자체는 setInterval 또는 throttle과 비슷할 수도 있습니다. 다만 제일 큰 차이점은 Chrome 기준으로 macro queue로 들어가는 Timer 관련 함수와는 달리, animation frame에서 처리되기 때문에 macro queue보다 우선순위를 가지고 작동하게 됩니다. 하지만 주의할 점이 존재합니다. 예로 들어 16ms(60fps)마다 callback이 실행될 때, callback 내부코드가 16ms를 넘어버리게 된다면, 다음 차례의 callback이 생략되게 됩니다. 그러므로 해당 함수를 작성할 때에는, 최대한 작은 task를 수행하도록 하는 것이 좋습니다.

비동기 스케줄링과 Frame의 LifeCycle - Jewoo.Song


Element Dragging

처음에는 이 기능을 개발할 때, 단순히 clientX, clientY 값에 따라서 Element의 top과 left를 갱신시켜주면 되지 않겠느냐는 생각이 들었다. 하지만 실제로 그렇게 구현해보니, 자잘한 끊김이 발생해서 transform을 이용한 방법으로 구현하게 되었다. 실제로 transform을 이용한 방법은 Layout 및 Painting을 건드리지 않고, GPU를 사용하여 좀 더 최적화된 View를 보여준다.

CSS GPU Animation: Doing It Right - Sergey Chikuyonok

1. 구조 작성하기

먼저 아래와 코드와 같이 이벤트 틀을 만들고, 세부적인 코드를 채워나가자!

const dragElement = (e) => {
  const MAX_WIDTH = 5000, MAX_HEIGHT = 5000;
  const elmnt = e.target;
  let = posX = 0, posY = 0, originX = 0, originY = 0, x = 0, y = 0;
  
  function mouseDown(e) { ... }
  
  function mouseMove(e) { ... }
  
  function mouseUp(e) { ... }
  
  mouseDown(e);
}

대략적인 흐름은 다음과 같다.

  1. mouseDown과 동시에 Document에 mouseMove와 mouseUp 이벤트를 등록시켜준다.

  2. mouseMove 중에서는 타겟 Element를 translate를 이용하여 위치시켜준다.

  3. mouseUp이 감지되면, 계산된 좌표를 Element에 적용한 뒤, transform을 지워준다.
    또한, mouseMove와 mouseUP 이벤트를 Document로부터 해제시켜준다.

2. MouseDown 이벤트 작성하기

function mouseDown(e) {
  e.preventDefault();
  originX = e.clientX;
  originY = e.clientY;
  document.onmouseup = mouseUp;
  document.onmousemove = mouseMove;
}

3. MouseMove 이벤트 작성하기

posXposY는 기존 마우스의 X | Y 좌표와 움직였을 때의 마우스의 X | Y 좌표의 차이를 의미한다. 그리고 타겟 Element가 화면을 벗어나지 않게 하기 위해서 MAX_WIDTHMAX_HEIGHT를 두어 해당 좌표 내에서만 Element가 이동할 수 있도록 하였다.

function mouseMove(e) {
  e.preventDefault();
  posX = e.clientX - originX;
  poxY = e.clientY - originY;
  
  if (elmnt.offsetLeft + posX >= 0 && elmnt.offsetLeft + elmnt.offsetWidth + posX <= MAX_WIDTH) {
    x = posX;
  }
  
  if (elmnt.offsetTop + posY >= 0 && elmnt.offsetTop + elmnt.offsetHeight + posY <= MAX_HEIGHT) {
    y = posY;
  }
  
  elmnt.style.transform = `translate(${x}px, ${y}px)`;
}

4. MouseUp 이벤트 작성하기

기존 Element의 좌표를 설정해주고 기존의 transform 효과 및 Event들을 제거해준다.

function mouseUp(e) {
  elmnt.style.transfrom = "";
  elmnt.style.left = `${elmnt.offsetLeft + x}px`;
  elmnt.style.top = `${elmnt.offsetTop + y}px`;
  
  document.onmouseup = null;
  document.onmousemove = null;
}


Screen Dragging

마우스를 이용한 Screen Dragging도 원리는 비슷하다.

1. 구조 작성하기

Element Dragging과 똑같이 틀을 만들고, 세부적인 코드를 채워나가자.

const dragScreen = (target) => {
  const SPEED = 2;
  const screen = target;
  let originX = 0, originY = 0, scrollLeft = 0, scrollTop = 0;
  
  function mouseDown(e) { ... }
  
  function mouseMove(e) { ... }
  
  function mouseUp(e) { ... }
  
  mouseDown(e);
}

2. mouseDown 이벤트 작성하기

해당 함수에서 e.button 이라는 Property가 존재하는데, 대표적으로 0일 경우 왼쪽 클릭, 1일 경우 중간 Wheel 클릭, 2일 경우 우클릭이다. 3과 4도 존재하는데 이 둘은 여기서 확인해보자. 또한, 사용자가 마우스로 끄는 느낌을 주기 위해서 cursor을 grabbing으로 변경하였다.

function mouseDown(e) {
  if (e.button !== 1) return; 		// 마우스 Wheel 클릭이 아니면 return;
  
  e.preventDefault();
  originX = e.pageX - target.offsetLeft;
  originY = e.pageY - target.offsetTop;
  scrollLeft = target.scrollLeft;
  scrollTop = target.scrollTop;
  target.onmouseup = mouseUp;
  target.onmousemove = mouseMove;
  document.body.style.cursor = 'grabbing';
}

3. mouseMove 이벤트 작성하기

해당 식에서 SPEED는 화면을 끌 때, 속도를 지정할 수 있다. 본인은 2가 제일 적당하다고 느껴졌다.

function mouseMove(e) {
  e.preventDefault();
  
  setTimeout(() => {
    const x = e.pageX - target.offsetLeft;
    const y = e.pageY - target.offsetTop;
    target.scrollLeft = scrollLeft - ((x - originX) * SPEED);
    target.scrollTop = scrollTop - ((y - originY) * SPEED);
  }, 0);
}

4. mouseUp 이벤트 작성하기

마찬가지로, Element의 각 Event들을 제거해주고, grabbing 으로 설정되었던 style을 해제시켜준다.

function mouseUp(e) {
  target.onmouseup = null;
  target.onmousemove = null;
  document.body.style.cursor = '';
}


Screen Dragging By Element

이 기능은 어떻게 보면, 위 2개의 기능을 합친 것으로 생각하면 된다. 그럼 코드 작성은 위에서 이미 진행했던, Element Dragging에서 코드를 추가시키는 방법으로 진행해보겠다. 우선 방법은 다음과 같다.

만약, DragMove한 상태에서 Mouse의 clientX | clientY가 위 빨간색 영역에 들어온다면, 해당 방향으로 scroll과 Element를 움직여준다. 또한, 마우스가 화면 가장자리로 갈수록 더 빨리 Scroll을 움직이게 해서, 사용자가 좀 더 부드럽고 자연스럽게 인터렉션할 수 있도록 제작하였다.

1. 이전 구조를 수정해보자

아래와 같이 SPEED, 스크롤을 적용할 canvas Element, 가장자리 여부를 판단하는 isEdge, 그리고 scrolling 이라는 boolean 변수가 하나 더 필요한데, 이에 대해서는 밑에서 좀 더 자세하게 알아보겠다.

const dragElement = (e) => {
  const MAX_WIDTH = 5000, MAX_HEIGHT = 5000;
  const MAX_SPEED = 20; 				// +1 Added! 
  const canvas = document.querySelector('#window');	// +1 Added!
  const elmnt = e.target;
  let = posX = 0, posY = 0, originX = 0, originY = 0, x = 0, y = 0;
  let isEdge = false, scrolling = false;		// +1 Added!
  
  function mouseDown(e) { ... }
  
  function mouseMove(e) { ... }
  
  function mouseUp(e) { ... }
  
  mouseDown(e);
}

2. MouseDown 이벤트 수정하기

MouseDown 함수 같은 경우, 마우스 왼쪽 클릭이 아닌 경우에는 Mouse Wheel로 조작하는 Screen Dragging 기능과 중복될 가능성이 있으므로, Element Dragging 기능이 작동하지 않게 수정하였다.

function mouseDown(e) {
  if (e.button !== 0) return;				// +1 Added!
  
  e.preventDefault();
  originX = e.clientX;
  originY = e.clientY;
  document.onmouseup = mouseUp;
  document.onmousemove = mouseMove;
}

3. 이번엔 MouseUp 이벤트부터 수정해보자

MouseMove 함수를 수정해보기 전에 MouseUp부터 수정해보자. 아래 코드와 같이 달라진 부분은 scrolling 변수와 isEdge 변수를 false로 설정해주는 부분만 추가되었다. 이 변수들은 이제 MouseMove에서 사용될 재귀함수를 중지시키기 위한 플래그로 생각하면 되겠다.

function mouseUp(e) {
  scrolling = false;					// +1 Added!
  isEdge = false;					// +1 Added!
  
  elmnt.style.transfrom = "";
  elmnt.style.left = `${elmnt.offsetLeft + x}px`;
  elmnt.style.top = `${elmnt.offsetTop + y}px`;
  
  document.onmouseup = null;
  document.onmousemove = null;
}

4. MouseMove 이벤트 수정하기

사실상, 이 기능은 MouseMove쪽이 핵심이다. 여러 가지의 경우를 전부 고려해서 코드를 작성해야 한다. 먼저 코드부터 살펴보자.

function mouseMove(e) {
  e.preventDefault();
  
  // +27 Added!
  if (e.clientX < 100) { 						// Left Side
    scrollX = (100 - e.clientX) / 4 > MAX_SPEED
            ? -MAX_SPEED
            : (100 - e.clientX) / -4;
    isEdge = true;
  } else if (e.clientX > window.innerWidth - 100) {			// Right Side
    scrollX = (e.clientX - (window.innerWidth - 100)) / 4 > MAX_SPEED
            ? MAX_SPEED
            : (e.clientX - (window.innerWidth - 100)) / 4;
    isEdge = true;
  } else {								// Center
    scrollX = 0;
  }

  if (e.clientY < 100) {						// Top Side
    scrollY = (100 - e.clientY) / 4 >  MAX_SPEED
            ? -MAX_SPEED
            : (100 - e.clientY) / -4;
    isEdge = true;
  } else if (e.clientY > window.innerHeight - 100) {			// Bottom Side
    scrollY = (e.clientY - (window.innerHeight - 100)) / 4 > MAX_SPEED
            ? MAX_SPEED
            : (e.clientY - (window.innerHeight - 100)) / 4;
    isEdge = true;
  } else {								// Center
    scrollY = 0;
  }
  
  posX = e.clientX - originX;
  poxY = e.clientY - originY;
  
  if (elmnt.offsetLeft + posX >= 0 && elmnt.offsetLeft + elmnt.offsetWidth + posX <= MAX_WIDTH) {
    x = posX;
  } else {						// +3 Added!
    scrollX = 0;
  }
  
  if (elmnt.offsetTop + posY >= 0 && elmnt.offsetTop + elmnt.offsetHeight + posY <= MAX_HEIGHT) {
    y = posY;
  } else {						// +3 Added!
    scrollY = 0;
  }
    
  if (scrollX === 0 && scrollY === 0) {			// +3 Added!
    isEdge = false;
  }
  
  function keepScrolling() { ... }			// +1 Added!
  
  if (isEdge && !scrolling) {				// +5 Added! +-1 Modifed!
    scrolling = true;
    window.requestAnimationFrame(keepScrolling);
  } else {
    elmnt.style.transform = `translate(${x}px, ${y}px)`;
  }
}

위 코드에서 [Left | Right | Top | Bottom] Side 부분이 존재한다. 이 부분이 바로 Mouse가 빨간 영역에 포함되었는지를 판단하는 코드다. 그 여부에 따라 isEdge를 수정해주었으며, 가장자리로 갈수록 스크롤 속도를 증가시키기 위해서 영역의 너비를 4로 나누어서 그 간격에 따라 scrollX | scrollY 값을 정했다. 하지만 속도가 너무 빨라지는 것을 방지하기 위해서 MAX_SPEED를 초과하지 않도록 조건문을 넣어주었다.

또한, 빨간 영역에 마우스가 존재하여도, 현재 Element 위치가 화면 가장자리 밖으로 위치하려고 한다면, scrollX | scrollY 값을 0으로 재설정해주었다.

keepScrolling() 아래 같은 경우는, 가장자리 여부에 따라서 2가지로 구분하였다. 만약 구분 짓지 않는다면, 가장자리에서 드래그할 경우, requestAnimationFrame 함수가 2개 이상으로 중복으로 실행되어 원치 않는 결과를 발생하게 된다. 그렇기에 scrolling 변수를 두어, 현재 keepScrolling() 함수가 진행하는 도중에는 진입하지 못하도록 설계하였다.

그렇다면 keepScrolling() 내부는 어떻게 구성되어 있을까? 확인해보자.

function keepScrolling() {
  if (!(isEdge && scrolling) {
    scrolling = false;
    return;
  }
  
  // X축
  if (elmnt.offsetLeft + x + scrollX >= 0 && elmnt.offsetLeft + x + scrollX + elmnt.offsetWidth <= MAX_WIDTH) {
    canvas.scrollLeft += Math.floor(scrollX);
    x += Math.floor(scrollX);
    originX -= Math.floor(scrollX);
  }
  
  // Y축
  if ((elmnt.offsetTop + y + scrollY) >= 0 && (elmnt.offsetTop + y + scrollY + elmnt.offsetHeight) <= MAX_HEIGHT) {
    canvas.scrollTop += Math.floor(scrollY);
    y += Math.floor(scrollY);
    originY -= Math.floor(scrollY);
  }

  elmnt.style.transform = `translate(${x}px, ${y}px)`;

  window.requestAnimationFrame(keepScrolling);
}

위 코드에서 좌표 계산은 단순히 scrollX | scrollY을 Element와 Scroll에 더해준 것뿐이고, 좀 더 집중해야 할 부분이 존재한다. 바로 상단 부분에서 함수 탈출 조건문 부분과 Math.floor() 부분이다. 위에서 설명한 잠깐! requestAnimationFrame(callback)를 쓰는 이유는? 에서 말한 것처럼 이 함수도 화면 주사율에 따른 함수 실행을 최대한 보장해주려고 하는 함수일뿐이지. 절대적으로 완벽하지는 않다. 그렇기에 함수 내에서 오래 걸리는 작업을 하거나, 여러 개의 requestAnimationFrame(callback) 함수가 동시에 돌아가게 된다면, 의도치 않은 결과가 발생할 수 있으니 주의해야 한다.

Math.floor() 부분이 없어도 작동은 한다. 하지만 실제로 저 부분을 지우고 실행한다면, 화면 가장자리로 Box가 이동했을 때, 미묘하게 점점 Box가 원하는 값이랑 일치하지 않는다는 것을 확인할 수 있다. 이 이유에 대해서는 아직 잘 모르겠다…




혹시 해당 이유에 대해서 알고 있으신 분이 계신다면 알려주시면 감사하겠습니다! ㅠㅠ…


[ 추가적으로 Touch 관련 코드는 전체코드에서 확인하실 수 있습니다! ]

profile
𝙄 𝙖𝙢 𝙖 𝙛𝙧𝙤𝙣𝙩𝙚𝙣𝙙 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙚𝙧 𝙬𝙝𝙤 𝙚𝙣𝙟𝙤𝙮𝙨 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙢𝙚𝙣𝙩. 👋 💻

0개의 댓글