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 貓 GIF 實際上是在畫面外。希望這個問題能盡快解決。

就是這樣。這項工作相當繁重。我很高興你能讀完整篇文章。這項技巧確實很難實作,而且除非自訂捲軸是體驗中不可或缺的部分,否則這項技巧的效益可能不大。但很高興知道這麼做是可行的,對吧?自訂捲軸的難度如此高,顯示 CSS 方面仍有待改進之處。不過請放心! 日後,HoudiniAnimationWorklet 將可讓這類完美無縫的捲動連結效果變得更簡單。