使用滚动捕捉事件

CSS 滚动捕捉模块定义了两个滚动捕捉事件scrollsnapchangingscrollsnapchange。它们可以响应浏览器分别确定新的滚动捕捉目标待定和选中的情况来运行 JavaScript。

本指南概述了这些事件,并提供了完整的示例。

事件概述

滚动捕捉事件设置在包含潜在滚动捕捉目标的滚动容器上。

  • 当浏览器确定当前滚动操作结束时将选择一个新的滚动捕捉目标时,会触发 scrollsnapchanging 事件。这个目标是待定的滚动捕捉目标。具体来说,该事件在滚动操作期间,每当用户移动到新的潜在捕捉目标上时触发。虽然 scrollsnapchanging 事件在每次滚动操作中可能会触发多次,但对于一个跨越多个捕捉目标的滚动操作,它并不会在所有潜在的捕捉目标上都触发。相反,它只为滚动最终可能停靠的最后一个目标触发。

  • 当滚动操作结束且选择了新的滚动捕捉目标时,会触发 scrollsnapchange 事件。具体来说,该事件在滚动操作完成时触发,但前提是选择了新的捕捉目标。该事件在 scrollend 事件触发之前触发。

让我们看一个展示这两个事件实际作用的例子(你将在本文后面看到它是如何构建的)。

试着上下滚动方框列表。

  • 试着在不释放滚动操作的情况下缓慢地上下滚动容器。例如,在触摸屏设备或触控板上拖动手指,或者按住滚动条上的鼠标按钮并移动鼠标。当你移动过方框时,它们应该会变成深灰色,当你移开时又会恢复正常。这就是 scrollsnapchanging 事件的作用。
  • 现在尝试释放滚动操作;离你滚动位置最近的方框应该会动画变为紫色,文字变为白色。这个动画发生在 scrollsnapchange 事件触发时。
  • 最后,尝试快速滚动。例如,在屏幕上用力滑动手指,以便在开始靠近滚动容器更下方的目标之前,滚动过几个潜在的目标。你应该只在滚动开始减速时看到一次 scrollsnapchanging 事件触发,然后 scrollsnapchange 事件触发,选中的捕捉目标变为紫色。

SnapEvent 事件对象

以上两个事件都共享 SnapEvent 事件对象。它有两个对滚动捕捉事件工作方式至关重要的属性:

  • snapTargetBlock 返回事件触发时在块方向上捕捉到的元素的引用,如果滚动捕捉只发生在行内方向,因此在块方向上没有元素被捕捉到,则返回 null
  • snapTargetInline 返回事件触发时在行内方向上捕捉到的元素的引用,如果滚动捕捉只发生在块方向,因此在行内方向上没有元素被捕捉到,则返回 null

这些属性使事件处理函数能够报告已经捕捉到的元素(对于 scrollsnapchange)或如果滚动操作现在结束将会捕捉到的元素(对于 scrollsnapchanging)——无论是一维还是二维的。然后,你可以用任何你想要的方式操作这些元素,例如通过它们的 style 属性直接设置样式,或者给它们设置在样式表中定义了样式的类等。

与 CSS scroll-snap-type 的关系

SnapEvent 上可用的属性值直接对应于滚动容器上设置的 scroll-snap-type CSS 属性的值。

  • 如果捕捉轴指定为 block(或在当前书写模式下等同于 block 的物理轴值),则只有 snapTargetBlock 返回元素引用。
  • 如果捕捉轴指定为 inline(或在当前书写模式下等同于 inline 的物理轴值),则只有 snapTargetInline 返回元素引用。
  • 如果捕捉轴指定为 both,则 snapTargetBlocksnapTargetInline 都返回元素引用。

处理一维滚动条

如果你处理的是水平滚动条,且内容具有水平的 writing-mode,那么当捕捉到的元素改变时,只有事件对象的 snapTargetInline 属性会改变;如果内容具有垂直的 writing-mode,则只有 snapTargetBlock 属性会改变。

相反,如果你处理的是垂直滚动条,且内容具有水平的 writing-mode,那么当捕捉到的元素改变时,只有 snapTargetBlock 属性会改变;如果内容具有垂直的 writing-mode,则只有 snapTargetInline 属性会改变。

在这两种情况下,这两个属性中不变的那个将返回 null

让我们看一个代码片段,展示一个典型的一维滚动捕捉事件处理函数。

js
scrollingElem.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.className = "select-section";
});

在这个片段中,一个 scrollsnapchange 处理函数被设置在一个块方向滚动的容器元素上,捕捉目标出现在其中。当事件触发时,我们在 snapTargetBlock 元素上设置一个 select-section 类,这可以用来为一个新选中的捕捉目标设置样式,使其看起来像是被选中了(例如,通过动画)。

处理二维滚动条

如果你处理的是水平垂直滚动条,代码会变得更复杂。这是因为 snapTargetBlock 属性 snapTargetInline 属性值都会返回一个元素引用(两者都不会返回 null),并且根据你滚动的方向和内容的 writing-mode,其中一个或另一个的值会改变。

  • 如果滚动条水平滚动,且内容具有水平的 writing-mode,那么当捕捉到的元素改变时,snapTargetInline 属性会改变;如果内容具有垂直的 writing-mode,则 snapTargetBlock 属性会改变。
  • 如果滚动条垂直滚动,且内容具有水平的 writing-mode,那么当捕捉到的元素改变时,snapTargetBlock 属性会改变;如果内容具有垂直的 writing-mode,则 snapTargetInline 属性会改变。

为了处理这种情况,你很可能需要跟踪是 snapTargetBlock 还是 snapTargetInline 元素发生了变化。让我们看一个例子。

js
const prevState = {
  snapTargetInline: "s1",
  snapTargetBlock: "s1",
};

scrollingElem.addEventListener("scrollsnapchange", (event) => {
  if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) {
    console.log(
      `The container was scrolled in the block direction to element ${event.snapTargetBlock.id}`,
    );
  }

  if (!(prevState.snapTargetInline === event.snapTargetInline.id)) {
    console.log(
      `The container was scrolled in the block direction to element ${event.snapTargetBlock.id}`,
    );
  }

  prevState.snapTargetBlock = event.snapTargetBlock.id;
  prevState.snapTargetInline = event.snapTargetInline.id;
});

在这个片段中,我们首先定义一个对象(prevState),它存储了前一个 snapTargetBlocksnapTargetInline 元素的 ID。

在事件处理函数中,我们使用 if 语句来测试:

  • prevState.snapTargetBlock 的 ID 是否等于当前 event.snapTargetBlock 元素的 ID。
  • prevState.snapTargetInline 的 ID 是否等于当前 event.snapTargetInline 元素的 ID。

如果值不同,就意味着滚动条在该方向(块或行内)上被滚动了,我们向控制台输出一条消息来表明这一点。在实际的例子中,你可能会以某种方式为捕捉到的元素设置样式,以表明它已被捕捉到。

然后我们更新 prevState.snapTargetBlockprevState.snapTargetInline 的值,为事件处理函数下一次运行做准备。

在本文的剩余部分,我们将看几个完整的滚动捕捉事件示例,你可以在每个部分的末尾的实时渲染版本中进行体验。

一维滚动条示例

这个示例展示了一个垂直滚动的 <main> 元素,其中包含多个浅灰色的 <section> 元素,它们都是滚动捕捉目标。当一个新的捕捉目标处于待定状态时,它会变成深灰色。当一个新的捕捉目标被选中时,它会平滑地动画变为紫色,文字变为白色。如果之前有另一个捕捉目标被选中,它会平滑地动画变回灰色,文字变为黑色。

HTML

该示例的 HTML 只有一个 <main> 元素。我们稍后将使用 JavaScript 动态添加 <section> 元素,以节省页面空间。

html
<main></main>

CSS

在 CSS 中,我们首先给 <main> 元素一个粗黑色的 border 和固定的 widthheight。我们将其 overflow 值设置为 scroll,这样溢出的内容将被隐藏并可以滚动到,并将 scroll-snap-type 设置为 block mandatory,这样只有块方向上的捕捉目标将总是被捕捉到。

css
main {
  border: 3px solid black;
  width: 250px;
  height: 450px;
  overflow: scroll;
  scroll-snap-type: block mandatory;
}

每个 <section> 元素都被赋予了 50pxmargin,以分隔开 <section> 元素,使滚动捕捉行为更加明显。然后我们将 scroll-snap-align 设置为 center,以指定我们想要捕捉到每个捕捉目标的中心。最后,我们应用一个 transition,以便在捕捉目标被选中或待定时,平滑地动画到和从所应用的样式变化。

css
section {
  margin: 50px auto;
  scroll-snap-align: center;
  transition: 0.5s ease;
}

上述的样式变化将通过 JavaScript 应用到 <section> 元素的类上来实现。select-section 类将用于表示选中——它会设置紫色的背景和白色的文本颜色。pending 类将用于表示待定的捕捉目标选择——它会将目标选择的背景色变为深灰色。

css
.pending {
  background-color: #cccccc;
}

.select-section {
  background: purple;
  color: white;
}

JavaScript

在 JavaScript 中,我们首先获取对 <main> 元素的引用,并定义要生成的 <section> 元素的数量(本例中为 21 个)以及一个用于开始计数的变量。然后我们使用 while 循环来生成 <section> 元素,给每个元素一个子 h2,其文本为 Section 加上 n 的当前值。

js
const mainElem = document.querySelector("main");
const sectionCount = 21;
let n = 1;

while (n <= sectionCount) {
  mainElem.innerHTML += `
    <section>
      <h2>Section ${n}</h2>
    </section>
  `;
  n++;
}

现在来看 scrollsnapchanging 事件处理函数。当 <main> 元素的子元素(即任何 <section> 元素)成为一个待定的捕捉目标选择时,我们:

  1. 检查之前是否有元素应用了 pending 类,如果有,则移除它。这样做是为了只有当前的待定目标被赋予 pending 类并变为深灰色。我们不希望之前待定但现在不再待定的目标保留该样式。
  2. snapTargetBlock 属性引用的元素(这将是其中一个 <section> 元素)添加 pending 类,使其变为深灰色。
js
mainElem.addEventListener("scrollsnapchanging", (event) => {
  const previousPending = document.querySelector(".pending");
  if (previousPending) {
    previousPending.classList.remove("pending");
  }

  event.snapTargetBlock.classList.add("pending");
});

注意: 对于这个演示,我们不需要担心 snapTargetInline 事件对象属性——我们只在垂直方向上滚动,并且演示使用的是水平书写模式,因此只有 snapTargetBlock 的值会改变。在这种情况下,snapTargetInline 将始终返回 null

当滚动操作结束,并且一个 <section> 元素实际被选为捕捉目标时,scrollsnapchange 事件处理函数会触发。这个函数会:

  1. 检查之前是否已选中一个捕捉目标——即,是否之前有一个元素应用了 select-section 类。如果有,我们移除它。
  2. select-section 类应用到 snapTargetBlock 属性引用的 <section> 元素上,这样刚刚被选中的捕捉目标就会应用选中动画。
js
mainElem.addEventListener("scrollsnapchange", (event) => {
  const currentlySnapped = document.querySelector(".select-section");
  if (currentlySnapped) {
    currentlySnapped.classList.remove("select-section");
  }

  event.snapTargetBlock.classList.add("select-section");
});

结果

尝试上下滚动滚动容器,并观察上述行为。

二维滚动条示例

这个示例与前一个类似,不同之处在于它展示了一个水平垂直滚动的 <main> 元素,其中包含多个浅灰色的 <section> 元素,它们都是捕捉目标。

该示例的 HTML 与前一个示例相同——只有一个 <main> 元素。

CSS

这个示例的 CSS 与前一个示例的 CSS 类似。最显著的不同如下。

首先让我们看看 <main> 元素的样式。我们希望 <section> 元素以网格形式布局,所以我们使用 CSS 网格布局来指定我们希望它们显示为七列,使用 grid-template-columns 值为 repeat(7, 1fr)。我们还通过在 <main> 元素上设置 paddinggap 来指定 <section> 元素周围的空间,而不是在 <section> 元素上设置 margin

最后,因为我们在这个示例中要在两个方向上滚动,我们将 scroll-snap-type 设置为 both mandatory,这样块方向行内方向的捕捉目标都将总是被捕捉到。

css
main {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  padding: 100px;
  gap: 50px;
  overflow: scroll;
  border: 3px solid black;
  width: 350px;
  height: 350px;

  scroll-snap-type: both mandatory;
}

接下来,我们将在这个示例中使用 CSS 动画而不是过渡。这导致了更复杂的代码,但能够对所应用的动画进行更精细的控制。

我们首先定义用于表示捕捉目标选择已作出或待定的类。select-sectiondeselect-section 类将应用关键帧动画来表示选中或取消选中。pending 类将用于表示待定的捕捉目标选择(它会为选择应用一个深灰色的背景,与前一个示例中一样)。

@keyframes 分别从灰色背景和黑色(默认)文本颜色动画到紫色背景和白色文本颜色,以及反向动画。后一个动画与第一个有些不同——它还使用 opacity 来创建淡出/淡入效果。

css
.select-section {
  animation: select 0.8s ease forwards;
}

.deselect-section {
  animation: deselect 0.8s ease forwards;
}

.pending {
  background-color: #cccccc;
}

@keyframes select {
  from {
    background: #eeeeee;
    color: black;
  }

  to {
    background: purple;
    color: white;
  }
}

@keyframes deselect {
  0% {
    background: purple;
    color: white;
    opacity: 1;
  }

  80% {
    background: #eeeeee;
    color: black;
    opacity: 0.1;
  }

  100% {
    background: #eeeeee;
    color: black;
    opacity: 1;
  }
}

JavaScript

在 JavaScript 中,我们以与前一个示例相同的方式开始,不同的是这次我们生成 49 个 <section> 元素,并且我们给每个元素一个 ID,格式为 s 加上 n 的当前值,以便之后跟踪它们。通过我们上面指定的 CSS 网格布局,我们有七列七行的 <section> 元素。

js
const mainElem = document.querySelector("main");
const sectionCount = 49;
let n = 1;

while (n <= sectionCount) {
  mainElem.innerHTML += `
    <section id="s${n}">
      <h2>Section ${n}</h2>
    </section>
  `;
  n++;
}

接下来我们指定一个名为 prevState 的对象,它允许我们随时跟踪先前选中的捕捉目标——它的属性存储了先前行内和块捕捉目标的 ID。这对于每次事件处理函数触发时,判断我们是需要为新的块目标还是新的行内目标设置样式非常重要。

js
const prevState = {
  snapTargetInline: "s1",
  snapTargetBlock: "s1",
};

例如,假设滚动容器被滚动,使得新的 SnapEvent.snapTargetBlock 元素的 ID 发生了变化(它不等于存储在 prevState.snapTargetBlock 中的 ID),但新的 SnapEvent.snapTargetInline 元素的 ID 仍然与存储在 prevState.snapTargetInline 中的 ID 相同。这意味着我们已经在块方向上移动到了一个新的捕捉目标,所以我们应该为 SnapEvent.snapTargetBlock 设置样式,但我们没有在行内方向上移动到新的捕捉目标,所以我们不应该为 SnapEvent.snapTargetInline 设置样式。

这次,我们将首先解释 scrollsnapchange 事件处理函数。在这个函数中,我们:

  1. 首先确保先前选中的 <section> 元素捕捉目标(通过存在 select-section 类来表示)应用了 deselect-section 类,以便它显示取消选中的动画。如果之前没有捕捉目标被选中,我们将 select-section 类应用到 DOM 中的第一个 <section>,以便它在页面首次加载时显示为选中状态。
  2. 比较先前选中的捕捉目标 ID 与新选中的捕捉目标 ID,对于块行内选择都进行比较。如果它们不同,这表示选择已经改变,所以我们对相应的捕捉目标应用 select-section 类以在视觉上表明这一点。
  3. 更新 prevState.snapTargetBlockprevState.snapTargetInline,使其等于刚刚被选中的滚动捕捉目标的 ID,这样当下一次事件触发时,它们就成为了前一次的选择。
js
mainElem.addEventListener("scrollsnapchange", (event) => {
  if (document.querySelector(".select-section")) {
    document.querySelector(".select-section").className = "deselect-section";
  } else {
    document.querySelector("section").className = "select-section";
  }

  if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) {
    event.snapTargetBlock.className = "select-section";
  }

  if (!(prevState.snapTargetInline === event.snapTargetInline.id)) {
    event.snapTargetInline.className = "select-section";
  }

  prevState.snapTargetBlock = event.snapTargetBlock.id;
  prevState.snapTargetInline = event.snapTargetInline.id;
});

scrollsnapchanging 事件处理函数触发时,我们:

  1. 从先前应用了 pending 类的元素上移除该类,这样只有当前的待定目标会被赋予 pending 类并变为深灰色。
  2. 给当前的待定元素添加 pending 类,使其变为深灰色,但前提是它尚未应用 select-section 类——我们希望先前选中的目标保持紫色的选中样式,直到新的目标实际被选中。我们还在 if 语句中包含了一个额外的检查,以确保我们只为行内或块待定捕捉目标设置样式,具体取决于哪个发生了变化。同样,我们在每种情况下都比较了前一个捕捉目标和当前捕捉目标。
js
mainElem.addEventListener("scrollsnapchanging", (event) => {
  const previousPending = document.querySelector(".pending");
  if (previousPending) {
    previousPending.className = "";
  }

  if (
    !(event.snapTargetBlock.className === "select-section") &&
    !(prevState.snapTargetBlock === event.snapTargetBlock.id)
  ) {
    event.snapTargetBlock.className = "pending";
  }

  if (
    !(event.snapTargetInline.className === "select-section") &&
    !(prevState.snapTargetInline === event.snapTargetInline.id)
  ) {
    event.snapTargetInline.className = "pending";
  }
});

结果

尝试在滚动容器中水平和垂直滚动,并观察上述行为。

DocumentWindow 上的滚动捕捉事件

在本文中,我们已经介绍了在 Element 接口上触发的滚动捕捉事件,但同样的事件也会在 DocumentWindow 对象上触发。请参阅:

这些事件的工作方式与 Element 版本非常相似,只是整个 HTML 文档必须被设置为滚动捕捉容器(即 scroll-snap-type 被设置在 <html> 元素上)。

例如,如果我们采用一个与我们上面看过的例子类似的例子,其中我们有一个包含重要内容的 <main> 元素:

html
<main>
  <!-- Significant content -->
</main>

<main> 元素可以通过一组 CSS 属性组合,变成一个滚动容器,例如:

css
main {
  width: 250px;
  height: 450px;
  overflow: scroll;
}

然后,你可以通过在 <html> 元素上指定 scroll-snap-type 属性,来在滚动内容上实现滚动捕捉行为。

css
html {
  scroll-snap-type: block mandatory;
}

下面的 JavaScript 代码片段将导致当 <main> 元素的子元素成为新选中的捕捉目标时,在 HTML 文档上触发 scrollsnapchange 事件。在处理函数中,我们在 SnapEvent.snapTargetBlock 引用的子元素上设置一个 selected 类,该类可以在事件触发时用于为其设置样式,使其看起来像是被选中了(例如,通过动画)。

js
document.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.classList.add("selected");
});

我们也可以在 Window 上触发事件,以实现相同的功能:

js
window.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.classList.add("selected");
});

另见