각 쇼핑몰 아이템 목록에서 추천되는 카테고리 항목별로 8개씩 보여주는 아이템을 구현하고자 했다. 그냥 리스트별로 보여주기에는 심심한 느낌이 들어 각 쇼핑몰 템플릿을 참고해 찾아본 후, 가로로 스크롤되면서 drag로 움직일 수 있는 기능적 요소를 더해 구현하기로 했다.
일단 전체적인 맥락에 따라 상위 기능으로 분류해 보면, scroll 컨테이너를 클릭했을 때, 클릭된 상태로 일정 범위로 drag 했을 때, drag를 끝내고 마우스 클릭을 해제할 때 정도로 나누어서 생각해 볼 수 있겠다.
+a) 추후 알아본 바로는 내가 현재 사용하고 있는 swiper 라이브러리에 scrollBar 에 대한 요소를 연결해서 현재의 스크롤 위치에 따라 자연스럽게 움직이는 커스텀 스크롤바도 구현할 수 있었으나 개인 공부 겸 직접 구현해보기로 했던 터라 따로 적용해보지는 않았다.
처음 마우스를 클릭 했을 때 실행되는 함수로 scrollContainer의 onMouseDown 에 할당해 줄 함수다.
startX 는 현재 클릭 된 지점의 x 값이 저장된 state로 이후 drag 과정에서 마우스 클릭지점으로 부터 얼마만큼 drag로 이동했는지를 연산하기 위해 저장해주었다.
isDragging state는 현재 drag 상태를 담는 state로, 추후 작성할 drag 함수가 컨테이너의 요소를 클릭하여 drag가 시작되었을 경우에만 함수가 트리거 되도록 하기 위해 정의해 주었다. (뒤에 추가 설명)
mouseScrollRef 객체는 현재의 ScrollContainer 를 참조하며, 해당 컨테이너의 scrollLeft 값을 구한다. scrollLeft는 해당 컨테이너의 현재 스크롤 위치를 x좌표 값으로 가진다.
이 값을 얻기 위해 함수 작성 전, Container 보다 내부의 Contents 의 width가 더 길도록 하여 스크롤 기능이 되도록 만들어 둔 상태다.
e.clientX 는 viewprot 기준 해당 이벤트가 발생한 (여기서는 클릭 한 좌표지점) 부분의 x 좌표값을 가지고 있다.
이후 이 e.clientX 값(클릭한 포인터의 x위치) + scrolledX(현 컨테이너의 스크롤 x 위치) 를 더해 drag 가로 스크롤의 시작점 startX로 업데이트한다.
e.clientX + scrolledX 계산 방식
e.clientX 는 현재 viewPort 기준 클릭 된 지점의 x 값이다.
만약 오른쪽에서 왼쪽으로 요소를 끌어서 drag 하게 된다면 scrollLeft 값은 늘어나게 된다.
이 때 쉽게 생각해보면 컨테이너 안에서 내가 아무리 오른쪽으로 길게 drag 하더라도 그 범위는 처음 클릭된 e.clientX 를 넘어서지는 못한다.
즉 오른쪽 drag 기준, e.clientX - scrollLeft 가 내가 drag 할 수 있는 최대 범위값이 되는 것이다.
또 e.clientX 는 viewProt 기준 x 값이기 때문에, 스크롤이 제법 진행된 상태(scrollLeft 값이 변경된 상태)에서도 e.clientX 값은 같다. 그렇기 때문에 현재의 scrolledLeft 값에 e.clientX 를 더해줘야 실제로 현재 클릭 된 지점의 스크롤 x 값을 구할 수 있다.
그래서 만약 해당 지점에서 클릭을 한 상태로 클릭을 유지하면서 오른쪽에서 왼쪽으로 쭈욱 drag를 진행한다면...
다음으로 onDrag 는 클릭 후 좌,우측으로 포인터를 이동할 때 트리거 될 함수다. 해당 함수는 onMouseMove에 넘겨주는데 이 때문에 앞서 설명했던 isDragging state를 사용한다.
기본적으로 onDrag 는 onMouseMove 의 특성상 마우스 포인터를 올려두기만 해도 실행될 것이다. 그러나 내가 원하는 것은 클릭 후 각 포지션값을 state에 저장하고, 이후 마우스를 움직일 때만 해당 drag함수가 실행되길 원한다. 이를 위해서 앞서 만들었던 onMouseClick 함수가 실행될 때 (클릭했을 때) 만 해당 onDrag 함수가 실행되도록 하여 불필요한 연산을 생략하였다.
컨테이너의 scrollLeft 값은 앞서 저장해둔 startX (스크롤 범위의 시작 x 값) 에서 해당 드래그 행동으로 실시간으로 받아오는 e.clientX 를 빼준다.
onDrag 함수는 마우스가 움직이는 만큼 반복적으로 무수히 실행된다.
onDrag 의 scrollLeft 계산 방식
처음 onDrag 가 실행될 때의 e.clientX 값은 startX 와 같다. (클릭 만 하고 아직 포인터를 움직이지 않은 상태)
만약 왼쪽 드래그를 시작하면 마우스 포인터는 클릭 지점에서 점점 왼쪽으로 이동할 것이므로 e.clientX 도 그만큼 줄어들고, 그 만큼 scrollLeft의 이동 변경값은 + 늘어난다.
만약 오른쪽으로 드래그를 시작하면 마우스 포인터는 클릭 지점에서 점점 오른쪽으로 이동할 것이므로 e.clientX 도 그 만큼 늘어나고, 그 만큼 scrollLeft 의 이동 변경값은 - 감소한다.
마우스 클릭이 해제되면 onMouseUp 이 실행되는데 이는 곧 사용자가 드래그를 끝냈음을 의미한다. 이 때 isDragging 상태를 다시 false 로 변경해야 더 이상 마우스의 스크롤 영역에 따라 스크롤 상태가 움직이지 않는다.
문제는 사용자에 따라 목록의 아이템을 클릭해서 자세히 보려거나, 그냥 드래그 하거나 두 가지 동작 중 하나를 할텐데 이 동작을 구별할 필요가 있었다.
기본적으로는 클릭, 드래그 동작 둘다 이벤트가 발생하기에 아이템 요소를 클릭한 것으로 간주하게 된다. 따라서 위 처럼 이벤트 트리거 동작과 이벤트 전파를 막기위해 preventEffects 함수로 감싸서 선언해주었다. 이후 현재 컨테이너의 아이템목록을 가져왔다.
아이템의 각 요소를 forEach문으로 돌면서 마우스 클릭 시 저장된 totalScroll 값과 현재 scrollLeft 인 currentX의 차이값을 비교하여 그 차이값이 15이상이면 이벤트리스너를 등록하여 preventEffects 함수가 트리거되도록 하고, 15미만일 경우 등록된 이벤트리스너를 해제하도록 구현했다.
이로 인해 드래그가 끝나서 onDragEnd 가 실행될 때 클릭 시의 위치값과 드래그된 이후의 위치값을 비교하여 그 격차가 15 이상이면 드래그하는 동작으로 간주하고 이벤트 동작을 막아서 불필요하게 아이템 요소를 클릭하게 되는 것을 방지할 수 있었다.
drag 이슈 - 마우스가 영역을 벗어났다가 다시 돌아오면 드래그 동작이 재시작되는 문제
기능 추가 - 드래그 끝난 시점에서 가장 가까운 아이템을 시작점으로 자동 스크롤
기본적인 스크롤 및 가로 드래그 기능은 완성했으나 뭔가 맵시가 없어보였다. 마우스로 드래그 동작을 하면 위처럼 움직이는 과정이 딱딱하고 단순해보여서 조금 부드러운 동작을 추가해보고자 했다.
드래그가 끝났을 때 알맞게 자동으로 현재 요소에 알맞게 스크롤로 이동되는 느낌이 있으면 좋을 거 같았고, 이를 구현하기 위해 컨테이너의 왼쪽 위치값과 가까운 아이템의 왼쪽 위치값을 활용하기로 했다.
currentClose 는 제일 최근의 가장가까운 것으로 지정된 아이템(closestItem)의 범위값과 현재 scrollLeft 의 차이값이다. 처음 초기값은 위의 코드를 보면 알 수 있듯 아이템목록 중 첫번째 아이템 노드로 설정해두었다.
let 으로 선언해 준 이유는 각 item 들을 forEach로 돌아보는 동안 변경될 수 있는 값이기 때문이다.
위처럼 forEach 로 도는 매 순번마다 각 item들의 scrollLeft 와의 차이 (distance) 와 제일 최근에 scrollLeft와 가장 가까운 것으로 간주된 값 (currentClose)를 비교한다.
이 과정으로 dragEnd 시에 사용자가 드래그로 요소를 제법 움직였을 경우, scrollLeft 와의 격차는 당연히 스크롤 이동이후의 가장 가까운 아이템이 초기값보다 더 작을 것이고, 이는 곧 더 가깝게 위치해있음을 의마하기에 closestItem 값을 계산된 가장가까운 아이템 노드로 변경해준다.
기능추가 - requestAnimationFrame 활용 애니메이션 적용
자동으로 스크롤 포지션이 가까운 아이템에 위치되도록 하였으나 그 동작이 매끄럽지 못한 문제를 발견했다.
아마도 scrollLeft 값을 재연산하여 할당하는 과정에서 별도의 작업없이 바로 수정값을 넣어주기 때문에 바로바로 위치값이 변경되어 생기는 이슈인듯 했다.
이 부분을 해결하기 위해 처음엔 scrollIntoView 를 사용하여 smooth 효과를 주는 식으로 부드럽게 전환하려 했는데 여전히 뭔가 뚝뚝 끊기는 현상은 사라지지 않았다.
따라서 이 부분은 UI 화면의 렌더링 자체에 무언가 작업이 이루어져야겠다는 판단을 하여 이전에 학습한 Raf(requestAnimationFrame) 을 이용하기로 했다.
raf 포스팅 자료
// 스크롤 섹션 애니메이션
const smoothScrollTo = (targetX: number, duration: number) => {
const startX = scrollRef.current.scrollLeft;
const startTime = performance.now();
const animateScroll = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
const easing = 0.5 - Math.cos(progress * Math.PI) / 2; // Ease-in-out
scrollRef.current.scrollLeft = startX + (targetX - startX) * easing;
const rafId = requestAnimationFrame(animateScroll);
if (progress >= 1) {
cancelAnimationFrame(rafId);
smoothScrollbar(getBarProgress(), 100);
}
};
requestAnimationFrame(animateScroll);
};
우선 크게 보면 시작점, 목표점 이렇게 두 개가 필요하고 이 두 값은 처음 할당된 그 값을 초기값으로 계속 가지고 있으면서 주사율에 맞춰 raf를 실행해 줄 필요가 있었다.
이를 위해 클로저를 활용하기로 했다. raf 의 인자로 들어갈 콜백함수 animateScroll 외부에 smoothScrollTo 라는 함수를 만들어 감싸고, 해당 함수 내부에 시작점 (startX), 그리고 이 함수에서 인자로 목표점(targetX)를 받아오도록 했다.
해당 smoothScrollTo 라는 외부함수는 onDragEnd 에서 이렇게 호출되는데 이 때 가장 가까운 아이템의 left값, 즉 targetX 목표값과, 애니메이션 동작 duration 값을 인자로 전달하여 호출했다.
함수 호출 이후의 코드 순서를 나열해보자면 이렇다.
1. 드래그가 끝난 시점의 scrollLeft 값이 startX 값이 되고, 외부함수를 호출한 시간대가 startTIme 값으로 담긴다.
2. 제일 아랫줄의 raf가 실행되면서 내부에 인자로 전달한 콜백함수 animateScroll 이 실행된다.
3. 현재 프레임 시간대에서 startTime을 뺀 만큼의 시간차 elapsedTime을 구한다.
4. 앞서 인자로 전달한 duration으로 이 값을 나누어 진행상태 정도에 대한 수치를 구한다. (progress)
여기서 Math.min을 사용한 이유는 만약 주기적으로 반복되다가 진행정도가 1을 넘게 되면 종료되어야 하기 때문에 두 값중 최소값을 선택하도록 한 것
5. progress 값을 이용해서 얼마만큼 화면에 변화를 줄 것인지를 연산한 결과를 easing 변수에 담는다.
6. startX 에 시작점과 목표점의 차이값에 easing을 곱한 값 만큼의 정도를 더한 값을 scrollLeft의 최신값으로 변경한다.
7. 함수 내부에서 재귀로 다시 raf를 실행하고 똑같이 animateScroll 을 콜백인자로 전달한다.
8. 위 과정을 다시 반복한다.
이 과정이 반복될 수록 처음 시작시간과 현재 프레임의 시간차가 커지는 만큼 progress 값도 커지게 되고, 그에 따라 easing의 값도 커지게 된다.
즉 매 주기마다 커지는 easing 값이 곱해지면서 점차적으로 scrollLeft 값에 변경된 만큼의 값이 더해지게 되어 scrollLeft 값이 targetX 값에 가까워지게 된다.
9. 반복되는 과정에서 progress 값이 1이 되면 canelAnimationFrame 이 실행되어 raf가 종료된다.
raf 활용 - 스크롤 바에 애니메이션 적용
// 상태 바 애니메이션
const progressState = scrollBarRef?.current?.children[0];
const progressStateRect = progressState?.getBoundingClientRect();
let currentPosition = progressStateRect?.left;
const smoothScrollbar = (targetX: number, duration: number) => {
const barWidth = scrollBarRef?.current.getBoundingClientRect().width;
const stateWidth = progressStateRect?.width;
// 애니메이션 시작시간, 최초 상태바 width 값
const startTime = performance.now();
const xPosition = targetX * barWidth;
const animateScroll = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
currentPosition =
currentPosition + (xPosition - currentPosition - stateWidth / 2) * 0.2;
progressState.style.transform = `translateX(${currentPosition}px)`;
const rafId = requestAnimationFrame(animateScroll);
if (progress === 1) cancelAnimationFrame(rafId);
};
requestAnimationFrame(animateScroll);
};
코드를 살펴보면, 우선 scrollBar 에 ref 참조값을 이용해서 child 요소인 bar 컨테이너 안의 상태바 노드를 progressState로 가져왔고, 여기에 getBoundingClientRect 메서드를 이용해 해당 상태바의 left 초기값을 구하고 이를 currentPosition 에 할당했다.
css 적으로 기본 포지션이 translateX(-100px) 이었기에 초기값은 -100px 인 상태
앞서 raf 사용방식과 마찬가지로 시작시간을 지정하고, 인자로 받아오는 targetX 값에 bar 컨테이너 전체의 width를 곱해 현재 상태바가 위치해야 하는 목표값을 구했다.
이전 스크롤 아이템에 대한 raf 함수의 인자와 달리 스크롤바 애니메이션으로 사용되는 raf의 외부함수 인자 targetX는 목표값이 아닌 전체스크롤 범위 기준 현재 스크롤된 범위의 비율값 %를 의미 (추후 뒤에 설명)
이후 앞서 raf 방식과 마찬가지로 animateScroll 콜백함수를 인자로 넣어 반복호출한다. 여기서 차이점은 스크롤바 애니메이션에는 매 콜백함수가 주기적으로 실행될 때마다 currentPosition 값이 갱신되면서 목표값에 도달할 때까지 그 값이 축적된다는 것이다. 이 값은 매 호출마다 translateX의 이동값이 되어 결과적으로 부드럽게 스크롤바가 x축 기준으로 이동하는 전환효과를 표현할 수 있게 된다.
stateWidth / 2 를 빼주는 이유는 좌측이나 우측 끝에 도달할 시 스크롤바의 일부가 계속 보이는 상태로 두기 위함. (현재 왼쪽이나 오른쪽으로 끝까지 이동 시 translate css 초기 설정값에 따라 한 쪽은 끝에 도달할 시 상태바가 그대로 자신의 너비만큼 들어가서 컨테이너 영역밖으로 사라져버림. 조건부로 코드를 수정하여 항상 상태바가 끝에 도달해도 노출되어 있도록 구현하고자 했으나 코드가 너무 더러워질듯 하여 현재는 임시방편으로 이렇게 구현한 상태..추후 수정이 필요한 부분)
인자로 들어가는 target값은 getBarProgress 의 return 값으로, 이는 전체 스크롤 가능 영역에서 현재 스크롤된 위치(scrollLeft) 를 나눈 비율값이다.
이 값은 후에 스크롤바 애니메이션 콜백함수의 외부함수에서 스크롤 바 컨테이너의 너비에 곱해져 최종적으로 도달해야하는 목표값 x가 된다.
(현재 스크롤 위치 / 전체스크롤가능영역 === 스크롤바 컨테이너 기준 상태바의 현재 위치에 대한 % 비율값)
결과적으로 드래그 한 양만큼 알맞게 따라 움직이면서도 자동으로 맞춰지면서도 수정되는 스크롤 범위만큼 알맞게 따라 이동되는 애니메이션 구현을 할 수 있었다.
현재 이 방대한 event 함수들과 애니메이션함수들이 하나의 컴포넌트에 통합되어 있다. 추후 다음 리팩토링 과정에서는 이 부분을 분리하여 코드개선을 하고, 브라우저 창 크기 변경에 따라 스크롤 아이템 요소들이 적절히 줄어들도록 resize 작업을 진행하고자 한다. (작업 후 포스팅 예정)