CSS 位置:固定式事件

TL;DR

以下是個秘訣:您可能不需要在下一個應用程式中使用 scroll 事件。我將使用 IntersectionObserver,說明如何在 position:sticky 元素固定或停止固定時觸發自訂事件。而且完全不必使用捲動事件監聽器。甚至還有精彩的示範影片:

查看產品示範 | 原始碼

推出 sticky-change 事件

使用 CSS 固定式位置的其中一項實際限制,就是 未提供可得知資源啟用時間的平台信號。 換句話說,沒有可知道元素何時會變得固定 即可停止黏著

請參考下列範例,該範例會將 <div class="sticky"> 固定在其父項容器頂端 10 個像素的位置:

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

如果瀏覽器能告知元素何時達到該標記,那該有多好?顯然不只我這麼認為。position:sticky 的信號可解鎖多項用途

  1. 在橫幅貼上時套用投射陰影。
  2. 當使用者閱讀您的內容時,記錄 Analytics 匹配即可得知 進度。
  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;
});

示範會在標題固定時,使用這個事件為標題加上陰影。並更新頁面頂端的新標題。

在示範中,效果會在沒有 scrollevent 的情況下套用。

沒有捲動事件的捲動效果?

網頁結構。
網頁結構。

讓我們先來瞭解一些術語,這樣我才能在接下來的文章中提到這些名稱:

  1. 捲動容器:包含「網誌文章」清單的內容區域 (可視區域)。
  2. 標題 - 含有 position:sticky 的每個區段中的藍色標題。
  3. 固定版面:每個內容版面。捲動至 固定式標頭。
  4. 「固定模式」:當 position:sticky 套用至元素時。

如要瞭解哪個標頭會進入「黏性模式」,我們需要設法判斷捲動容器的捲動偏移量。這樣我們就能 來計算目前顯示的標頭。但多虧了 若是沒有 scroll 事件,則難以採取這些做法 :) 另一個問題是 當修正後,position:sticky 會將元素從版面配置中移除。

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

新增 dumby DOM 以決定捲動位置

我們要使用 IntersectionObserver,而不是 scroll 事件 判斷標題進入及結束固定模式的時機。在每個固定區段中新增兩個節點 (又稱為哨兵),一個在頂端,一個在底端,可做為找出捲動位置的路徑點。由於這些 標記進入並離開容器後,它們的可見性會變更, Intersection Observer 會觸發回呼。

未顯示 Sentinel 元素
隱藏的哨兵元素。

我們需要兩個哨兵,涵蓋四種向上和向下捲動的情況:

  1. 向下捲動:當頂端哨兵跨越容器頂端時,標頭會變成固定。
  2. 向下捲動 - 標題接近底部 區段及其底部密封件會跨越容器的頂部。
  3. 向上捲動 - 標題會在頂端貼紙捲動時退出固定模式 所有內容。
  4. 向上捲動header 會變成固定,因為底部哨兵會從頂端返回畫面。

請依照發生順序,查看 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;
}

設定交叉觀察器

交集觀測器以非同步方式觀察 也就是目標元素以及文件可視區域或父項容器在這個範例中 我們要觀察到與父項容器交集

魔法醬是 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],因此其回呼會盡快觸發 才能看到 Sentinel 視窗

這套程序與底部壓桿 (.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 類別已在網頁載入時宣告。

日後,如果要觀察元素計算樣式的變更,可能就需要使用「樣式變異觀察器」擴充功能。position: sticky