obo 프로젝트를 하면서 nav바, 회원가입, 로그인 등의 화면을 만들고 나니 메인페이지가 없어서 볼 때마다 허전하고 휑하다는 기분이 들어 메인페이지를 만들기로 했다.
위와 같이 한 페이지 내에서 4개의 섹션으로 나누어 애니메이션, 텍스트와 함께 페이지가 구성되도록 디자인했다.
아직 푸터는 만들지 않았기에 nav바만 포함시켜 만들기로 하고 추후에 푸터를 포함하여 스타일을 다듬기로 했다.
다른 것 보다도 이 페이지의 핵심은 스크롤 1번당 섹션 1개씩 건너뛰어 이동하는 기능이었다.
어떻게 하면 스크롤을 한번 내리면 다음 섹션으로 이동하고 한번 올리면 이전 섹션으로 이동할 수 있게 할까?
일단 아이디어가 떠오르지 않아
chatGPT에게 물어보았더니, 스크롤 위치에 따라 이동시키면 될 거라는 대답을 들을 수 있었다.
사실 스크롤의 강제(?) 이동 기능은 이미 빌리지(BilliG) 프로젝트에 참여했을때, 채팅방의 대화 내역이 표시되는 영역에 대해 항상 가장 최신의 채팅이 보이게 하도록 채팅 대화내역 영역 바로 밑에 div 태그를 배치하고 해당 div태그에 useRef로 ref 를 참조하도록 설정하여 scrollIntoView로 이동시켜봤던 경험이 있었다.
그래서 chatGPT가 알려준 아이디어와 이전의 경험을 합쳐보면 스크롤의 현재 위치에 따라 useRef를 이용해서 각 섹션의 ref를 참조하도록 설정하고 현재 위치에 따라 이동시키면 되겠구나! 라는 1차적인 아이디어가 만들어졌다.
그렇다면, 일단 스크롤의 현재 위치를 어떻게 알아내느냐가 중요한데 이와 관련된 DOM요소의 속성에는 무엇이 있는지 chatGPT에게 물어본 후 자세한 내용은 MDN에 검색하여 알아볼 수 있었다.
Element.scrollTop
요소의 콘텐츠가 세로로 스크롤되는 픽셀 수를 가져오거나 설정하는 속성으로 현재 세로 스크롤의 위치가 몇 픽셀 정도에 위치해 있는지 확인할 수 있다고 보면 된다.
Element.clientHeight & Element.scrollHeight
Element.clientHeight은 엘리먼트의 내부 높이를 픽셀로 반환한다고 하는데, 모니터의 뷰포트 높이에 해당하는 영역의 높이라고 생각하면 된다.
즉, 모니터의 한 화면 안에 다 들어오는 영역의 높이이다.
Element.scrollHeight은 요소 콘텐츠의 총 높이를 나타내며, 바깥으로 넘쳐서 보이지 않는 콘텐츠도 포함한다고 하는데, 모니터의 한 화면 안에 다 들어오지 못하는 스크롤로 확인 가능한 넘치는 영역들의 높이를 모두 포함한 높이이다.
즉, 스크롤 가능한 전체 영역의 높이이다.
이제 이 세 가지 속성을 통해 스크롤의 현재 위치와 전체 높이, 한 섹션의 높이를 지정할 수 있게 되었다.
그런데, 이것저것 시도를 하다 보니 위의 속성들이 제대로 된 값을 반환하지 않고 전부 0으로 출력되는 상황이 벌어졌다.
이유를 생각해보니 메인페이지가 될 영역에 대해서 스크롤 위치와 높이를 측정했을 뿐, 실제로 사용자 입장에서 가장 바깥에서 스크롤 할 수 있는 App.tsx 영역에서의 스크롤이라고 생각하지 않았던 것이었다.
그해서 MainPage.tsx와 App.tsx 영역의 너비를 수정하고 App.tsx에서 스크롤을 컨트롤하는 함수를 만들기로 했다.
MainPage.tsx에서 스크롤 컨트롤을 하려던 실수를 인지하고 App.tsx에서 스크롤을 컨트롤하고자 방향을 바꿨을 때,
useRef를 이용해서 스크롤 할 전체 영역인 className이 App인 div 를 참조해야 한다고 생각했다.
useRef가 반환하는 값인 React.MutableRefObject에는 current라는 속성이 존재하는데, current 속성에는
실제 DOM 요소가 저장된다.
그리하여 useRef를 이용해 선언한 변수에 .current.scrollTop, .current.clientHeight, .current.scrollHeight 을 통해 속성에 접근하면 해당 값을 정상적으로 읽어낼 수 있게 된다.
const scrollRef = useRef<HTMLDivElement>(null);
if (scrollRef.current) {
const scrollTop = scrollRef.current.scrollTop;
const scrollHeight = scrollRef.current.scrollHeight;
const clientHeight = scrollRef.current.clientHeight;
}
❗ 위와 같이 scrollRef.current가 존재할 경우에만 속성에 접근해야 타입 에러가 발생하지 않는다.
이렇게 하면 App.tsx에서 가장 바깥의 div가 참조되어 스크롤링 할 때마다 onScroll 이벤트에 함수가 동작하도록 설정할 수 있게 된다.
그런데 MainPage.tsx에서 4개의 섹션에 대해서도 useRef를 이용하여 참조를 생성해야 되었다.
이유는, 스크롤 이벤트 발생 시 해당 섹션으로 이동해야 하기 때문이다.
이동할 섹션 요소가 무엇인지 참조할 수 있도록 아래와 같이 4개의 섹션 참조 배열을 선언했다.
const sectionRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
나는 이미 4개의 섹션을 map 메소드를 이용하여 생성하는 방법을 적용했기 때문에, ref={sectionRefs[index]} 와 같이 ref 속성을 부여해주기만 하면 되었다.
return (
<StyledMainWrapper>
{animationDataArray.map((item, index) => {
return (
<StyledSectionContainer key={index} index={index} ref={sectionRefs[index]}>
/*코드가 길어보이므로 숨김처리*/
</StyledSectionContainer>
);
})}
</StyledMainWrapper>
);
현재 스크롤의 위치와 이동할 섹션에 대한 참조가 완료되었으므로 스크롤 이벤트 발생 시 동작할 함수를 작성하면 완성이다!
우선, 나는 1회의 스크롤만으로 다음 섹션으로 넘어가는 이벤트를 만들어야 하기 때문에 위 그림처럼 minScrollHeight를 상수로 설정한 후 (대략 한 스크롤 휠이 100px 정도 되니까 100으로 설정) 해당 값보다 scrollTop이 크다면 다음 섹션으로 넘어갈 수 있도록 하고 마찬가지로 다음 섹션에서도 해당 섹션의 스크롤 맨 위부터 minScrollHeight 를 넘어가면 다음 섹션으로 넘어가는 아이디어를 구상했다.
useEffect(() => {
sectionRefs[scrollIndex].current?.scrollIntoView({ behavior: 'smooth' });
}, [scrollIndex]);
index라 함은 전역적으로 관리하는 상태 값인 scrollIndex를 칭한다. 위 코드로 scrollIndex가 업데이트될 때마다 섹션 이동이 일어날 수 있게 된다.
이로써 스크롤을 아래로 1번 내리면 다음 섹션으로 스크롤이 이동되는 기능이 구현되었다!
하지만...
현재로써는 다음 섹션으로 넘어가는 건 가능하지만, 이전 섹션으로 되돌아가는 건 불가능한 상황이다.
그렇기에 스크롤을 올릴 때 이전 섹션으로 되돌아가는 로직을 추가해야 한다.
다음 섹션으로 넘어가는 아이디어는 납득도 되고 구현도 잘 되는데, 이전 섹션으로 되돌아가는 아이디어는 어떻게 생각해볼 수 있을까?
우선은 "되돌아간다 === scrollTop 값이 줄어든다" 라는 아이디어에서 시작할 수 있었다.
스크롤을 올리면 scrollTop 값이 줄어들게 된다. 즉, 방금 전 내가 스크롤 하여 나온 scrollTop 값보다 현재 내가 스크롤하여 나온 scrollTop값이 더 작다.
그렇기에 prevScrollPosition 이라는 상태 값을 새로 만들었다.
해당 값은 스크롤을 내릴 때의 동작을 모두 수행한 후 업데이트되도록 했다.
그리고 현재 scrollTop 상태 값인 scrollPosition 을 prevScrollPosition 과 비교하기 위해
새로운 변수 delta를 선언해준다.
const delta = scrollPosition - prevScrollPosition;
delta가 0보다 크면 스크롤을 내릴 때 라고 판단할 수 있고, delta가 0보다 작으면 스크롤을 올릴 때 라고 판단할 수 있게 된다.
물론 0과 같을 수도 있겠지만 실제로 스크롤 값을 조정해본 결과 0과 같은 경우는 극히 드물었기에 배제했다.
/*변수 및 state 선언 생략*/
/* 스크롤 내릴때 동작 */
if (delta > 0) {
setInitialScrollState(false);
if (scrollPosition < minScrollHeight) {
setCurrentIndex(0);
} else if (scrollPosition >= minScrollHeight && scrollPosition < clientHeight) {
setCurrentIndex(1);
} else if (scrollPosition >= clientHeight + minScrollHeight && scrollPosition < clientHeight * 2) {
setCurrentIndex(2);
} else if (scrollPosition >= clientHeight * 2 + minScrollHeight && scrollPosition <= maxScrollTop) {
setCurrentIndex(maxSectionIndex);
}
}
setPrevScrollPosition(scrollPosition);
/* 스크롤 올릴때 동작 */
if (delta < 0) {
if (currentIndex > 0) {
setCurrentIndex(currentIndex => currentIndex - 1);
} else {
setCurrentIndex(0);
}
}
useEffect(() => {
if (currentIndex >= 0 && currentIndex <= 3) {
setScrollIndex(currentIndex);
}
}, [currentIndex]);
위와 같이 App.tsx에서의 스크롤 이벤트에 동작하는 handleOnScroll 함수의 로직을 확정하고 기능 동작을 확인해보았다.
그랬더니 스크롤을 아래로 내려 다음 섹션으로 넘어가는 것은 가능하나 위로 올려 이전 섹션으로 되돌아가는 것은 불가했다.
정확히 말하자면, 스크롤을 위로 여러 번 올리면 갑자기 인덱스가 0인 섹션으로 한 번에 올라갔다.🤣
scrollIndex가 정확히 잘 들어갔는지 확인해보기 위해 콘솔로그로 확인해봤으나 너무 정확히 잘 들어가 있었다 (...)
그렇기에 무엇이 원인인지 완벽하게 납득이 가지 않고 이해되지 않아 스크롤을 위로 올릴 때의 로직은 우선 제외하고
대신에 맨 마지막 섹션에서 버튼을 누르면 인덱스가 0인 섹션으로 돌아갈 수 있게끔 임시방편을 마련해뒀다.
또한, 맨 처음 페이지가 로드될 때 인덱스가 0인 섹션에 스크롤이 고정되느라 nav바가 안 보이게 되는 문제가 발생했는데
맨 처음에 페이지가 로드되었음을 설정하는 상태 값을 전역적으로 관리하여 해당 값이 true이면 scrollIntoView가 동작하지 않도록 막았다.
인덱스가 감소하는 방향의 섹션 이동이 불가한 이유에 대해 추후에 더 알아보고 개선을 해야겠다고 생각했다.
섹션 이동 직전의 scrollIndex 값이 정상 출력됨에도 해당 섹션으로 이동하지 않는 현상의 원인에 대해서
로직 충돌이나 코드 수행 순서가 얽혔을 가능성이 있으므로 해당 부분은 꼭 수정하고 싶다.
또한, 해당 기능이 ux가 좋지 않다고 판단하여 적용하지 않는 사례가 많다고 들었는데
내가 디자인한 메인 페이지가 한 섹션이 뷰포트 높이를 전부 차지하고 있기 때문에 여러 번의 스크롤로 콘텐츠를 모두 확인하는 것은
번거로울 것으로 판단했기 때문이다. (실제 콘텐츠가 단순 애니메이션과 짧은 텍스트의 조합이기도 하고)
하지만 만약 더 나은 디자인으로 개선할 수 있다면 obo 프로젝트 완료 후 변경 작업을 시도해도 좋을 듯하다.
-끝-
참고자료
https://developer.mozilla.org/ko/docs/Web/API/Element/clientHeight
https://developer.mozilla.org/ko/docs/Web/API/Element/scrollHeight
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop