CSS position:sticky 事件

Eric Bidelman

要点

提示:您的下一个应用中可能不需要 scroll 事件。借助 IntersectionObserver,我将展示如何在 position:sticky 元素固定或停止固定时触发自定义事件。所有这些操作均无需使用滚动监听器。甚至还有一个很棒的演示可以证明这一点:

观看演示 | 来源

隆重推出 sticky-change 活动

使用 CSS 粘性位置的实际限制之一是,它不提供平台信号来判断属性何时处于活动状态。换言之,没有任何事件能够知道元素何时变为粘性,或何时不再具有粘性。

以下示例将 <div class="sticky"> 固定在其父级容器顶部 10px 处:

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

如果浏览器在元素到达该标记时发出提示,不是很好吗? 显然,不是只有我这么认为。position:sticky 的信号可以解锁许多用例

  1. 在横幅上应用阴影。
  2. 当用户阅读您的内容时,记录分析命中以了解其进度。
  3. 当用户滚动页面时,将浮动 TOC widget 更新为当前部分。

鉴于这些用例,我们制定了一个最终目标:创建一个在 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 来确定滚动位置

我们将使用 IntersectionObserver 来确定headers何时进入和退出粘性模式,而不是使用 scroll 事件。在每个粘性版块中添加两个节点(一个在顶部,一个在底部),这些节点将作为确定滚动位置的航点。当这些标记进入和离开容器时,其可见性会发生变化,而 Intersection Observer 也会触发回调。

不显示标记元素
隐藏的标记元素。

我们需要两个标记来涵盖上下滚动的四种情况:

  1. 向下滚动 - 当标头顶部的标记越过容器顶部时,标头会变得粘稠。
  2. 向下滚动 - 在到达部分底部且底部的标记穿过容器顶部时,标题将离开粘滞模式。
  3. 向上滚动 - 当顶部标记从顶部滚动回视图时,标题将退出粘滞模式。
  4. 向上滚动 - 当其底部的标记从顶部返回视图时,标题会变得粘稠。

最好按照 1-4 的发生顺序查看抓屏:

交叉观察者会在哨兵进入/离开滚动容器时触发回调。

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] 进行配置,因此其回调会在 Sentinel 变为可见后立即触发。

该过程与底部标记 (.sticky_sentinel--bottom) 类似。系统会创建第二个观察者,以在页脚穿过滚动容器底部时触发。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 自定义事件和生成标记:

/**
 * @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 事件的界面模式。事实证明,答案是肯定和否。IntersectionObserver API 的语义使得它难以适用于所有情况。不过,正如我所示的那样,您可以将其用于一些有趣的技术。

通过其他方法检测样式变化吗?

不一定。我们需要的是一种观察 DOM 元素的样式变化的方法。遗憾的是,网络平台 API 中没有什么功能可以让您查看样式更改。

MutationObserver 是逻辑首选,但在大多数情况下并不适用。例如,在演示中,当向元素添加 sticky 类时会收到回调,但在元素的计算样式发生更改时会收到回调。回想一下,在网页加载时已声明 sticky 类。

将来,针对 Mutation Observer 的“Style Mutation Observer”扩展程序可能有助于观察元素计算样式的变化。position: sticky