cover

제 블로그에서 메뉴를 담당하는 Dock을 구현하기 위해 꽤 많은 시간을 썼습니다. 저처럼 구현한 곳은 별로 못봤었거든요. 그냥 아이콘이 커지는 정도에 그친 경우가 대부분이였습니다. 하지만 실제 맥에서 구현된 Dock 마우스 위치에 따라 아이콘이 서로 크기가 미세하게 달라지거든요. 그걸 어떻게 구현했는지 공유하고자 합니다.

코드

위 Codepen에서도 볼 수 있지만 코드를 하나하나 살펴보겠습니다.

HTML

html
      <nav class="dock">
  <div class="item">
    <img src="https://upload.wikimedia.org/wikipedia/en/9/98/FinderBigSur.png">
    <p>Finder</p>
  </div>
  <div class="item">
    <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Adobe_Premiere_Pro_CC_icon.svg/240px-Adobe_Premiere_Pro_CC_icon.svg.png">
    <p>Premiere Pro</p>
  </div>

  <div class="item">
    <img src="https://upload.wikimedia.org/wikipedia/en/2/23/System_Preferences_icon.png">
    <p>Setting</p>
  </div>
  <div class="item">
    <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Safari_browser_logo.svg/2057px-Safari_browser_logo.svg.png">
    <p>Safari</p>
  </div>
  <div class="item">
    <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Instagram_logo_2016.svg/480px-Instagram_logo_2016.svg.png">
    <p>Instagram</p>
  </div>
</nav>
    

요약하면 결국 이렇게 반복되는 겁니다.

html
      <nav>
	<div class="item">
		<img />
		<p>
	<div>
	...(반복)
</nav>
    
image

CSS

css
      .dock {
	/* 왼쪽 중앙에 고정 */
  position: absolute; 
  left: 10px;
  top: 50%;
  transform: translateY(-50%);

	/* dock 가로 크기 결정 */
	/* 세로는 아이콘 개수에 따라 유동적으로 늘어납니다. */
	width: 50px;

	/* 꾸미기 */
  padding: 10px 5px;
  border-radius: 14px;
  background-color: rgb(36 36 36);
  border: 1px solid #565656;
  box-shadow: 0px 0px 0px 1px 3f3f3f;
}

.item {
  position: relative;
}

.item img {
	/* item 크기에 맞게 이미지도 변합니다. */
  width: 100%;
}

.item p {
	/* 각 아이콘의 기준으로 오른쪽 중앙에 나타납니다. */
  position: absolute;
  top: 50%;
  left: 100%;
  transform: translateY(-50%);

	/* 꾸미기 */
  margin: 0 0 0 5px;
  padding: 3px 10px;
  background: #333;
  border-radius: 6px;
  border: 1px solid #565656;
  box-shadow: 0px 0px 0px 1px 3f3f3f;
  white-space: nowrap;
  color: #ddd;

	/* 처음에 글자가 나타나지 않다가 마우스를 가져다 대면 나타납니다. */
  display: none;
}

.item:hover p {
  display: block;
}

/* 배경 */
body {
  background-image: url(https://512pixels.net/downloads/macos-wallpapers-thumbs/10-14-Night-Thumb.jpg);
}
    

Javascript

결국 중요한건 Javascript 내용이겠죠.

javascript
      const MIN_WIDTH = 50;
const MAX_WIDTH = MIN_WIDTH * 2;
const STEP = (MAX_WIDTH - MIN_WIDTH) * 0.05;

let aniID = null;
const dock = document.querySelector(".dock");

const updateWidth = function (nextWidths) {
  window.cancelAnimationFrame(aniID);

  aniID = null;

  let isAllDone = true;
  let newWidth = 0;
  const items = document.querySelectorAll(".item");
  for (let i = 0; i < items.length; i++) {
    const currWidth = items[i].getBoundingClientRect().width;
    const goalWidth = nextWidths[i];
    if (goalWidth < currWidth) {
      newWidth = Math.max(currWidth - STEP, goalWidth);
      isAllDone = false;
    } else if (goalWidth > currWidth) {
      newWidth = Math.min(currWidth + STEP, goalWidth);
      isAllDone = false;
    } else {
      newWidth = goalWidth;
    }
    items[i].style.width = newWidth + "px";
  }

  // 다시 애니메이션 추가
  if (!isAllDone) {
    aniID = window.requestAnimationFrame(() => {
      updateWidth(nextWidths);
    });
  }
};

dock.addEventListener("mousemove", function (e) {
  const dockTop = e.target.getBoundingClientRect().top;
  const y = e.clientY - dockTop;

  const nextWidths = [];
  const items = document.querySelectorAll(".item");
  for (const item of items) {
    const rect = item.getBoundingClientRect();
    const center = rect.top - dockTop + rect.height / 2;

    const dist = Math.abs(center - y);
    nextWidths.push(Math.max(MAX_WIDTH - dist / 4, MIN_WIDTH));
  }
  console.log(nextWidths);
  updateWidth(nextWidths);
});

dock.addEventListener("mouseleave", function (e) {
  const items = document.querySelectorAll(".item");
  const nextWidths = [];
  for (const item of items) {
    nextWidths.push(MIN_WIDTH);
  }
  updateWidth(nextWidths);
});
    

상수값이 몇 개 있습니다.

javascript
      const MIN_WIDTH = 50;
const MAX_WIDTH = MIN_WIDTH * 2;
const STEP = (MAX_WIDTH - MIN_WIDTH) * 0.05;
    
MIN_WIDTH: 기본 크기입니다. CSS의 Dock 가로 크기와 동일해야합니다.
MAX_WIDTH: 마우스를 가져다 대었을 때 가장 커질 수 있는 크기입니다.
STEP: 마우스를 호버시 아이콘이 커지는 속도 조절합니다.
javascript
      const updateWidth = function (nextWidths) {
  window.cancelAnimationFrame(aniID);
  aniID = null;

  let isAllDone = true;
  let newWidth = 0;
  const items = document.querySelectorAll(".item");
  for (let i = 0; i < items.length; i++) {
    const currWidth = items[i].getBoundingClientRect().width;
    const goalWidth = nextWidths[i];
    if (goalWidth < currWidth) {
      newWidth = Math.max(currWidth - STEP, goalWidth);
      isAllDone = false;
    } else if (goalWidth > currWidth) {
      newWidth = Math.min(currWidth + STEP, goalWidth);
      isAllDone = false;
    } else {
      newWidth = goalWidth;
    }
    items[i].style.width = newWidth + "px";
  }

  if (!isAllDone) {
    aniID = window.requestAnimationFrame(() => {
      updateWidth(nextWidths);
    });
  }
};
    

이 함수는 nextWidths 라는 배열 값을 전달해주면 그 값에 맞게 아이콘 크기가 바뀌게 됩니다. 예를 들어 [60, 75, 100, 75, 60] 이라면, 중앙이 더 튀어나오게 나타날 겁니다.

image

그런데 바로 바뀌지 않고 천천히 애니메이션이 되면서 바뀌게 하기 위해 window.requestAnimationFrame 을 사용합니다. setInterval과 달리 과하지 않게 1초에 60번 정도 반복해서 사람이 보기에 애니메이션 처럼 보일 정도로 함수를 반복 실행하게 해줍니다.

javascript
      const updateWidth = function (nextWidths) {
	// ...
  // nextWidths의 크기만큼 아이콘들이 모두 다 변하지 않았다면
	// 다시 함수 실행
  if (!isAllDone) {
    aniID = window.requestAnimationFrame(() => {
      updateWidth(nextWidths);
    });
  }
};
    

updateWidth의 중간 내용은 각 아이콘의 현재 가로 크기를 목표하는 값(nextWidths)에 맞게 조금씩 변하도록 합니다.

현재 값이 목표 값보다 작다면STEP만큼 빼기
현재 값이 목표 값보다 크다면STEP만큼 더하기
현재 값이 목표 값과 같다면 → 놔두기

빼거나 더할 때 MIN_WIDTH 와 MAX_WIDTH 사이에 오도록 제한도 해줍니다.

그런데 상단에 애니메이션을 취소하는 부분이 있습니다.

javascript
      const updateWidth = function (nextWidths) {
  window.cancelAnimationFrame(aniID);
  aniID = null;
	// ...
}
    

왜냐하면 해당 크기로 바뀌는 도중에 사용자가 마우스를 옮겨서 목표값이 달라져야할 수 있기 때문입니다. 그럼 사용자가 마우스를 옮기면서 어떻게 목표값이 바뀌는지 볼까요?

우선 마우스가 Dock에서 어느 위치에 있는지 구합니다.

javascript
      dock.addEventListener("mousemove", function (e) {
  const dockTop = e.target.getBoundingClientRect().top;
	// Dock 상단 위치 기준으로 부터 마우스 위치
  const y = e.clientY - dockTop;
});
    
image

그리고 각 아이콘마다 중앙 위치도 구합니다.

javascript
      const items = document.querySelectorAll(".item");
  for (const item of items) {
    const rect = item.getBoundingClientRect();
    const center = rect.top - dockTop + rect.height / 2;
	}
}
    
image

둘의 거리 차이에 따라 값을 정합니다.

둘의 거리 차이가 작다 → 마우스가 아이콘에 가깝다 → MAX_WIDTH에 가깝게
둘의 거리 차이가 크다 → 마우스가 아이콘에서 멀다 → MIN_WIDTH에 가깝게
javascript
      const dist = Math.abs(center - y);
nextWidths.push(Math.max(MAX_WIDTH - dist / 4, MIN_WIDTH));
    

dist / 4 는 제가 임의로 정했습니다. 그래서 그 거리만큼 빼서 크기가 정해집니다. 그리고 정해진 이 값으로 위에서 만들었던 updateWidth를 실행합니다.

javascript
      const nextWidths = [];
const items = document.querySelectorAll(".item");
for (const item of items) {
  const rect = item.getBoundingClientRect();
  const center = rect.top - dockTop + rect.height / 2;

  const dist = Math.abs(center - y);
  nextWidths.push(Math.max(MAX_WIDTH - dist / 4, MIN_WIDTH));
}
updateWidth(nextWidths);
    

만약 마우스가 벗어나는 경우 원래 크기로 천천히 돌아갑니다.

javascript
      dock.addEventListener("mouseleave", function (e) {
  const items = document.querySelectorAll(".item");
  const nextWidths = [];
  for (const item of items) {
    nextWidths.push(MIN_WIDTH);
  }
  updateWidth(nextWidths);
});
    

마무리하며

여기에 CSS와 약간 값을 조정해서 원하는 크기와 속도를 만들 수 있습니다. 저처럼 왼쪽이 아닌 하단이나 상단도 해보세요!