CSS 位置:固定式事件

Eric Bidelman

重點摘要

秘密:在下一個應用程式中,您可能不需要使用 scroll 事件。藉由使用 IntersectionObserver,我示範如何在 position:sticky 元素已修正或停止固定時觸發自訂事件。而且無需使用捲動事件監聽器。還可以獲得超棒的示範示範:

查看示範 | 來源

sticky-change 活動簡介

使用 CSS 固定式位置的其中一個實際限制,是並未提供知道屬性何時啟用的平台信號。 也就是說,對於元素是否固定或不再固定,沒有事件可得知。

請參考以下範例,從父項容器頂端修正 <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 進入「固定模式」,我們需要某種方法 決定捲動容器的捲動偏移。這樣我們就能計算目前顯示的 header。不過,在沒有 scroll 事件的情況下執行這類操作相當困難 :) 另一個問題是,position:sticky 會在修正後從版面配置中移除元素。

因此,如果沒有捲動事件,我們就無法在標頭上執行版面配置相關計算

新增 dumby DOM 以決定捲動位置

我們將使用 IntersectionObserver 來判斷headers進入及結束固定模式的時機,而非 scroll 事件。在每個固定式區段中新增兩個節點 (又稱「Sinels」),一個在頂端和底部各一個,可做為判斷捲動位置的路點。這些標記進入及離開容器時,其瀏覽權限會改變,Intersection Observer 會觸發回呼。

未顯示 已將 Sinel 元素
隱藏的寄件元素。

我們需要條紙來涵蓋四個上下捲動的案例:

  1. 向下捲動 - 當 header 的頂部十字型跨越容器頂部時,會變得固定。
  2. 向下捲動 - header 會在固定模式到達區段底部時離開固定模式,而底部傳送器會跨越容器頂端。
  3. 向上捲動 - 當頂端傳送器從頂端捲動回檢視畫面時,header 離開固定模式。
  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 會以非同步方式觀察目標元素和文件可視區域或父項容器的交集變更。在這個範例中,我們會觀察與父項容器相交的交集。

魔法是 IntersectionObserver。每個 sentinel 都會取得 IntersectionObserver,以便觀察其在捲動容器中的交集顯示設定。當 Sendinel 捲動至可見可視區域時,我們知道標頭已固定或已停止固定。同理,當 XML 離開可視區域時。

首先,我為標頭和頁尾傳送器設定觀察器:

/**
 * 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] 設定,因此回呼會在出現,inel 時立即觸發。

這個程序與底部傳送器 (.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 Observer 的「Style Mutation Observer」擴充功能可能有助於觀察元素計算樣式的變化。position: sticky