CSS 位置:固定式事件

TL;DR

祕訣:下一個應用程式中不一定會用到 scroll 事件。使用 IntersectionObserver, 我會示範在 position:sticky 元素修正完畢或停止勾選時觸發自訂事件。除了 捲動事件監聽器此外,還有精彩的示範可以證明:

觀看示範 | 來源

隆重推出 sticky-change 活動

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

以下範例將修正<div class="sticky"> 位於其父項容器的頂端:

.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;
});

示範會使用 這個事件會在解決陰影後形成標頭。該元件也會更新

在示範中,套用的效果不會有捲動事件。

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

網頁結構。
網頁結構。

我們來解一下這些名稱 請繼續保持:

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

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

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

新增 dumby DOM 以決定捲動位置

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

未顯示 Sentinel 元素
隱藏的密封器元素。

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

  1. 向下捲動 - 標題在頂端組合交叉線交叉時變成固定式 容器的頂端
  2. 向下捲動 - 標題接近底部 區段及其底部密封件會跨越容器的頂部。
  3. 向上捲動 - 標題會在頂端貼紙捲動時退出固定模式 所有內容。
  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;
}

設定交叉觀察器

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

魔法醬是 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 類別已在網頁載入時宣告。

日後, 「樣式變動觀察器」 適用於 Mutation Observer 的擴充功能,可用於觀察 元素的計算樣式 position: sticky