CSS position:sticky イベント

要約

次のアプリでは scroll イベントは必要ないかもしれません。IntersectionObserver を使用して、position:sticky 要素が固定されたとき、または固定されなくなったときにカスタム イベントを発生させる方法について説明します。スクロールリスナーを 使用する必要はありませんそれを実証する素晴らしいデモもあります。

デモを見る | 出典

sticky-change イベントのご紹介

CSS 固定位置を使用する場合の実用的な制限の 1 つは、プロパティがアクティブになったことを知らせるプラットフォーム シグナルが提供されないことです。つまり、要素が固定状態になったタイミングや、要素が固定されなくなったタイミングを把握するためのイベントはありません。

次の例では、<div class="sticky"> が親コンテナの上部から 10 ピクセル固定されています。

.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 が修正されるとレイアウトから要素を削除することです。

そのため、スクロール イベントがないと、ヘッダーでレイアウト関連の計算を実行する機能が失われます

スクロール位置を決定するためにダンビー DOM を追加する

scroll イベントの代わりに、IntersectionObserver を使用して、headersが固定モードを開始するタイミングと終了するタイミングを決定します。各固定セクションに 2 つのノード(標識)を追加すると、スクロール位置を把握するためのウェイポイントとして機能します。これらのマーカーがコンテナに出入りすると表示が変化し、Intersection Observer がコールバックを開始します。

標識要素なし
隠れたセンチネル要素。

上下にスクロールする 4 つのケースに対応するには、2 つの見解が必要です。

  1. 下にスクロール - ヘッダーは、最上位のセンチネルがコンテナの上部を横断すると固定されます。
  2. 下にスクロール - header は、セクションの下部に到達し、下部の標識がコンテナの上部を横切るため、固定モードを終了します。
  3. 上へのスクロール - header は、最上部のスクロールで上からビューに戻ると固定モードを終了します。
  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)についても同様です。フッターがスクロール コンテナの下部を通過すると起動する 2 つ目のオブザーバーが作成されます。observeFooters 関数は、sentinel ノードを作成し、各セクションにアタッチします。オブザーバーは、センチネルとコンテナの底部の交差を計算し、コンテナの出入りを判断します。この情報により、セクション ヘッダーが固定されるかどうかが決まります。

/**
 * 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 カスタム イベントを発生させてセンチネルを生成するためのユーティリティが 2 つあります。

/**
 * @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 イベントを使用せずにスクロール効果を追加しました。

デモを見る | 出典

おわりに

長年にわたって開発されてきた scroll イベントベースの UI パターンの一部を置き換えるツールとして IntersectionObserver が役立つのかとよく疑問に思っています。答えは「はい」か「いいえ」です。IntersectionObserver API のセマンティクスにより、すべてに使用することは困難です。しかし、ここで紹介したように、いくつかの興味深い手法に使用できます。

スタイルの変更を検出する別の方法は?

そうでもありません。必要なのは、DOM 要素のスタイル変更を監視する方法でした。残念ながら、ウェブ プラットフォーム API には、スタイルの変更を監視できるものはありません。

MutationObserver が第 1 の選択肢となりますが、これはほとんどのケースでは機能しません。たとえば、このデモでは、sticky クラスが要素に追加されたときにコールバックを受け取りますが、要素の計算済みスタイルが変更されたときにはコールバックを受け取りません。sticky クラスはページの読み込み時にすでに宣言されていることを思い出してください。

将来的には、要素の計算済みスタイルの変更を監視するために、Mutation Observer の「スタイル ミューテーション オブザーバー」拡張機能が役立つ可能性があります。position: sticky