Scrollbar 만들기 (with Virtual scroll)

Hyeok·2023년 11월 30일
1

가상스크롤

목록 보기
2/2

서론

[Virtual Scroll 만들기] 해당글의 virtual scroll을 구현하고나서 스크롤바도 직접 구현하면 재미있겠다!(?) 라는 생각으로 시작했다.

💡 Concept

스크롤 영역에 마우스 호버 시 영역위에 스크롤이 보여지는 형태로 구현하자!

🔧 구현

구현은 서론에서 언급한 글의 virtual scroll을 확장하여 구현하였다.

이전 코드 - Virtual Scroll

템플릿 확장

HTML
<div class="wrapper">
    <div class="container">
    <div class="scroll-content">
        <div class="content">
        <table role="presentation">
            <thead role="presentation"></thead>
            <tbody role="presentation"></tbody>
            <tfoot role="presentation"></tfoot>
        </table>
        </div>
    </div>
    <!-- New Template -->
    <div class="sb">
        <div class="sb-thumb">
        <div class="sb-thumb-content"></div>
        </div>
    </div>
    </div>
</div>
CSS
/* ... */

.sb {
  position: absolute;
  pointer-events: auto;
  top: 0;
  right: 0;
  height: 100%;
  transition: background-color 0.5s linear 1s;
}

.sb:hover,
.sb:has(.sb-thumb-active) {
  background-color: rgba(207, 218, 233, 0.33);
  transition: background-color 0.15s linear 0.15s;
}

.sb .sb-thumb {
  width: 8px;
  height: 0;
  padding: 0 2px;
  transition:
    width 0.2s linear 0.15s,
    transform 0.1s ease-in-out;
}

.sb:hover .sb-thumb,
.sb .sb-thumb-active {
  width: 15px;
}

.sb-thumb-content {
  position: relative;
  width: 100%;
  height: 100%;
  transition: background-color 0.5s linear 1s;
}

.sb-thumb-content::after {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: transparent;
  transition: background-color 0.15s linear 0.15s;
}

.wrapper:hover .sb-thumb-content {
  transition: background-color 0.15s linear 0.15s;
  background-color: #b4b4b4;
}

.sb-thumb:is(:active, .sb-thumb-active) .sb-thumb-content::after {
  background-color: rgba(56, 149, 225, 0.8);
}

스크롤 막대의 높이 계산

너비와 높이, 데이터의 양이 고정된 템플릿에서 스크롤 막대의 높이를 계산한다.

const minScrollThumbHeight = 20 // 막대의 최소크기

const scrollThumbRatio = viewportHeight / scrollHeight; // 전체 컨텐츠와 보여지는 영역의 비율
const thumbHeight = viewportHeight * scrollThumbRatio
const scrollThumbHeight = Math.max(thumbHeight, minScrollThumbHeight)

스크롤 막대 이동거리 계산

위에서 계산한 높이를 토대로 이동거리를 계산한다.

/**
 * @param {number} scrollTop
 */
function calculateTranslate(scrollTop) {
  const maxTranslateY = viewportHeight - scrollThumbHeight // 막대의 최대 이동거리
  const translateRatio = scrollTop / scrollHeight
  const translateY = Math.min(translateRatio * viewportHeight, maxTranslateY)
  return translateY
}

❗ 문제점

하지만 위와 같이 계산할 경우 thumbHeight < scrollThumbHeight 경우에 offset의 끝에서 scrollThumbHeight - thumbHeight의 차이의 translate 영역에 해당하는 데이터는 maxTranslateY의 크기제한으로 인해 볼 수 없게되는 문제가 발생한다.

🔨 개선

실제 개산한 막대크기와 최소 막대크기의 차이만큼을 viewportHeight에서 빼준 값을 스크롤바 영역의 길이로 계산하여 translate값을 구하면 위의 문제점을 해결할 수 있다.

const thumbDiff = scrollThumbHeight - thumbHeight의
const scrollbarHeight = viewportHeight - thumbDiff

/**
 * @param {number} scrollTop
 */
function calculateTranslate(scrollTop) {
  ...
  const translateY = Math.min(translateRatio * scrollbarHeight, maxTranslateY)
  ...
}

✔ 결과 - offsets 계산 함수와 병합

// 스크롤 막대 높이 계산
const scrollThumbRatio = viewportHeight / scrollHeight;
const thumbHeight = viewportHeight * scrollThumbRatio;
const scrollThumbHeight = Math.max(thumbHeight, minScrollThumbHeight);
const scrollThumbDiff = scrollThumbHeight - thumbHeight;
const scrollbarHeight = viewportHeight - scrollThumbDiff;

/**
 * @param {number} scrollTop
 */
function calculateVariables(scrollTop) {
  const passNodeCount = Math.floor(scrollTop / rowHeight); // 지나온 노드의 개수
  const maxStartIndex = dataSize - visibleNodeCount; // startIndex의 최대값

  // 인덱스 계산
  let startIndex = Math.min(Math.max(passNodeCount - nodePadding, 0), maxStartIndex);
  const endIndex = startIndex + visibleNodeCount;
  const offsets = [startIndex, endIndex];

  // 스크롤 막대 이동거리 계산
  const maxTranslateY = viewportHeight - scrollThumbHeight;
  const translateRatio = scrollTop / scrollHeight;
  const translateY = Math.min(translateRatio * scrollbarHeight, maxTranslateY);

  return { offsets, translateY };
}

함수가 병합되었으므로 해당 함수를 사용하던 onScrollChange 함수를 업데이트 해준다.

function onScrollChange(newScrollTop) {
  const { offsets, translateY } = calculateVariables(newScrollTop);
  render(offsets);
  $container.scrollTop = newScrollTop;
  $thumb.style.transform = `translateY(${translateY}px)`; // 막대 이동
}

스크롤 이벤트 등록

scrollTop에 따른 가변값을 계산 함수를 구현하였으니, 이제는 이벤트를 등록하여 실제로 움직일 수 있도록 할 차례이다.

하지만 여기서 우리는 의문을 가질 수 있다.

스크롤이 비활성화 되어있는 상태에서 어떻게 스크롤 이벤트를 발생시키지?

이제 대한 방안으로 나는 Wheel event를 사용하였다.

Wheel Scroll

MDN Web Docs를 보면 wheel event에는 delta라는 속성이 존재하는것을 볼 수 있다.
delta 값은 우리가 스크롤을 한번 이동하면 Chrome을 기준으로 100의 값이 발생한다.
(아래로 스크롤 시 100, 위로 스크롤 시 -100)

이 delta 값을 scrollTop에 더해주는 것으로 스크롤 이동을 구현하였다.

let scrollTop = 0

$container.addEventListener('wheel', (event) => {
  if (event.shiftKey === false) {
    const step = event.deltaY
    const moveScrollTop = scrollTop + step
    const newScrollTop = Math.max(Math.min(moveScrollTop, maxScrollTop), 0)
    // 스크롤이 가능할 경우 기본 스크롤 이벤트를 막아준다.
    // 외부 스크롤영역이 존재할 경우 같이 스크롤되는 문제를 방지하기 위함
    if (moveScrollTop >= 0 && moveScrollTop <= maxScrollTop) event.preventDefault()
    scrollTop = newScrollTop
    onScrollChange(newScrollTop)
  }
}, { passive: false })

Drag Scroll

컨텐츠를 넘기기 위한 다른 방법으로는 스크롤 막대를 직접 움직여서 이동하는 방법이 존재한다.

이에 대한 구현사항은 다음과 같다.

/** @type {HTMLElement} */
const $html = document.querySelector('html');

/** @type {null | number} */
let dragStartY = null; // 드래그 시작 y 좌표
let prevUserSelect = ''; // document body's previous userSelect css property value


$scrollbar.addEventListener('mousedown', (event) => {
  if ($thumb.contains(event.target)) {
    // 스크롤바에서 스크롤막대에 마우스를 누른경우
    // 마우스를 누르기 시작한 시점의 Y좌표를 기억한다
    dragStartY = event.pageY;
    $thumb.style.transition = 'none';
    prevUserSelect = document.body.style.userSelect;
  }
});

document.body.addEventListener('mousemove', (event) => {
  // 문서에서 마우스를 움직일경우
  if (typeof dragStartY === 'number') {
    // 시작Y좌표가 설정되어 있다면 스크롤 이동을 시작한다.
    event.preventDefault();
    document.body.style.userSelect = 'none'; // 스크롤 이동시 텍스트가 선택되는것을 방지
    const currentY = event.pageY;
    const translateDelta = currentY - dragStartY; // 막대가 이동할 거리
    onTranslate(translateDelta); // 막대 이동
    dragStartY = currentY; // 드래그 시작위치를 이동한 위치로 변경
  }
});

/**
 * @param {number} translateDelta
 */
function onTranslate(translateDelta) {
  const { translateY } = calculateVariables(scrollTop);
  // 현재 막대위치에서 이동할 거리를 더하여 새롭게 이동할 scrollTop을 역산한다.
  const newScrollTop = Math.max(((translateY + translateDelta) * scrollHeight) / scrollbarHeight, 0);
  onScrollChange(newScrollTop);
  scrollTop = newScrollTop;
}

$html.addEventListener('mouseup', () => {
  // 문서 전체에 대해서
  if (typeof dragStartY === 'number') {
    // 시작Y좌표가 설정 되어있다면 막대이동을 종료시킨다.
    document.body.style.userSelect = prevUserSelect; // 기본 userSelect 값으로 초기화
    dragStartY = null;
    $thumb.style.transition = '';
  }
});

Click Scroll

마지막 스크롤 이동방법으로 막대가 아닌 scrollbar 영역을 클릭하여 막대를 클릭한 위치까지 이동시키는 방법이 있다.

let isTracking = false; // scrolling flag
let trackId = -1; // interval id

// 마우스가 스크롤바영역 밖으로 나가거나 마우스 클릭을 종료하면
// 해당위치까지의 추적을 종료시킨다
$scrollbar.addEventListener('mouseleave', () => (isTracking = false));
$scrollbar.addEventListener('mouseup', () => (isTracking = false));
$scrollbar.addEventListener('mousedown', (event) => {
  // 스크롤에 마우스를 누른경우
  event.stopPropagation();
  clearInterval(trackId); // interval 초기화
  // 마우스를 누른 위치가 막대영역과 겹쳐있다면 이동을 종료
  if ($thumb.contains(event.target)) return (isTracking = false);
  isTracking = true;

  const offset = event.offsetY;
  const { translateY } = calculateVariables(scrollTop);
  
  const minOffset = Math.max(offset - scrollThumbHeight, 0); // 막대가 아래로 이동할때 멈출 최소 offset
  // 현재막대를 기준으로 위쪽을 클릭하였다면 -1 아래를 클릭하였다면 1
  const multiplier = offset < (translateY + translateY + scrollThumbHeight) / 2 ? -1 : 1;
  const delta = 100 * multiplier; // 델타값 생성
  trackId = setInterval(() => {
    if (!isTracking) clearInterval(trackId);
    // 새로운 스크롤 위치를 계산하여 이동
    scrollTop = Math.max(Math.min(scrollTop + delta, maxScrollTop), 0);
    onScrollChange(scrollTop);
    const changeY = calculateVariables(scrollTop).translateY;
    // 막대가 마우스를 누른영역과 겹칠경우 이동을 종료시킨다.
    if ((multiplier > 0 && changeY > minOffset) || (multiplier < 0 && changeY < offset)) isTracking = false;
  }, 33);
});

✨ 결과

profile
FE 탐구생활 🙂

0개의 댓글

관련 채용 정보