[Virtual Scroll 만들기] 해당글의 virtual scroll을 구현하고나서 스크롤바도 직접 구현하면 재미있겠다!(?) 라는 생각으로 시작했다.
스크롤 영역에 마우스 호버 시 영역위에 스크롤이 보여지는 형태로 구현하자!
구현은 서론에서 언급한 글의 virtual scroll을 확장하여 구현하였다.
<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>
/* ... */
.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)
...
}
// 스크롤 막대 높이 계산
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를 사용하였다.
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 })
컨텐츠를 넘기기 위한 다른 방법으로는 스크롤 막대를 직접 움직여서 이동하는 방법이 존재한다.
이에 대한 구현사항은 다음과 같다.
/** @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 = '';
}
});
마지막 스크롤 이동방법으로 막대가 아닌 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);
});