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 위젯을 현재 섹션으로 업데이트합니다.

이러한 사용 사례를 염두에 두고 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. 헤더 - position:sticky가 있는 각 섹션의 파란색 제목입니다.
  3. 고정 섹션 - 각 콘텐츠 섹션입니다. 고정 헤더 아래로 스크롤되는 텍스트입니다.
  4. '고정 모드' - position:sticky가 요소에 적용되는 경우

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

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

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

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

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

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

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

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

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

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 클래스는 페이지 로드 시 이미 선언되었습니다.

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