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

自定义滚动条非常罕见,这主要是因为滚动条是 Web 上几乎无法设置样式的剩余部分之一(日期选择器就是其中之一)。您可以使用 JavaScript 自行构建,但这样做成本高、保真度低,并且可能会出现延迟。在本文中,我们将利用一些非常规 CSS 矩阵来构建一个自定义滚动条,在滚动时无需任何 JavaScript,只需一些设置代码即可。

要点

您不介意这些小事吗?您只想查看 Nyan Cat 演示并获取库?您可以在我们的 GitHub 代码库中找到该演示的代码。

LAM;WRA(长篇幅且涉及数学;无论如何都会读)

前段时间,我们构建了一个视差滚动条(您是否阅读了这篇文章?非常好,值得您花时间!通过使用 CSS 3D 转换将元素推回,元素的移动速度比实际滚动速度更慢

回顾

首先,我们来回顾一下平行视差滚动条的运作方式。

如动画所示,我们通过在 3D 空间中沿 Z 轴将元素“向后”推送来实现了视差效果。滚动文档实际上是沿 Y 轴的平移。因此,如果我们向下滚动 100 像素,则每个元素都会向上平移 100 像素。这适用于所有元素,即使是“更远”的元素也是如此。但由于它们离相机更远,因此它们在屏幕上的观察移动距离将小于 100 像素,从而产生所需的视差效果。

当然,将元素向后移动也会使其看起来更小,我们可以通过将元素放大来修正这一点。我们在构建平行滚动条时就已经计算出确切的数学公式,因此我不会重复所有细节。

第 0 步:我们要做什么?

滚动条。我们将构建的就是这样一个应用。但您是否曾真正考虑过他们在做什么?我当然没有。滚动条用于指示当前可见的内容量以及读者已阅读的进度。如果您向下滚动,滚动条也会随之滚动,以指示您正在向下滚动。如果所有内容都适合放入视口,滚动条通常会隐藏。如果内容的高度是视口高度的 2 倍,滚动条会填满视口高度的 ½。如果内容的高度是视口高度的 3 倍,滚动条就会缩放到视口的 1/3 等。您应该能看出规律了。您还可以点击并拖动滚动条,以更快地浏览网站,而无需滚动。对于这样一个不起眼的元素,这是一个令人惊讶的行为量。我们先解决一个问题。

第 1 步:将车辆倒车

好的,我们可以使用 CSS 3D 转换让元素的移动速度低于滚动速度,如“视差滚动”一文中所述。我们还可以反转方向吗?事实证明,我们可以做到,这也是我们构建完美无缺的自定义滚动条的方法。为了了解其运作方式,我们首先需要介绍一些 CSS 3D 基础知识。

如需在数学意义上获得任何类型的透视投影,您最终很可能会使用齐次坐标。我不会详细介绍它们是什么以及它们为何有效,但您可以将它们视为具有第四个坐标(称为 w)的 3D 坐标。除非您想实现透视失真,否则此坐标应为 1。我们无需担心 w 的详细信息,因为我们不会使用除 1 以外的任何值。因此,从现在开始,所有点都是 4 维矢量 [x, y, z, w=1],因此矩阵也需要是 4x4。

您可以通过以下方式了解 CSS 在后台使用齐次坐标:使用 matrix3d() 函数在 transform 属性中定义自己的 4x4 矩阵。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 的转换渲染模型的规范,但为了方便本文的阅读,我简化了上述算法。

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

透视矩阵乘以滚动矩阵乘以元素转换矩阵等于四乘四的单位矩阵(第四行第三列为 -1/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]

第四行第三列为 -1/p 的 4 x 4 单位矩阵乘以第二行第四列为 -n 的 4 x 4 单位矩阵乘以第四行第四列为 -1 的 4 x 4 单位矩阵乘以四维向量 x, y, z, 1 等于第四行第三列为 -1/p、第二行第四列为 -n、第四行第四列为 -1 的 4 x 4 单位矩阵等于四维向量 x, y + n, 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 作为权宜解决方法来解决此问题,在这里我们将执行完全相同的操作。请参阅“Parallax”一文,重温相关知识。

浏览器滚动条怎么样?

在某些系统上,我们必须处理永久的原生滚动条。过去,滚动条无法隐藏(非标准伪类选择器除外)。因此,为了隐藏它,我们必须使用一些(无需数学)黑客技巧。我们使用 overflow-x: hidden 将滚动元素封装在容器中,并使滚动元素的宽度大于容器。浏览器的原生滚动条现在已不在视野范围内。

金融

将所有这些内容整合在一起,我们现在就可以构建一个帧完美的自定义滚动条,就像 Nyan cat 演示中的滚动条一样。

如果您没有看到 Nyan Cat,则表示您遇到了我们在构建此演示时发现并报告的 bug(点击拇指即可让 Nyan Cat 显示)。Chrome 非常擅长避免不必要的工作,例如绘制或为屏幕之外的内容添加动画。坏消息是,我们的矩阵恶作剧会让 Chrome 认为 Nyan Cat GIF 实际上不在屏幕上。希望这个问题能尽快得到解决。

就是这样。这需要付出大量的努力。感谢您阅读完整内容。要想实现此目的,需要使用一些真正的技巧,而且除非自定义滚动条是体验的重要组成部分,否则很少值得这么做。不过,得知可以这样做,您应该会很高兴吧?自定义滚动条如此困难,说明 CSS 方面还有工作要做。不过别担心! 未来,HoudiniAnimationWorklet 将大大简化此类精准到帧的滚动关联效果的实现。