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

自定义滚动条极为罕见,主要是因为滚动条是网络上几乎没有样式的其余元素之一(我看着您,日期选择器)。您可以使用 JavaScript 构建自己的模型,但这样做成本高昂、保真度低,并且可能会有延迟。在本文中,我们将利用一些非常规的 CSS 矩阵来构建自定义滚动条,它在滚动时不需要任何 JavaScript,只需要一些设置代码。

要点

你不关心小事?只想看一下 Nyan cat 演示并获取相应库?您可以在我们的 GitHub 代码库中找到演示版代码。

LAM;WRA(长篇幅和数学格式;仍然会读取)

不久之前,我们开发了视差滚动条(您看过这篇文章吗?非常不错,值得您花时间查看!)通过使用 CSS 3D 转换将元素推回,元素移动速度会比实际滚动速度慢

回顾

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

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

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

第 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 代码,使用 CSS 的 Perspective 属性使 div 失真。

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

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

滚动矩阵是沿 y 轴的平移。如果我们向下滚动 400 像素,所有元素都需要向上移动 400 像素。透视矩阵是一种矩阵,它会将点“拉”得更靠近消失点,并在 3D 空间中离消失点越远。这样既可以在距离较远时放大内容,又能降低翻译速度时的“移动速度”;因此,如果某个元素被推回,400 像素的平移会导致该元素在屏幕上仅移动 300 像素。

如果您想了解所有详细信息,应阅读有关 CSS 转换渲染模型的spec,但为了阅读本文,我简化了上述算法。

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

透视矩阵乘以滚动矩阵乘以元素转换矩阵等于四乘四单位矩阵,其中第四行第三列减去 1 乘以 4 单位矩阵,第二行第四列,再减去 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]

第四行、第三列、四乘四单位矩阵、第四行、第四列和四乘四单位矩阵(第四行、第四列、四维矢量、x、y、z、1 等于四乘四乘积,第四行,减去四行 p 和第三列)- 四乘四单位矩阵 - 第四行,负一维 p 和第三列负一乘 p 的四乘四单位矩阵

我列出了展示元素转换矩阵效果的中间步骤。如果您对矩阵数学不太理解,也没有关系。在最后一行中,我们最终会将滚动偏移量 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 是可见内容的比例。拇指覆盖的垂直空间的比率应与可见内容的比率相等:

当且仅当拇指点样式的点高度等于滚动条高度乘以滚动条高度与滚动条高度时,拇指点样式点的高度与 scrollerHeight 高度相等。
<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 cat 演示中的滚动条。

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

看看成绩吧。这项工作很费劲。感谢你阅读整本书要做到这一点,这确实是个棘手的地方,而且很少值得为此费力,除非自定义滚动条是体验的重要组成部分。但很高兴得知这是有可能实现的,不是吗?自定义滚动条很难实现,这表明 CSS 方面还有待完成的工作。但不必担心! 将来,HoudiniAnimationWorklet 可以更轻松地实现这类与帧完美的滚动链接效果。