이런 식으로 현재 스크롤 위치에 따라 어디를 탐색하고 있는지 표시해주는 기능을 구현해보려고 한다. 요런 걸 Scrolling Navbar, 아니면 ScrollSpy라고 하는 것 같은데... 스크롤 스파이는 Bootstrap이나 jQuery에서 쓰는 독자용어 같더라. 나는 Scrolling Nav라고 표현하겠음!
그리고 사실 scroll-snap-type을 사용해서 fullpage scroll을 구현을 했는데, 그렇다면 현재 화면에 대한 Navigation은 어떻게 표시해줄 수 있는지를 고민하다가 Scrolling Nav라는 걸 알게 된 것. 이에 대한 건 아래쪽에서!
먼저 화면을 구성해보자. 가볍게 슝슝~
App.tsx
const sectionList = [ 'lightcoral', 'lightgreen', 'lightblue', 'lightcyan', 'lightsalmon', 'lightyellow', 'lightslategray', ]; export const App = () => { const sectionElements = sectionList.map((sectionColor) => ( <section key={sectionColor} id={sectionColor} className={styles.content} style={{ backgroundColor: sectionColor }} {sectionColor} </section> )); return ( <main className={styles.fullContainer}> {sectionElements} </main> ); };
아참 그리고 CSS는 Module CSS를 사용했다. Pure CSS 쓰다가 SCSS같은 전처리기 쓰다가 CSS in JS 쓰다가 결국 다시 CSS Module로 돌아오는 게 참 웃겨! 역시 튜닝의 끝은 순정인가
App.module.scss
.full-container { width: 100%; height: 100dvh; scroll-behavior: smooth; scroll-snap-type: y mandatory; overflow-y: scroll; } .content { width: 100%; height: 100dvh; display: grid; place-content: center; font-size: min(max(4vw, 1rem), 3rem); font-weight: 600; text-transform: uppercase; scroll-snap-align: start; }
여기에서 주의할 점은 scroll-snap-type을 쓰기 위해서는 overflow 속성이 지정되어야 한다. 안 그러면 안 먹힌다구!
또, 어느 지점에서 어떻게 멈출지를 정하는 게 scroll-snap-align이다. 객체를 중심으로 시작, 중간, 끝으로 조절할 수 있고, scroll-padding, scroll-margin 등을 사용하면 해당 위치에서 약간의 위치를 변경할 수 있다. 난 필요 없으므로 패스!
scroll-behavior는 네비 버튼을 클릭하면 부드럽게 이동하게 만들기 위해 지정해주었다.
나는 요 속성이 참 마음에 드는 게, 예상치 못한 스크롤 입력에 대해서도 유연하게 처리하고 있기 때문이었다. 예상치 못한 스크롤 입력이란, 저렇게 난리 부르스치면서 스크롤하고, 애매한 위치로 놔두고, 스크롤링이 끝나기 전에 다시 스크롤링을 시도하는 것에 대한 모든 것을 의미한다. 요걸 아주 자연스럽게 처리하고 있다는 점이 좋았다!
또, 모바일에서도 잘 동작해서 좋았다. 하나의 속성으로 크로스 브라우징이 된다니~ 물론 디테일하게 가려면 커스텀을 해야겠지만, 가벼운 캐러셀와 같은 요구사항이라면 요걸로 퉁쳐도 될 것 같다.
요까지 하면~ 아주 간편하게 Full page scroll 구현 끝!
이제 현재 화면이 어디에 위치해있는지를 알아주고, Nav Item에 뿌려주면 된다. 위치를 파악하는 방법은 여러 방법이 있겠지만, 쉽게 IntersectionObserver로 진행했다.
일단 네비게이션을 만들어주자.
ScrollingNavbar
const ScrollingNavbar = () => { const [currentSectionColor, setCurrentSectionColor] = useState('lightcoral'); const navItemElements = sectionList.map((sectionColor) => ( <NavItem key={`nav-${sectionColor}`} sectionColor={sectionColor} isActive={sectionColor === currentSectionColor} /> )); return <ul className={styles.navList}>{navItemElements}</ul>; }; type NavItemProp = { sectionColor: string; isActive: boolean; onClick: (sectionColor: string) => void; }; const NavItem = ({ sectionColor, isActive, onClick }: NavItemProp) => { return ( <li id={sectionColor} className={cn(styles.navItem, { [styles.active]: isActive })} style={{ backgroundColor: sectionColor }} onClick={() => onClick(sectionColor)} ></li> ); };
NavItem은 isActive 상태가 들어오면 아웃라인을 그려준다. 상태별 스타일은 classnames 패키지를 사용했다. 현재 선택된 SectionColor를 State로 저장하고, 비교 후에 같으면 active class를 넣어주는 형식이다.
이제 제일 중요한 IntersectionObserver를 써먹어서 화면 위치를 알아내보자.
...사실 알아내기 전에, 요 IO를 어떻게 하면 React스럽게, 다시 말해서 controlled로 다룰 수 있을까 고민해보고 나름 머 이렇게 저렇게 해봤는데. 나름의 결론은 IO는 그냥 uncontrolled 하게 쓰는 게 맞는 것 같다. 이에 대해선 후술.
편하게 쓰기 위해, 또 관심사 분리를 위해 Hook으로 만들어주었다.
useIntersectionObserver
type UseIntersectionObserverHookProp = { onIntersection: (entry: IntersectionObserverEntry) => void; onNoIntersection?: (entry: IntersectionObserverEntry) => void; }; const useIntersectionObserver = ({ onIntersection, onNoIntersection, }: UseIntersectionObserverHookProp) => { const IOOptions = { threshold: 0.5 }; const handleIOCallback: IntersectionObserverCallback = (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { onIntersection(entry); } else { onNoIntersection && onNoIntersection(entry); } }); }; const IO = useRef(new IntersectionObserver(handleIOCallback, IOOptions)); const observe = (element: HTMLElement) => IO.current.observe(element); const unobserve = (element: HTMLElement) => IO.current.unobserve(element); return { observe, unobserve }; };
이래저래 마음에 들지 않는 코드지만.. 요게 최선인 것 같다(ㅋㅋ)
기본적으로 '여러 개'의 HTML Element를 검사하고 있기 때문에, 받는 콜백은 해당 Element에 대한 이벤트로 생각해주어야 한다. 그래서 나는 아예 entry를 넘겨주었다.
그리고 쓰는 쪽에선 이렇게~ 쓰면 된다.
ScrollingNavbar
const ScrollingNavbar = () => { const [currentSectionColor, setCurrentSectionColor] = useState('lightcoral'); const { observe, unobserve } = useIntersectionObserver({ onIntersection({ target }) { setCurrentSectionColor(target.id); }, }); useEffect(() => { const allSectionList = document.querySelectorAll('section')!; allSectionList.forEach(observe); return () => allSectionList.forEach(unobserve); }, []); // .... }
이렇게 하면 초기 렌더가 끝난 후 section들에 대해서 observe를 하게 되고, 각 section들은 intersection이 될 때마다 hook의 callback을 실행시켜주게 되는 것. 그러면 onIntersection으로 들어가게 되고, 거기에서 currentSectionColor를 지정해주고, NavItem이 업데이트가 되는 것이다.
이렇게 하면 React의 State 추적 메커니즘을 어느 정도 잘 살렸다고 볼 수 있겠...지?!
사실 저렇게 안 하고 이것저것 다 빼고 document.querySelector()를 통해서 바로 active class를 넣어주면 코드가 훨씬 간결해진다. 하지만.. React를 쓰는 이상 Controlled하게 다뤄야 하지 않나?라는 생각에 저렇게 연결을 해준 거였는데, 지금 생각해보면 굳이 이걸 Controlled하게 다룰 필요가 없어 보이기도 하고...
IO자체가 비동기적으로, 또, React의 생명주기 사이클에서 벗어난 상태기도 하니까 굳이 Controlled하게 다룰려고 하지 않아도 될 것 같은 느낌. 게다가 콜백함수가 IO를 생성할 때 평가되는 걸로 계속 유지가 되니까 결국 안쪽에선 직접 꺼내는 document.querySelector()를 써야하기도 하고.
머... 그러므로 그냥 되도록 안 쓰되, Web API에서 제공하는 것들 중에 어쩔 수 없이 써야 할 경우는 맘 편히 쓰자! (ㅋㅋ)
오늘의 삽질 끝~