CSS Deep-Dive - 利用 matrix3d() 实现完美帧效果自定义滚动条

自定义滚动条非常罕见,这主要是因为滚动条是网络上非常不固定的一小部分(我看着您,日期选择器)。您可以使用 JavaScript 构建自己的代码,但这种方式不仅成本高昂、保真度低,而且可能会造成延迟。在本文中,我们将利用一些非常规的 CSS 矩阵来构建自定义滚动条,该滚动条在滚动时无需任何 JavaScript,只需一些设置代码。

要点

你不关心小事?如果您只想查看 Nyan cat 演示并获取该库,您可以在我们的 GitHub 代码库中找到演示版代码。

LAM;WRA(长篇幅,数学格式;无论如何都会读)

不久之前,我们构建了视差滚动条(您读过那篇文章吗?非常棒,值得您花时间学习!)。使用 CSS 3D 转换将元素推回原位后,元素的移动速度会比实际滚动速度

回顾

我们先回顾一下视差滚动条的工作原理。

如该动画中所示,我们通过沿 Z 轴在 3D 空间中向后推动元素来实现视差效果。滚动文档实际上是沿 Y 轴的平移。因此,如果我们向下滚动(例如 100 像素),每个元素将向上平移 100 像素。这适用于所有元素,包括“靠后”的元素。但是,由于它们离镜头较远,它们在屏幕上观察到的移动将小于 100 像素,从而产生所需的视差效果。

当然,将元素移回空间后也会使其看起来更小,我们可以通过放大元素来纠正此问题。我们在构建视差滚动条时计算出确切的数学原理,因此我不会重复所有细节。

第 0 步:我们想要做什么?

滚动条。这就是我们要构建的内容。但你有没有认真过来做这些事情呢?我当然没有。滚动条指示当前可见的可用内容量,以及您作为读者的进度如果您向下滚动,滚动条也会随之滚动,表示您正在浏览到最后。如果所有内容都可适合视口,则滚动条通常会隐藏。 如果内容具有视口高度的 2 倍,则滚动条将填充视口高度的 1⁄2。相当于视口高度 3 倍的内容会将滚动条缩放至视口高度的 1⁄3,以此类推。您可以看到相应的模式。您还可以点击并拖动滚动条(而不是滚动)以更快地浏览网站。对于像这样不显眼的元素来说,其行为量出乎意料。一起战斗吧。

第 1 步:颠倒顺序

好的,我们可以使用 CSS 3D 转换使元素的移动速度慢于滚动速度,如视差滚动文章中所述。我们能否反过来?事实证明,我们可以通过这种方式构建 与帧完美相称的自定义滚动条要了解其工作原理,我们需要先了解一些 CSS 3D 基础知识

若要从数学角度获得任何类型的透视投影,您最后很可能需要使用同构坐标。我不会详细介绍它们是什么以及它们为何有效,但您可以将它们视为具有另一个名为 w 的附加第四个坐标的 3D 坐标。除非您希望透视失真,否则此坐标应为 1。我们无需担心 w 的细节,因为我们不使用 1 以外的任何其他值。因此,从现在开始,所有点都是四维矢量 [x, y, z, w=1],因此矩阵也需要是 4x4。

在您使用 matrix3d() 函数在转换属性中定义自己的 4x4 矩阵时,您会看到 CSS 在后台使用同构坐标。matrix3d 接受 16 个参数(因为矩阵为 4x4),依次指定一列。因此,我们可以使用此函数手动指定旋转、平移等。但它还可以弄乱这个 w 坐标!

在使用 matrix3d() 之前,我们需要一个 3D 上下文,因为如果没有 3D 上下文,就不存在任何透视失真,也不需要同构坐标。如需创建 3D 上下文,我们需要一个包含 perspective 的容器和一些可以在新创建的 3D 空间中转换的元素。例如:

一段使用 CSS 的透视属性扭曲 div 的 CSS 代码。

透视图容器内的元素由 CSS 引擎处理,如下所示:

  • 将元素的每个角(顶点)转换为基于透视容器的同构坐标 [x,y,z,w]
  • 从右到左以矩阵形式应用元素的所有转换。
  • 如果透视元素可滚动,则应用滚动矩阵。
  • 应用透视矩阵。

滚动矩阵是沿 y 轴的平移操作。如果我们将元素向下滚动 400 像素,则需要将所有元素向上移动 400 像素。透视矩阵是一个矩阵,它会将点在 3D 空间中越靠后的位置“拉”越靠近消失点。这样既可以实现在较远位置缩小内容显得变小的效果,又可以使内容在翻译时“移动较慢”。因此,如果某个元素被推回,400px 的平移将会导致该元素在屏幕上仅移动 300px。

如果您想了解所有详细信息,请参阅有关 CSS 转换渲染模型的spec,但就本文而言,我简化了上面的算法。

我们的框位于透视容器内,该容器的 perspective 属性值为 p,并且我们假设该容器可滚动,向下滚动 n 像素。

透视矩阵乘以滚动矩阵乘以元素转换矩阵,等于四乘四单位矩阵,第四行第三列负一过 p 乘以四乘以四,第二行第四列的减号 n 乘以元素转换矩阵。

第一个矩阵是透视矩阵,第二个矩阵是滚动矩阵。回顾一下:滚动矩阵的作用是在向下滚动时使元素向上移动,因此为负号。

但对于滚动条,我们希望相反,我们希望元素在向下滚动时向下滚动。我们可以运用以下技巧:反转方框各个角的 w 坐标。如果 w 坐标为 -1,则所有转换都将在相反的方向上生效。那么我们该怎么做呢?CSS 引擎负责将 Box 的角转换为同构坐标,并将 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 等于四维向量 x,y,z,1 等于四维向量 x,y,z,1 等于四维向量 x,y,z,1 等于四维向量 x,y,z,1 等于四行四列 n 与第四行 n 中的减一对 n 相等。

我列出了一个中间步骤,用于显示元素转换矩阵的效果。如果您对矩阵数学不太了解,没关系。尤里卡时刻是,在最后一行中,我们最终将滚动偏移 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。对于滚动条的每个像素,我们希望拇指移动 1/1 个像素:

系数等于滚动条点高度减去拇指点高度等于滚动条点滚动高度值减去滚动条点高度。

这是我们的调整系数。现在,我们需要将缩放比例转换为沿 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 cat 演示中的滚动条一样。

如果您看不到 Nyan 猫,则表示您在构建此演示时遇到了我们找到并提交的 bug(点击拇指图标即可让 Nyan 猫出现)。Chrome 非常擅长避免不必要的工作,例如为屏幕外的内容绘制或添加动画效果。坏消息是,我们的矩阵恶作剧让 Chrome 认为彩虹猫 GIF 实际上不在屏幕内。 希望此问题能尽快得到解决。

没问题。工作量非常大。感谢你们阅读了整篇文章要实现这一点,这是一项真正的技巧,可能很少值得为此付出努力,除非自定义滚动条是体验的重要组成部分。知道这是可能的,我很高兴,不是吗?创建自定义滚动条并非易事,这表明在 CSS 方面需要完成一些工作。但不必担心! 将来,HoudiniAnimationWorklet 会让此类滚动链接效果变得更加完美。