웹뿐만 아니라 과거의 많은 소프트웨어들은 세로 스크롤이 동작할 때, 상단에 고정요소가 많이 등장하였습니다. 또한, 현재에도 많은 어플리케이션에서 고정요소를 사용합니다. 아래는 그 고정요소를 구현하기 위한 가장 대표적인 방법입니다.
position: fixed;
와 Javascript를 이용하여 Scroll Event
으로 구현position: sticky;
를 이용하여 구현고정요소를 구현하기 위해서는 다양하게 존재하겠지만, 이 글에서는 2번째 방법
에 대해서 한계점과 그 한계점을 돌파하기 위한 방법에 대해서 기술해보겠습니다. 하지만, 만약 현재 죽어버린 Internet Explorer를 위한 고정요소 개발을 위해서라면, 보지 않으셔도 됩니다. 유감스럽게도 sticky
는 IE에서 동작하지 않습니다.
(NOPE)
먼저, sticky
가 우리의 삶에 어떤 영향을 끼쳤는지에 대해서 체감해야할 필요성이 존재합니다. 과거의 방식을 먼저 알아볼까요?
모던 웹 브라우저가 나오기전에는 sticky
가 없었습니다. 그렇기에 다른 방식으로 고정요소를 구현해야 했습니다. 기존 구현 방식을 사용하려면 다음과 같은 지식이 필요합니다.
Scroll Event
의 동작으로 인해서 성능저하를 방지하기 위한 기술우리는 단순히 헤더 또는 사이드바를 화면에 특정 구간에서 화면에 고정시키고 싶었을뿐인데, 생각보다 베이스지식이 많이 필요합니다. 그래서 현대 브라우저에서 sticky
라는 속성이 등장하였습니다.
위에서 했던 과정은 sticky
속성을 사용함으로서 CSS로만 충분히 해결할 수 있게 되었습니다.
바로 아래 코드로 말이죠.
.sticky { position: sticky; top: 0; }
그러나, 필자가 현재 진행 중인 사내 프로젝트에서 overflow
와 함께 sticky
속성을 사용해야하는 경우가 발생하였습니다. 하지만, sticky
가 동작하기위해 필요한 조건은 다음과 같습니다.
top
, bottom
, left
, right
중 하나라도 함께 존재하여야 함.overflow
속성이 존재하는 경우, visible
를 제외하고 값을 가지지 않아야함.위와 같은 속성이 기본적으로 깔려있는데, overflow
와 함께 쓰여야한다니, 모순적인 것처럼 보입니다. 이 두 가지 속성이 언제 함께 사용되냐고 하면 Excel과 같은 상황입니다.
엑셀에서 상단 알파벳영역은 세로 스크롤을 해도 상단에 붙어있습니다. 동시에 가로 스크롤을 해도 thead
영역과 tbody
영역이 함께 이동됩니다. 제가 가장 원하고 이상적인 동작입니다!
그럼, 이 것을 웹에서 구현했을 때에는 어떤 문제점이 발생할까요?
가로너비에 제한없이 Sticky
하나만 사용한 경우입니다. 잘 동작하지만 제가 원하는 상황은 아닙니다. 제가 원하는 것은 body
에 스크롤이 걸려있는 것이 아닌, table
태그에만 스크롤이 걸려있는 상황입니다.
간단하게 테이블에 테이블헤더에는 Sticky
속성을 넣어두고, 테이블에는 Overflow
을 사용해서 가로스크롤이 생기도록 제작해보았습니다. 하지만, 이 방식으로는 테이브러헤더에 Sticky
가 동작하지 않습니다!
이번에는 테이블의 Height
를 200px로 설정해보았습니다. 그랬더니, 의도했던 모습이랑 비슷 해졌습니다만, 제가 원하는 상황은 테이블의 높이가 아래처럼 고정되는 상황이 아닙니다. 또한, 하나의 화면에서 스크롤이 중첩되어서 좋은 방법도 아닌 것처럼 보입니다. 결국 다른 방법을 찾아야했습니다.
위에서 소개한 예제 코드에서는 Overflow
가 테이블에 걸려있었습니다. 아래 예시에서는 각 thead
와 tbody
에 Overflow
을 걸어서, Sticky
가 정상적으로 동작하도록 만들었습니다. 그렇지만 이 또한 제가 원하는 최종 모습은 아닙니다. 제가 원하는건 tbody
를 스크롤해도 thead
가 함께 스크롤 되는 모습입니다.
안타깝게도, 자바스크립트의 도움없이는 해결할 수 있는 방법을 찾지 못하여, 두 Element의 스크롤을 Sync해주는 기능을 추가했습니다.
스크롤을 동기화 시키는 작업은 asvd/syncscroll [Github] 오픈소스를 활용하였습니다. 테이블 헤더와 바디의 스크롤을 동기화시켜서 원하는 모습을 만들어냈습니다. 세로 스크롤은 body
에 적용되어 있으며, 가로 스크롤은 table
에 적용되어 있는 모습이 정확하게 동작합니다.
그러나, 해당 라이브러리에서는 className
을 사용하는 관계로 제가 원하는 구조에 적용시키기가 난해했기에 원하는 동기화 시키고 싶은 Element 배열
을 인자로 받는 함수로 확장시켜서 개발하였습니다. 아래는 코드입니다.
const x = Symbol('x'); const y = Symbol('y'); const sync = Symbol('sync'); /** * 스크롤 동기화 이벤트 등록 함수 * - 해당 함수에 넣어진 태그들이 스크롤되면, 해당 태그들은 스크롤이 동기화된다. * @param props * @param props.elems 스크롤 동기화 대상 Element * @param props.xAxis 가로축 동기화 여부 * @param props.yAxis 세로축 동기화 여부 */ export const addSyncScroll = ({ elems, xAxis, yAxis, }: { elems: Element[]; xAxis?: boolean; yAxis?: boolean; }) => { let timer = 0; let scrollingElement: Element | null = null; elems.forEach(elem => { elem[x] = 0; elem[y] = 0; (function (elem) { elem.addEventListener( 'scroll', (elem[sync] = () => { // 다른 스크롤 이벤트가 간섭하지 못하도록 Lock 걸어둠 if (scrollingElement && scrollingElement !== elem) { return; } else if (!scrollingElement) { scrollingElement = elem; } else { window.clearTimeout(timer); timer = window.setTimeout(() => { scrollingElement = null; }, 100); } let scrollX = elem.scrollLeft; let scrollY = elem.scrollTop; // 스크롤 비율 계산 const xRate = scrollX / (elem.scrollWidth - elem.clientWidth); const yRate = scrollY / (elem.scrollHeight - elem.clientHeight); // Scroll 위치가 다를 경우에만 갱신 const updateX = scrollX !== elem[x]; const updateY = scrollY !== elem[y]; elem[x] = scrollX; elem[y] = scrollY; // 스크롤 Element가 아닌 다른 Element일 경우에만 갱신 elems.forEach(otherElem => { if (elem === otherElem) { return; } if ( xAxis && updateX && Math.round( otherElem.scrollLeft - (scrollX = elem[x] = Math.round(xRate * (otherElem.scrollWidth - otherElem.clientWidth))), ) ) { otherElem.scrollLeft = scrollX; } if ( yAxis && updateY && Math.round( otherElem.scrollTop - (scrollY = elem[y] = Math.round(yRate * (otherElem.scrollHeight - otherElem.clientHeight))), ) ) { otherElem.scrollTop = scrollY; } }); }), ); })(elem); }); }; /** * 스크롤 동기화 이벤트 해제 함수 * - 해당 함수에 넣어진 태그들이 가지고 있는 스크롤 동기화 이벤트를 해제한다. * @param props * @param props.elems DOM Element List */ export const removeSyncScroll = ({ elems }: { elems: Element[] }) => { elems.forEach(elem => elem.removeEventListener('scroll', elem[sync])); };
React
환경에서 위 함수를 통해서 아래과 같이 작성할 수 있습니다.
useEffect(() => { const headerElement = header.current; const bodyElement = body.current; if (!headerElement || !bodyElement) { return; } addSyncScroll({ elems: [headerElement, bodyElement], xAxis: true }); return () => { removeSyncScroll({ elems: [headerElement, bodyElement] }); }; }, []);
더 좋은 방법이 있다면, 언제든지 댓글로 알려주세요! 👏