[CSS] Position: sticky; is not working

NB·2022년 7월 19일
5
post-thumbnail

웹뿐만 아니라 과거의 많은 소프트웨어들은 세로 스크롤이 동작할 때, 상단에 고정요소가 많이 등장하였습니다. 또한, 현재에도 많은 어플리케이션에서 고정요소를 사용합니다. 아래는 그 고정요소를 구현하기 위한 가장 대표적인 방법입니다.

  1. position: fixed; 와 Javascript를 이용하여 Scroll Event 으로 구현
  2. position: sticky; 를 이용하여 구현

고정요소를 구현하기 위해서는 다양하게 존재하겠지만, 이 글에서는 2번째 방법 에 대해서 한계점과 그 한계점을 돌파하기 위한 방법에 대해서 기술해보겠습니다. 하지만, 만약 현재 죽어버린 Internet Explorer를 위한 고정요소 개발을 위해서라면, 보지 않으셔도 됩니다. 유감스럽게도 sticky 는 IE에서 동작하지 않습니다.

(NOPE)

먼저, sticky 가 우리의 삶에 어떤 영향을 끼쳤는지에 대해서 체감해야할 필요성이 존재합니다. 과거의 방식을 먼저 알아볼까요?

기존 구현 방식 (IE 호환)

모던 웹 브라우저가 나오기전에는 sticky 가 없었습니다. 그렇기에 다른 방식으로 고정요소를 구현해야 했습니다. 기존 구현 방식을 사용하려면 다음과 같은 지식이 필요합니다.

  • 화면 스크롤위치가 고정요소 위치에 도달하였는지 여부
  • 과도한 Scroll Event의 동작으로 인해서 성능저하를 방지하기 위한 기술

우리는 단순히 헤더 또는 사이드바를 화면에 특정 구간에서 화면에 고정시키고 싶었을뿐인데, 생각보다 베이스지식이 많이 필요합니다. 그래서 현대 브라우저에서 sticky 라는 속성이 등장하였습니다.

Sticky 방식

위에서 했던 과정은 sticky 속성을 사용함으로서 CSS로만 충분히 해결할 수 있게 되었습니다.
바로 아래 코드로 말이죠.

.sticky {
  position: sticky;
  top: 0;
}

그러나, 필자가 현재 진행 중인 사내 프로젝트에서 overflow 와 함께 sticky 속성을 사용해야하는 경우가 발생하였습니다. 하지만, sticky 가 동작하기위해 필요한 조건은 다음과 같습니다.

  • top, bottom, left, right 중 하나라도 함께 존재하여야 함.
  • 부모의 요소들 중 overflow 속성이 존재하는 경우, visible 를 제외하고 값을 가지지 않아야함.

위와 같은 속성이 기본적으로 깔려있는데, overflow 와 함께 쓰여야한다니, 모순적인 것처럼 보입니다. 이 두 가지 속성이 언제 함께 사용되냐고 하면 Excel과 같은 상황입니다.

엑셀에서 상단 알파벳영역은 세로 스크롤을 해도 상단에 붙어있습니다. 동시에 가로 스크롤을 해도 thead 영역과 tbody 영역이 함께 이동됩니다. 제가 가장 원하고 이상적인 동작입니다!
그럼, 이 것을 웹에서 구현했을 때에는 어떤 문제점이 발생할까요?

구현 과정

Sticky만 사용 🫤

가로너비에 제한없이 Sticky 하나만 사용한 경우입니다. 잘 동작하지만 제가 원하는 상황은 아닙니다. 제가 원하는 것은 body에 스크롤이 걸려있는 것이 아닌, table 태그에만 스크롤이 걸려있는 상황입니다.

Sticky와 Overflow 사용 😣

간단하게 테이블에 테이블헤더에는 Sticky 속성을 넣어두고, 테이블에는 Overflow 을 사용해서 가로스크롤이 생기도록 제작해보았습니다. 하지만, 이 방식으로는 테이브러헤더에 Sticky 가 동작하지 않습니다!

Height 설정 😩

이번에는 테이블의 Height 를 200px로 설정해보았습니다. 그랬더니, 의도했던 모습이랑 비슷 해졌습니다만, 제가 원하는 상황은 테이블의 높이가 아래처럼 고정되는 상황이 아닙니다. 또한, 하나의 화면에서 스크롤이 중첩되어서 좋은 방법도 아닌 것처럼 보입니다. 결국 다른 방법을 찾아야했습니다.

내부에 Overflow 사용 🤔

위에서 소개한 예제 코드에서는 Overflow 가 테이블에 걸려있었습니다. 아래 예시에서는 각 theadtbodyOverflow을 걸어서, Sticky가 정상적으로 동작하도록 만들었습니다. 그렇지만 이 또한 제가 원하는 최종 모습은 아닙니다. 제가 원하는건 tbody를 스크롤해도 thead가 함께 스크롤 되는 모습입니다.

안타깝게도, 자바스크립트의 도움없이는 해결할 수 있는 방법을 찾지 못하여, 두 Element의 스크롤을 Sync해주는 기능을 추가했습니다.

Scroll 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] });
  };
}, []);

더 좋은 방법이 있다면, 언제든지 댓글로 알려주세요! 👏

References

profile
𝙄 𝙖𝙢 𝙖 𝙛𝙧𝙤𝙣𝙩𝙚𝙣𝙙 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙚𝙧 𝙬𝙝𝙤 𝙚𝙣𝙟𝙤𝙮𝙨 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙢𝙚𝙣𝙩. 👋 💻

0개의 댓글