CSS 深入探索 - 矩陣完美的自訂捲軸

自訂捲軸十分罕見,主要是因為捲軸是網站上幾乎無法設定樣式的元素之一 (我指的是日期挑選器)。您可以使用 JavaScript 自行建構,但這麼做成本高昂、精確度低,而且可能會造成延遲。在本文中,我們將利用一些非傳統的 CSS 矩陣來建構自訂捲軸,在捲動時不需要任何 JavaScript,只需要一些設定程式碼即可。

TL;DR

你不關心小事嗎?您只想查看 Nyan cat 示範並取得程式庫嗎?您可以在我們的 GitHub 存放區中找到這段示範程式碼。

LAM;WRA (長篇和數學;無論如何都會讀)

我們先前曾建立了視差捲動器 (您有閱讀這篇文章嗎?它真的很棒,值得你花時間瞭解!使用 CSS 3D 轉換功能將元素推回,元素移動速度會比實際捲動速度較慢

重點回顧

首先,讓我們回顧一下視差捲軸的運作方式。

如動畫所示,我們在 3D 空間中沿著 Z 軸將元素推向「後方」,藉此產生視差效果。捲動文件實際上是沿著 Y 軸進行轉譯。因此,如果我們向下捲動 100 像素,每個元素都會向上移動 100 像素。這項設定適用於所有元素,即使是「較遠」的元素也一樣。但由於這些元素離相機較遠,因此觀察到的螢幕上移動幅度會小於 100 像素,進而產生所需的視差效果。

當然,將元素移回空間也會使其顯示尺寸變小,我們會透過將元素縮放回來來修正。我們在建構視差捲動器時已算出確切的數學運算,因此不會重複所有細節。

步驟 0:我們想做什麼?

捲軸。我們將建構的就是這類應用程式。但您是否曾經認真思考過他們的職責?我絕對沒有。捲軸列可用來顯示目前可見的內容數量,以及讀者瀏覽進度。如果您向下捲動,捲軸也會跟著捲動,表示您正在往下捲動。如果所有內容都符合檢視區範圍,捲軸通常會隱藏。如果內容的高度是可視區域的 2 倍,捲軸就會填滿可視區域的 ½ 高度。內容高度是可視區域的 3 倍,會將捲軸縮放至可視區域的 ⅓ 等等。您會發現這類模式。此外,您也可以透過點選並拖曳捲軸的方式,加快瀏覽網站的速度,而不需捲動畫面。對於這種不起眼的元素,這實在是相當多的行為。我們來一場一場打吧。

步驟 1:倒車

好的,我們可以使用 CSS 3D 轉換,讓元素移動的速度比捲動速度慢,如視差捲動文章所述。我們也可以反轉方向嗎?事實上,我們可以做到,這也是我們建構完美無瑕的自訂捲軸的做法。為了瞭解這項功能的運作方式,我們需要先介紹幾個 CSS 3D 的基本概念。

如要以數學方式取得任何類型的透視投影,您很可能會使用同質座標。我不會詳細說明這些是什麼,以及為何有效,但您可以將它們視為 3D 座標,再加上一個稱為 w 的第四個座標。除非您想讓透視圖失真,否則這個座標應為 1。我們不需要擔心 w 的詳細資料,因為我們不會使用 1 以外的任何值。因此,從現在開始,所有點都是 4 維向量 [x, y, z, w=1],因此矩陣也必須是 4x4。

您可以透過以下方式,瞭解 CSS 在幕後使用同構座標:使用 matrix3d() 函式在 transform 屬性中定義自己的 4x4 矩陣。matrix3d 會接收 16 個引數 (因為矩陣為 4x4),依序指定每個資料欄。因此,我們可以使用這個函式手動指定旋轉、平移等作業,但這也讓我們可以操弄 w 座標!

我們必須先取得 3D 內容,才能使用 matrix3d(),因為沒有 3D 內容,就不會有任何透視扭曲,也不需要使用同質座標。如要建立 3D 結構定義,我們需要一個包含 perspective 的容器,以及一些可在新建立的 3D 空間中轉換的部分元素。範例

使用 CSS 的透視屬性扭曲 div 的 CSS 程式碼。

CSS 引擎會以以下方式處理透視容器中的元素:

  • 將元素的每個角落 (頂點) 轉換為相對於透視容器的均勻座標 [x,y,z,w]
  • 將所有元素的轉換作業以矩陣形式從右到左套用。
  • 如果透視圖元素可捲動,請套用捲動矩陣。
  • 運用透視矩陣。

捲動矩陣是沿著 y 軸平移。如果我們向下捲動 400 像素,所有元素都需要向上移動 400 像素。透視矩陣是一種矩陣,會將點「拉近」消失點,並在 3D 空間中往後退。這會同時產生兩種效果:讓物體在遠處時看起來較小,並且在經過轉換時「移動速度較慢」。因此,如果元素被推送,翻譯 400 像素會導致元素只在畫面上移動 300 像素。

如要瞭解所有詳細資訊,請參閱 CSS 轉換算繪模型的規格,但為了方便說明,我簡化了上述演算法。

我們的方塊位於透視容器內,perspective 屬性的值為 p,並假設容器可捲動並往下捲動 n 個像素。

透視矩陣乘以捲動矩陣乘以元素轉換矩陣,等於四乘四的單位矩陣,其中第四列第三欄為負一除以 p,乘以四乘四的單位矩陣,其中第二列第四欄為負 n,乘以元素轉換矩陣。

第一個矩陣是透視矩陣,第二個矩陣是捲動矩陣。回顧一下:捲動矩陣的工作是在我們向下捲動時,讓元素向上移動,因此會出現負號。

但是,我們希望捲軸的相反元素,也就是當捲動畫面時,向下移動向下。在以下這個領域中,我們有可以運用的技巧:反轉方塊邊角的 w 座標。如果 w 座標為 -1,則所有轉譯都會以相反方向生效。那該怎麼做呢?CSS 引擎會負責將方塊的邊角轉換為同質座標,並將 w 設為 1。matrix3d() 大展身手的機會來了!

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

這個矩陣只會否定 w。因此,當 CSS 引擎將每個邊角都轉換為 [x,y,z,1] 形式的向量時,矩陣會將其轉換為 [x,y,z,-1]

四乘四的單位矩陣,其中第四列第三欄為負一除以 p,乘以四乘四的單位矩陣,其中第二列第四欄為負 n,再乘以四乘四的單位矩陣,其中第四列第四欄為負一,再乘以四維向量 x、y、z、1,等於四乘四的單位矩陣,其中第四列第三欄為負一除以 p,再減去第二列第四欄的 n,並在第四列第四欄減去 1,等於四維向量 x、y、z、減去 z 除以 p 減去 1。

我列出了中間步驟,以顯示元素轉換矩陣的效果。如果您不熟悉矩陣運算,也沒關係。值得注意的是,在最後一行中,我們會將捲動偏移量 n 加到 y 座標,而不是減去。如果向下捲動,元素會向下轉譯。

不過,如果我們只將這個矩陣放入範例中,系統就不會顯示該元素。這是因為 CSS 規格規定,任何 w < 0 的頂點都會阻擋元素算繪。此外,由於我們的 Z 座標為 0,p 為 1,因此 w 會是 -1。

幸運的是,我們可以選擇 z 的值!為確保最後 w 為 1,我們需要將 z 設為 -2。

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

瞧,我們的方塊回來了

步驟 2:讓它移動

我們的方塊現在已出現,而且看起來就像沒有任何轉換一樣。目前無法捲動透視容器,因此無法查看,但我們知道元素在捲動時會往「其他方向」。那麼,我們來讓容器捲動吧!我們可以新增一個占用空間的間距元素:

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

接著,請捲動方塊!紅色方塊向下移動。

步驟 3:指定大小

頁面會在頁面向下捲動時向下移動。這就是最困難的部分。接下來,我們需要將其樣式設為類似捲軸,並讓其更具互動性。

捲軸通常由「指標」和「軌道」組成,但軌道不一定會顯示。縮圖的高度與可見內容的多寡成正比。

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight 是可捲動元素的高度,scroller.scrollHeight 則是可捲動內容的總高度。scrollerHeight/scroller.scrollHeight 是可見內容的比例。拇指覆蓋的垂直空間比例應等於可見內容的比例:

當且僅當拇指圖示點樣式點樣高度除以捲動器高度等於捲動器高度除以捲動器點樣捲動高度時,拇指圖示點樣式點樣高度除以捲動器高度才會等於捲動器高度除以捲動器點樣捲動高度。
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

拇指大小看起來沒問題,但移動速度過快。這裡是我們可以從視差捲軸中擷取技巧的地方。如果我們將元素移到更後方,則在捲動時會移動得更慢。我們可以向上放大,以修正尺寸錯誤。但我們應該將其推遲多久?來試試看吧,你也猜到,就是數學!我保證這是最後一次。

重要的資訊是,我們希望在向下捲動時,手指圖示的底部邊緣能與可捲動元素的底部邊緣對齊。換句話說,如果我們捲動 scroller.scrollHeight - scroller.height 像素,我們希望拇指能以 scroller.height - thumb.height 進行轉譯。對於捲動器的每個像素,我們希望使用拇指移動像素的一小部分:

係數等於捲軸點高度減去捲軸點高度,除以捲軸點捲動高度減去捲軸點高度。

這就是縮放比例。接下來,我們需要將縮放比例轉換為沿著 z 軸的平移,這項作業在視差捲動文章中已完成。根據規格中的相關章節:縮放比例係數等於 p/(p − z)。我們可以解出這個 z 方程式,找出我們需要沿著 z 軸平移拇指的距離。但請注意,由於 w 座標的錯誤,我們需要沿著 z 轉譯額外的 -2px。另外請注意,元素的轉換會由右至左套用,也就是說,特殊矩陣前面的所有翻譯都不會反轉,但特殊矩陣後面的所有翻譯都會反轉!讓我們將這項資訊編碼!

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

我們有捲軸!它只是一個 DOM 元素,我們可以任意設定樣式。從無障礙設計的角度來看,讓拇指回應點選和拖曳動作非常重要,因為許多使用者都習慣以這種方式與捲軸互動。為了避免這篇網誌文章變得更長,我不會解釋這部分的詳細資訊。如要進一步瞭解如何執行這項操作,請參閱程式庫程式碼

iOS 呢?

啊,iOS Safari 是我的老朋友。與視差捲動效果一樣,我們在這裡遇到問題。由於我們是在元素上捲動,因此需要指定 -webkit-overflow-scrolling: touch,但這會導致 3D 扁平化,且整個捲動效果都會停止運作。我們透過偵測 iOS Safari 並採用 position: sticky 做為解決方法,藉此在視差捲動器中解決這個問題,在這裡我們執行相同步驟。請參閱視差文章,瞭解如何重新整理記憶體。

那瀏覽器捲軸呢?

在某些系統上,我們必須處理永久的本機捲軸。過去,捲軸無法隱藏 (除非使用非標準的擬造選取器)。因此,我們必須使用一些 (不含數學) 駭客技巧來隱藏它。我們會使用 overflow-x: hidden 將捲動元素包裝在容器中,並讓捲動元素的寬度大於容器。現在瀏覽器原本的捲軸不會再顯示

金融服務業

將所有內容整合後,我們現在可以建構一個完美的自訂捲軸,就像 Nyan 貓示範中的那個一樣。

如果您看不到 Nyan 貓,表示您遇到了我們在建構此示範時發現並記錄的錯誤 (點選大拇指即可顯示 Nyan 貓)。Chrome 非常擅長避免不必要的作業,例如繪製或以動畫形式呈現畫面外的內容可惜的是,我們的矩陣清除了,讓 Chrome 認為 Nyan cat GIF 實際上不在畫面上顯示。希望很快就能解決這個問題。

就是這樣這項工作相當繁重。我很高興你能讀完整篇文章。這種做法可以達到這個目的,可能不值得這麼做,但除非自訂捲軸是體驗中不可或缺的一環。但好消息是 這種情況不會發生自訂捲軸的難度如此高,顯示 CSS 方面仍有待改進之處。不過請放心! 日後,HoudiniAnimationWorklet 將可讓這類完美無縫的捲動連結效果變得更簡單。