CSS position:sticky 事件

要点

秘诀:您的下一个应用中可能不需要 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. Headers - 包含 position:sticky 的每个部分的蓝色标题。
  3. 粘性版块 - 每个内容版块。在粘性标题下方滚动的文本。
  4. “粘滞模式”- 将 position:sticky 应用于元素时。

为了知道哪个标题进入“粘滞模式”,我们需要通过某种方式确定滚动容器的滚动偏移量。这样我们就可以计算当前显示的 header。不过,如果没有 scroll 事件,这样做就非常棘手 :) 另一个问题是,position:sticky 会在元素修复后从布局中移除该元素。

因此,如果没有滚动事件,我们就无法对标题执行布局相关计算

添加 dumby DOM 以确定滚动位置

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

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

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

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

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

该过程与底部标记 (.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 事件的界面模式。事实证明,答案是“是”或“否”。IntersectionObserver API 的语义使得它对所有事情都难以使用。不过,正如我在此处展示的,您可以将其用于一些有趣的技术。

检测样式更改的另一种方法是什么?

不一定。我们需要的是一种观察 DOM 元素样式变化的方法。遗憾的是,在 Web 平台 API 中,没有任何可让您监控样式更改的内容。

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

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