CSS 位置:固定式事件

重點摘要

密鑰:下一個應用程式中不一定要有 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 套用至元素時。

如要瞭解哪個標頭進入「固定模式」,我們需要一種方法判斷捲動容器的捲動偏移。如此一來,我們就能計算目前顯示的標頭。不過,如果沒有 scroll 事件,這個做法會很棘手 :) 另一個問題是,在修正後,position:sticky 會從版面配置中移除元素。

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

新增 dumby DOM 以決定捲動位置

我們會改用 IntersectionObserver 判斷headers進入及結束固定式模式的時機,而不是 scroll 事件。在每個固定式區段中新增兩個節點 (也稱為孤十字),一個在頂端和底部一個節點,可以作為判斷捲動位置的路徑點。這些標記進入及離開容器時,其瀏覽權限會隨之變更,Intersection Observer 則會觸發回呼。

未顯示 Sentinel 元素
隱藏的智慧型標記元素。

我們需要智慧型尖,涵蓋四個上下捲動的情況:

  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每個 Stinel 都會取得一個 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],因此其回呼會在顯示 Sentinel 時立即觸發。

這個程序與底部密封圖 (.sticky_sentinel--bottom) 類似。系統會建立第二個觀察器,在頁尾穿過捲動容器的底部時觸發。observeFooters 函式會建立 Centinel 節點,並附加至每個區段。觀察器會使用容器底部計算錐形的交集,並判斷它是進入還是離開。這項資訊會決定區段標頭是否固定。

/**
 * 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);
}

這樣就大功告成了!

最終示範

我們建立了自訂事件,在沒有使用 scroll 事件的情況下修正含有 position:sticky 的元素時,並新增捲動效果。

查看示範 | 來源

結論

我經常想知道 IntersectionObserver 是否適合取代某些多年來開發的 scroll 事件型 UI 模式。事實上,答案是不一定。IntersectionObserver API 的語意讓所有內容都難以用到。但如這裡所示 您可以使用這個 API 進行有趣的操作

還有一種方式能偵測樣式變更嗎?

算不上是。我們需要的一種方式是觀察 DOM 元素的樣式變化。遺憾的是,網路平台 API 並未提供可讓您查看樣式變化的影片。

MutationObserver 是邏輯的第一選擇,但對大多數情況而言都無效。舉例來說,在示範中,在元素新增 sticky 類別時會收到回呼,但當元素的運算樣式變更時,則不會收到回呼。提醒您,sticky 類別已在網頁載入時宣告。

日後,適用於變動觀察器的「樣式變動觀察器」擴充功能或許有助於觀察元素計算樣式的變化。position: sticky