使用滾動捕捉事件

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

另見