프론트엔드 부트캠프의 3주 차에 주어진 첫 과제는 클론 코딩이었습니다. 자기가 고른 사이트를 HTML, CSS, Javascript를 이용해서 클론 코딩하는 건데요.
제가 선택한 사이트는 Gitlab이었습니다.
이 사이트에서 대부분은 순수 HTML과 CSS로 구현을 할 수 있었지만 제가 맞닥뜨린 가장 어려운 부분 중 하나는 히어로 섹션에 있던 거대한 슬라이더였습니다.
about.gitlab.com의 히어로 슬라이더
이 슬라이더는 강의 시간에 배웠던 Swiper.js와는 작동 방식이 조금 달랐습니다.
이 정도가 제가 알게 된 Swiper.js와 Gitlab 사이트에 있는 슬라이더의 차이점이었습니다.
저는 외부 라이브러리에 대한 지식이 별로 없었기 때문인데 다른 슬라이더 관련 라이브러리는 알지 못했는데요. 그래서 저에게 주어진 선택지는 Swiper.js를 그대로 쓰느냐, 또는 직접 자바스크립트를 이용해 구현을 하느냐, 두 가지였습니다.
처음에는 슬라이더를 직접 구현하는 데에 얼마나 걸릴지 예상이 안됐었기 때문에 Swiper.js의 코드를 뜯어봐서 제가 원하는 부분을 만들기로 했습니다.
Swiper.js의 Free Mode
우선 Free Mode로 자유로운 슬라이드 드래그를 적용해 줬습니다.
그다음은 슬라이더 전체에 비례해서 스크롤 진행도가 표시되는 내비게이션 바를 구현하기로 했습니다.
function Scrollbar(_ref) {
let {
swiper,
extendParams,
on,
emit
} = _ref;
const document = getDocument();
let isTouched = false;
let timeout = null;
let dragTimeout = null;
let dragStartPos;
let dragSize;
let trackSize;
let divider;
Swiper의 Scrollbar
모듈입니다.
if (swiper.isHorizontal()) {
dragEl.style.transform = `scaleX(${newPos*0.00432})`;
dragEl.style.width = `335px`;
}
if (swiper.isHorizontal()) {
dragEl.style.width = `335px`;
}
이 중에서 스크롤바의 width
를 할당하는 로직에 값을 하드 코딩해 주고 transform
을 scaleX
로 변경해 주었습니다.
이후로 슬라이더의 50%를 이동하는 버튼을 구현하려고 했지만, Swiper의 Navigation
모듈은 swiper
객체에서 인수를 받아오기 때문에 난이도가 있었습니다. 충분히 시간이 있었다면 찾아볼 수도 있었겠지만, 과제 기한까지 며칠 남지 않은 상황에서 가장 상호작용이 많은 Swiper
클래스를 뜯어보는 건 무리라고 생각했어요.
따라서 저는 직접 자바스크립트로 간단한 슬라이더를 구현하기로 했습니다.
이벤트 관련 지식이 적었기 때문에 유튜브 강의를 참고했습니다.
Horizontal Scroll Slider
Create Touch Slider From Scrollbar
let target = 0;
let current = 0;
let ease = 0.0825;
const slider = document.querySelector('.swiper');
const sliderWrapper = document.querySelector('.swiper-wrapper');
let maxScroll = sliderWrapper.offsetWidth - window.innerWidth + 96 * 2;
const lerp = (start, end, factor) => {
return start + (end - start) * factor;
}
const update = () => {
current = lerp(current, target, ease);
let moveRatio = current / maxScroll;
gsap.set('.swiper-scrollbar', {
scaleX: moveRatio,
});
requestAnimationFrame(update);
};
slider.addEventListener('wheel', e => {
if (Math.abs(e.deltaX) > 10) target += e.deltaX;
target = Math.max(0, target);
target = Math.min(target, maxScroll);
});
update();
우선 요소 자체의 overflow를 사용한 슬라이더를 만들 생각이었기 때문에 첫 번째 영상의 코드에서 휠 스크롤 비율을 계산하는 부분만 남겼습니다.
gsap.set('.swiper-scrollbar', {
scaleX: moveRatio,
});
그 후엔 마찬가지로 GSAP에서 transform
을 적용하는 부분을 scaleX
로 수정해 주었습니다.
let ease = 0.0825;
또한 보간 함수의 ease 값을 살짝 조정하였습니다.
let pressed = false;
let mouseX = 0;
const prevBtn = document.querySelector('.swiper-button-prev');
const nextBtn = document.querySelector('.swiper-button-next');
slider.addEventListener('mousedown', e => {
pressed = true;
mouseX = e.clientX;
slider.style.cursor = 'grabbing';
});
slider.addEventListener('mouseleave', () => {
pressed = false;
});
slider.addEventListener('mouseup', () => {
pressed = false;
slider.style.cursor = 'grab';
});
slider.addEventListener('mouseenter', () => {
slider.style.cursor = 'grab';
});
slider.addEventListener('mousemove', e => {
if (pressed) {
slider.scrollLeft += mouseX - e.clientX;
target += mouseX - e.clientX;
target = Math.max(0, target);
target = Math.min(maxScroll, target);
mouseX = e.clientX;
}
});
이 부분은 두 번째 영상의 코드를 활용해서 구현하였습니다. 마우스가 움직인만큼의 값을target
에도 더해주어서 위에서 GSAP을 통해 scaleX
를 걸어주는 코드에 적용될 수 있도록 했습니다.
prevBtn.addEventListener('click', () => {
slider.scrollLeft -= maxScroll/2;
target -= maxScroll/2;
target = Math.max(0, target);
});
nextBtn.addEventListener('click', () => {
slider.scrollLeft += maxScroll/2;
target += maxScroll/2;
target = Math.min(target, maxScroll);
});
내비게이션 버튼은 단순히 전체 슬라이더의 50%만큼 스크롤과 target
을 변화시키는 식으로 작성하였습니다.
또한 target
의 값이 0
이라면 원본 사이트처럼 Prev 버튼을 비활성화 시켜주었습니다.
if (target) prevBtn.classList.remove('disabled');
else prevBtn.classList.add('disabled');
내비게이션 버튼 이동 시에 화면이 갑자기 바뀌는 걸 방지하기 위해 슬라이더에 scroll-behavior: smooth
옵션을 주었는데 드래그 시 예상과 다르게 동작하는 문제가 생겼습니다.
아마 정량적으로 scrollLeft
를 변화시키는 로직이 mousemove
이벤트를 통해 밀리세컨드 단위로 실행되면서 CSS의 scroll-behavior: smooth
와 충돌해서 문제가 생기는 것 같았는데요.
그래서 처음엔 smooth
를 포기하고 setInterval
을 사용한 스크롤 이동을 시도해 보았습니다.
const scrollInterval = (step, direction, speed, threshold) => {
let cnt = 0;
const giveInterval = setInterval(() => {
slideAction(step, direction);
cnt += step;
if (cnt > threshold) window.clearInterval(giveInterval);
}, speed);
};
하지만 스크롤 움직임을 ease하게 구현하기 어려운 점, 메모리 상황에 따라서 setInterval
의 동작 퍼포먼스가 달라지는 점, 그리고 CSS에 이미 있는 걸 JS로 구현할 필요 없다는 우선순위의 문제까지 겹쳐 다시 smooth
를 사용한 스크롤을 사용하기로 했습니다.
대신, 내비게이션 버튼을 눌렀을 때만 한시적으로 적용해 사이드 이펙트를 방지했습니다.
prevBtn.addEventListener('click', () => {
slider.style.scrollBehavior = 'smooth';
slider.scrollLeft -= maxScroll/2;
target -= maxScroll/2;
target = Math.max(0, target);
slider.style.scrollBehavior = 'auto';
});
nextBtn.addEventListener('click', () => {
slider.style.scrollBehavior = 'smooth';
slider.scrollLeft += maxScroll/2;
target += maxScroll/2;
target = Math.min(target, maxScroll);
slider.style.scrollBehavior = 'auto';
});
기존 라이브러리가 아닌 직접 함수를 작성했기 때문에 드래그 스크롤 시에 발생하는 사이드 이펙트들이 생겼습니다. 크게 두 가지로,
a
태그 위에서 드래그를 시작했을 때 마우스를 떼면 클릭으로 인식해 링크로 이동하는 현상이 있었는데요.
img 요소 드래그 (acmicpc.net)
문제를 해결하기 위해 우선 슬라이더 안의 요소들의 draggable
속성에 일괄적으로 false
를 부여했습니다.
const draggables = sliderWrapper.querySelectorAll('img, svg, p, a, span, h1, h2, h3, h4, h5, h6, .text');
draggables.forEach((draggable) => {
draggable.setAttribute('draggable', 'false');
});
또한 a
태그 문제는 pointer-events
속성을 활용했습니다. 마우스를 움직일 때만 none
으로 처리해 주고, 마우스를 뗄 때는 다시 원래대로 동작하도록 처리했습니다.
const heroCards = sliderWrapper.querySelectorAll('a.hero-card');
slider.addEventListener('mouseup', () => {
heroCards.forEach((card) => {
card.style.pointerEvents = 'auto';
});
});
slider.addEventListener('mousemove', e => {
if (pressed) {
heroCards.forEach((card) => {
card.style.pointerEvents = 'none';
});
}
});
매우 간단한 기능이지만 외부 라이브러리의 도움을 받지 않고 직접 구현해 본 경험이 많은 도움이 된 것 같습니다. 그리고 다른 사람의 코드를 내가 구현하고자 하는 기능에 맞게 변형하는 과정에서 핵심이 된 웹 이벤트들에 대한 이해가 더 늘어난 것 같았습니다.
외부 라이브러리를 사용하는 것이 나을까, 내가 직접 구현하는 것이 나을까?
아마도 제가 앞으로 개발을 하면서 상황마다 해 볼 고민이라고 생각하는데요. 저 같은 신입의 입장에서는 빠르게 다른 라이브러리를 배우고 도입할 수 있는 능력, 기능을 구현하기 위한 원리를 파악하고 때로는 직접 구현할 수 있는 능력. 양쪽 모두가 중요하다는 것을 느낄 수 있었습니다.