CSS position:sticky에 대한 이벤트

에릭 비델만

요약

주의사항: 다음 앱에서는 scroll 이벤트가 필요하지 않을 수 있습니다. IntersectionObserver를 사용하여 position:sticky 요소가 수정되거나 고정이 중지되었을 때 맞춤 이벤트를 실행하는 방법을 보여드리겠습니다. 스크롤 리스너를 사용하지 않아도 됩니다. 이를 입증하는 놀라운 데모도 있습니다.

데모 보기 | 소스

sticky-change 이벤트 소개

CSS 고정 위치를 사용할 때의 실질적인 제한사항 중 하나는 속성이 활성 상태인지 알 수 있는 플랫폼 신호를 제공하지 않는다는 점입니다. 즉, 요소가 고정될 때 또는 고정되지 않는 시점을 알 수 있는 이벤트가 없습니다.

다음은 상위 컨테이너 상단에서 <div class="sticky">를 10px로 수정하는 예시입니다.

.sticky {
  position: sticky;
  top: 10px;
}

요소가 해당 표시에 도달했을 때 브라우저가 알리면 좋지 않을까요? 그렇게 생각하는 유일한 사람은 아닙니다. position:sticky의 신호로 다음과 같이 여러 사용 사례를 잠금 해제할 수 있습니다.

  1. 배너가 고정될 때 그림자를 적용합니다.
  2. 사용자가 콘텐츠를 읽는 동안 분석 조회수를 기록하여 진행 상황을 파악하세요.
  3. 사용자가 페이지를 스크롤할 때 플로팅 TOC 위젯을 현재 섹션으로 업데이트합니다.

Google에서는 이러한 사용 사례를 염두에 두고 position:sticky 요소가 수정될 때 실행되는 이벤트를 만드는 최종 목표를 마련했습니다. 이를 sticky-change 이벤트라고 합니다.

document.addEventListener('sticky-change', e => {
  const header = e.detail.target;  // header became sticky or stopped sticking.
  const sticking = e.detail.stuck; // true when header is sticky.
  header.classList.toggle('shadow', sticking); // add drop shadow when sticking.

  document.querySelector('.who-is-sticking').textContent = header.textContent;
});

데모는 이 이벤트를 사용하여 그림자가 수정될 때 그림자를 붙입니다. 또한 페이지 상단에 새 제목이 업데이트됩니다.

데모에서는 스크롤 이벤트 없이 효과가 적용됩니다.

스크롤 이벤트 없이 스크롤 효과를 사용하나요?

페이지의 구조입니다.
페이지의 구조

게시물의 나머지 부분에서 이러한 이름을 참조할 수 있도록 몇 가지 용어를 제거하겠습니다.

  1. 스크롤 컨테이너 - '블로그 게시물' 목록이 포함된 콘텐츠 영역 (표시 영역)입니다.
  2. Headers: 각 섹션에 position:sticky이 포함된 파란색 제목
  3. 고정 섹션 - 각 콘텐츠 섹션 고정 헤더 아래로 스크롤되는 텍스트입니다.
  4. '고정 모드': position:sticky가 요소에 적용되는 경우

'고정 모드'로 전환되는 헤더를 확인하려면 스크롤 컨테이너의 스크롤 오프셋을 결정하는 방법이 필요합니다. 이를 통해 현재 표시되고 있는 헤더를 계산할 수 있습니다. 그러나 scroll 이벤트가 없으면 매우 까다롭습니다. :) 또 다른 문제는 position:sticky가 수정되면 레이아웃에서 요소를 삭제한다는 것입니다.

따라서 스크롤 이벤트가 없으면 헤더에서 레이아웃 관련 계산을 실행할 수 없습니다.

스크롤 위치 확인을 위해 더미 DOM 추가

scroll 이벤트 대신 IntersectionObserver를 사용하여 headers가 고정 모드로 전환되거나 종료되는 시점을 결정합니다. 각 고정 섹션에 노드 두 개(센티널)를 하나, 즉 상단에 하나, 하단에 하나씩 추가하면 스크롤 위치를 파악할 수 있는 경유지 역할을 합니다. 이러한 마커가 컨테이너에 들어오고 나가면 공개 상태가 변경되고 Intersection Observer에서 콜백을 실행합니다.

센티널 요소 표시 안 함
숨겨진 센티널 요소.

위아래로 스크롤하는 네 가지 사례를 다루려면 두 개의 센티널이 필요합니다.

  1. 아래로 스크롤 - header는 상단 센티널이 컨테이너 상단을 넘으면 고정됩니다.
  2. 아래로 스크롤 - header는 섹션 하단에 도달하고 하단 센티널이 컨테이너 상단을 지나가면 고정 모드를 종료합니다.
  3. 위로 스크롤 - 헤더는 상단 센티널이 맨 위에서 뷰로 다시 스크롤될 때 고정 모드를 종료합니다.
  4. 위로 스크롤 - 하단 센티널이 맨 위에서 뷰 안으로 다시 교차할 때 헤더가 고정됩니다.

1~4의 스크린캐스트를 발생한 순서대로 보는 것이 좋습니다.

Intersection Observer는 센티널이 스크롤 컨테이너에 들어가거나 나올 때 콜백을 실행합니다.

CSS

센티널은 각 섹션의 상단과 하단에 있습니다. .sticky_sentinel--top는 헤더 상단에 있고 .sticky_sentinel--bottom는 섹션 하단에 있습니다.

하위 센티널이 기준점에 도달했습니다.
상단 및 하단 센티널 요소의 위치
:root {
  --default-padding: 16px;
  --header-height: 80px;
}
.sticky {
  position: sticky;
  top: 10px; /* adjust sentinel height/positioning based on this position. */
  height: var(--header-height);
  padding: 0 var(--default-padding);
}
.sticky_sentinel {
  position: absolute;
  left: 0;
  right: 0; /* needs dimensions */
  visibility: hidden;
}
.sticky_sentinel--top {
  /* Adjust the height and top values based on your on your sticky top position.
  e.g. make the height bigger and adjust the top so observeHeaders()'s
  IntersectionObserver fires as soon as the bottom of the sentinel crosses the
  top of the intersection container. */
  height: 40px;
  top: -24px;
}
.sticky_sentinel--bottom {
  /* Height should match the top of the header when it's at the bottom of the
  intersection container. */
  height: calc(var(--header-height) + var(--default-padding));
  bottom: 0;
}

Intersection Observer 설정

Intersection Observer는 타겟 요소와 문서 표시 영역 또는 상위 컨테이너의 교차점에서 변경사항을 비동기식으로 관찰합니다. 여기서는 상위 컨테이너와의 교집합을 관찰합니다.

매직 소스는 IntersectionObserver입니다. 각 센티널은 스크롤 컨테이너 내의 교차 공개 상태를 관찰하기 위한 IntersectionObserver를 가져옵니다. 센티널이 표시되는 표시 영역으로 스크롤하면 헤더가 고정되거나 고정이 중지됩니다. 마찬가지로 센티널이 표시 영역을 벗어날 때도 마찬가지입니다.

먼저 머리글 및 바닥글 센티널에 관찰자를 설정합니다.

/**
 * Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
 * Note: the elements should be children of `container`.
 * @param {!Element} container
 */
function observeStickyHeaderChanges(container) {
  observeHeaders(container);
  observeFooters(container);
}

observeStickyHeaderChanges(document.querySelector('#scroll-container'));

그런 다음 .sticky_sentinel--top 요소가 스크롤 컨테이너 상단을 통과할 때 실행되는 관찰자를 추가했습니다 (두 방향 모두 가능). observeHeaders 함수는 상위 센티널을 만들어 각 섹션에 추가합니다. 관찰자는 컨테이너 상단과 센티널의 교차점을 계산하고 표시 영역에 진입할지 아니면 표시 영역을 벗어나는지를 결정합니다. 이 정보는 섹션 헤더가 고정되는지 여부를 결정합니다.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--top` become visible/invisible at the top of the container.
 * @param {!Element} container
 */
function observeHeaders(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;

      // Started sticking.
      if (targetInfo.bottom < rootBoundsInfo.top) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.bottom >= rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
       fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [0], root: container});

  // Add the top sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--top');
  sentinels.forEach(el => observer.observe(el));
}

관찰자는 센티널이 표시되는 즉시 콜백이 실행되도록 threshold: [0]로 구성됩니다.

이 프로세스는 하단 센티널 (.sticky_sentinel--bottom)과 유사합니다. 바닥글이 스크롤 컨테이너의 하단을 통과할 때 실행되도록 두 번째 관찰자가 생성됩니다. observeFooters 함수는 센티널 노드를 만들어 각 섹션에 연결합니다. 관찰자는 센티널과 컨테이너 하단 사이의 교차점을 계산하고 센티널이 들어오고 나가는지를 결정합니다. 이 정보는 섹션 헤더가 고정되는지 여부를 결정합니다.

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
 * container.
 * @param {!Element} container
 */
function observeFooters(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;
      const ratio = record.intersectionRatio;

      // Started sticking.
      if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.top < rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
        fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [1], root: container});

  // Add the bottom sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
  sentinels.forEach(el => observer.observe(el));
}

관찰자는 전체 노드가 뷰 내에 있을 때 콜백이 실행되도록 threshold: [1]로 구성됩니다.

마지막으로 sticky-change 맞춤 이벤트를 실행하고 센티널을 생성하는 유틸리티가 두 가지 있습니다.

/**
 * @param {!Element} container
 * @param {string} className
 */
function addSentinels(container, className) {
  return Array.from(container.querySelectorAll('.sticky')).map(el => {
    const sentinel = document.createElement('div');
    sentinel.classList.add('sticky_sentinel', className);
    return el.parentElement.appendChild(sentinel);
  });
}

/**
 * Dispatches the `sticky-event` custom event on the target element.
 * @param {boolean} stuck True if `target` is sticky.
 * @param {!Element} target Element to fire the event on.
 */
function fireEvent(stuck, target) {
  const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
  document.dispatchEvent(e);
}

작업이 끝났습니다.

최종 데모

position:sticky가 있는 요소가 고정되고 scroll 이벤트를 사용하지 않고 스크롤 효과를 추가한 경우 맞춤 이벤트를 만들었습니다.

데모 보기 | 소스

결론

IntersectionObserver가 수년간 발전해 온 일부 scroll 이벤트 기반 UI 패턴을 대체하는 데 유용한 도구가 될지 궁금했습니다. 대답은 '예'와 '아니요'입니다. IntersectionObserver API의 의미 체계는 모든 것에 사용하기 어렵게 만듭니다. 하지만 여기서 보여드린 것처럼 몇 가지 흥미로운 기법을 활용할 수 있습니다.

스타일 변경을 감지하는 또 다른 방법은

잘 모르겠죠 우리는 DOM 요소의 스타일 변경을 관찰하는 방법이 필요했습니다. 안타깝게도 웹 플랫폼 API에는 스타일 변경을 볼 수 있는 것이 없습니다.

MutationObserver는 논리적인 첫 번째 선택이지만 대부분의 경우에는 작동하지 않습니다. 예를 들어 데모에서는 sticky 클래스가 요소에 추가되면 콜백이 수신되지만 요소의 계산된 스타일이 변경될 때는 수신되지 않습니다. sticky 클래스는 페이지 로드 시 이미 선언되었습니다.

향후 Mutation Observers의 'Style Mutation Observer' 확장 프로그램이 요소의 계산된 스타일의 변경사항을 관찰하는 데 유용할 수 있습니다. position: sticky.