响应式 3D 形状的现状

Avatar of Ana Tudor
Ana Tudor 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

有些人可能知道,我一直很喜欢 3D 几何图形。这意味着我会倾向于使用 CSS 3D 变换来创建各种几何形状。我已经创建了大量此类演示,您可以在 CodePen 上查看

因此,我经常被问到是否可以使用例如 % 值而不是我的演示通常使用的 em 值来创建响应式 3D 形状。答案比简单的“是”或“否”复杂一些,所以我认为将其写成一篇文章是个好主意。让我们深入了解一下!

使用 % 值创建响应式 3D 形状

这是可能的,对吧?是的,但它会使事情复杂化。为了说明这一点,让我们举个例子。假设我们想创建一个立方体。

在我们真正开始着手之前,有两件事需要说明。

  1. 如果您需要复习一下如何使用 CSS 3D 变换创建简单的非响应式 长方体,您可以查看 这篇旧文章,一直看到条形分布部分。它(甚至非常详细地)解释了许多与 CSS 变换工作原理相关的概念,以及使用简洁紧凑的代码构建长方体的过程,我在这里不再赘述这些内容。
  2. 让我们回顾一下 % 值对我们可能希望在元素上设置的一些 CSS 属性的含义。

各种 CSS 属性的 %

对于 widthpaddingmargin% 值相对于我们设置这些属性的元素的父元素的 width

让我们考虑以下情况:我们有一个元素,它是 body 的子元素。在这个元素上,我们设置了 % 值的 widthpaddingmargin

.boo {
  width: 50%;
  padding: 10%;
  margin: 5%;
}

如果我们调整视口大小,使得 body(我们元素的父元素)的 width300px,那么我们的元素的 width150px300px50%),padding30px300px10%),margin15px300px5%),这可以通过检查元素来看到。

检查元素(参见 实时演示)。

为了使这按照我们的预期工作,父元素的 width 不应依赖于其子元素的 width。这是我们应该避免的循环依赖。例如,如果父元素具有 position: absolute,就会发生这种情况。在这种情况下,父元素的 width 似乎是根据我们元素的内容计算出来的,然后我们元素的 widthpaddingmargin 被计算为父元素 width% 值。

对于 height% 值相对于我们设置 height 属性的元素的父元素的 height。让我们考虑一下我们的元素是 body 的子元素,我们已将其 height 设置为覆盖整个视口。

body { height: 100vh; }

.boo { height: 50%; }

如果我们调整视口大小,使得 body(我们元素的父元素)的 height300px,那么我们元素的 height150px300px50%),这可以通过检查元素来看到。

检查元素(参见 实时演示)。

为了使这按照我们的预期工作,父元素的 height 不应依赖于其子元素的 height。这是我们应该避免的另一种循环依赖。但与 width 的情况不同,每当我们没有显式地为父元素设置 height 时,就会遇到这种情况。

对于 transform,如果 translate() / translateX() / translateY() / translateZ() / translate3d() 函数使用 % 值,那么此值相对于元素沿该轴的大小。请注意,这些是元素局部坐标系的轴,它与元素本身一起在 3D 空间中进行变换。它的 x 轴始终指向元素的右侧,它的 y 轴始终指向元素的底部,它的 z 轴始终指向元素的前面。

translate(50%)translate(50%, 0)translateX(50%)translate3d(50%, 0, 0) 的值会使元素沿 x 轴移动其自身 width 的一半。这意味着元素的垂直中线最终会位于其初始右侧边缘的位置。在 此实时测试 中,灰色框表示元素处于其初始位置,未应用任何变换。橙色框表示应用了 transformtranslateX(50%) 的元素。

在元素上应用 transformtranslateX(50%)

translate(0, 50%)translateY(50%)translate3d(0, 50%, 0) 的值会使元素沿 y 轴移动其自身 height 的一半。这意味着元素的水平中线最终会位于其初始底部边缘的位置。在 此实时测试 中,灰色框表示元素处于其初始位置,未应用任何变换。橙色框表示应用了 transformtranslateY(50%) 的元素。

在元素上应用 transformtranslateY(50%)

translateZ(50%)translate3d(0, 0, 50%) 的值会怎样?好吧,这里没有异常,它使元素沿 z 轴移动其沿该轴大小的一半。但是元素沿 z 轴的大小是多少?我们肯定没有在任何地方设置它,但所有元素都是平的,包含在一个平面上,因此,它们沿其 z 轴的大小始终为 0。这意味着使用 % 值沿 z 轴进行平移 **没有任何作用**,因为 0 的任何 % 仍然是 0。如果我们检查 translateZ(50%) 的计算值,我们可以看到它是 none

% 值工作方式的后果

第一个后果是 **使用 % 值,我们无法创建其 width 依赖于视口 height 的元素,并且创建其 height 依赖于视口 width 的元素也不是那么简单**。

我们想要创建一个立方体,所以让我们看看如何创建一个边长为视口 width20% 的正方形。首先,我们确保我们的正方形元素的 widthheight 都等于 0。我们可以显式地设置这些值,或者我们可以绝对定位我们的正方形,在本例中我们选择这样做,因为它在稍后创建立方体时非常方便。然后,我们在这个正方形元素上设置 padding 为我们想要立方体边长 20% 的一半 20%/2 = 10%——这使得每个 padding 值(padding-toppadding-rightpadding-bottompadding-left)都成为视口 width10%

$edge-len: 20%;

.square {
  position: absolute;
  padding: .5*$edge-len;
}

padding-left10%padding-right10% 加起来,在水平方向上使正方形的宽度为视口 width20%。将 padding-top10%padding-bottom10% 加起来,在垂直方向上使正方形的高度为视口 width20%。结果是一个随视口 width 缩放的正方形。

我们得到了一个随视口 width 缩放的正方形(参见 实时测试)。

这看起来不错,但我们必须记住,我们将元素的 widthheight 设置为零,这意味着我们不能在其上设置 box-sizing: border-box,并且在这种情况下,添加一个不会增加我们正方形在屏幕上占据的总空间的 border 需要使用内嵌 box-shadow 进行模拟,或者使用 padding 中减去 border-width 并结合使用 calc()

$edge-len: 20%;
$bw: .5em; // border width

.square {
  position: absolute;
  border: solid $bw currentColor;
  padding: calc(#{.5*$edge-len} - #{$bw});
}

此外,使用 background-clip 获得很酷的效果,例如 backgroundborder 之间透明的空间 也变得更加复杂。对于 简单的间距 border,我们需要使用 calc()padding 中减去 border-widthbox-shadow 的扩展。更不用说我们还需要一个等于 box-shadow 扩展的 margin 来修复定位。

$edge-len: 20%;
$bw: .5em; // border width
$s: .75em; // shadow spread

.square {
  position: absolute;
  margin: $s;
  border: solid $bw transparent;
  padding: calc(#{.5*$edge-len} - #{$bw + $s});
  box-shadow: 0 0 0 $s currentColor;
  background: #e18728 padding-box;
}

对于 双间距 border,我们别无选择,只能使用伪元素。

$edge-len: 20%;
$bo: .25em; // outer border width
$so: .5em; // outer gap width
$bi: 1em; // inner border width
$si: .75em; // inner gap width
$inner-len: calc(100% - #{2*($so + $si + $bo + $bi)});

.square {
  position: absolute;
  padding: .5*$edge-len;
  box-shadow: inset 0 0 0 $bo;

  &:before {
    position: absolute;
    border: solid $bi currentColor;
    padding: $si;
    width: $inner-len; height: $inner-len;
    transform: translate(-50%, - 50%);
    background: #e18728 content-box;
    content: '';
  }
}

第二个后果是 **沿其 z 轴(向前和向后)平移此类元素,使其平移量依赖于其至少一个视口相关维度(widthheight),也不是那么简单**。

我们之前已经确定,我们不能使用%值来沿元素的z轴平移一个依赖于视口尺寸的距离。但是,使用变换,我们有不止一种方法可以将元素带到某个位置。

例如,rotate(53deg) translate(5em)等价于translate(3em, 4em) rotate(53deg),这可以在这个在线测试中看到。

因此,虽然我们不能使用带有%值的translateZ()将我们的正方形向前移动其width的一半,但我们可以想出一个不使用translateZ()函数的变换链,但仍然将我们的元素放在我们想要的位置。

让我们考虑一下上面得到的没有边框的正方形,假设我们想将其向前移动其边长的一半。我们有多种方法可以做到这一点。

例如,我们可以先绕其y轴旋转-90°。这会旋转我们的正方形及其坐标系,使其现在朝左,其x轴指向我们,离开屏幕。

接下来,我们沿其x轴平移50%x轴现在指向我们,所以这意味着我们的正方形向前移动了其边长的一半。我们正方形的垂直中线处于我们想要的位置,但我们是从右侧看到我们的正方形,而我们想从前面看到它。

这就是为什么我们的最后一步是反转初始旋转。所以我们围绕其y轴将正方形旋转90° - 这使它再次面向我们,同时其x轴再次指向右侧。

这三个步骤导致了rotateY(-90deg) translateX(50%) rotateY(90deg)变换链,它们由以下演示(点击播放)说明

查看CodePen上的Ana Tudor (@thebabydino) 编写的 不使用translateZ将正方形向前移动其边长的一半 - 方法1

另一个产生相同结果的变换链是rotateX(90deg) translateY(50%) rotateX(-90deg)。就像前面一样,它使用了旋转元素(以及与其一起旋转的局部坐标系)的技巧,使我们使另一个轴(在这种情况下为y轴)指向z轴在旋转之前的方向,沿此其他轴(y轴)平移,然后最终反转第一次旋转。我们可以在下面的CodePen中看到这一点。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 不使用translateZ将正方形向前移动其边长的一半 - 方法2

请注意,前面的两个演示在Edge中不起作用

使用%值创建响应式立方体

我们从以下结构开始

<div class='cube'>
  <div class='cube--face'></div>
  <div class='cube--face'></div>
  <div class='cube--face'></div>
  <div class='cube--face'></div>
  <div class='cube--face'></div>
  <div class='cube--face'></div>
</div>

我们可以使用像Haml或Slim或其他任何预处理器来简化这一点。

.cube
  - 6.times do
    .cube__face

我这样做是因为我不喜欢多次编写相同的内容,并且,使用像Haml这样的东西,我甚至没有引入一个在循环中没有使用的循环变量。这主要取决于个人喜好,因为我们没有很多代码。只有7个HTML元素:.cube元素及其6个面子元素1

我们将body元素作为我们的场景,因此我们使其覆盖整个视口height并在其上设置perspective。请记住,这是一个任意决定,只是为了简化事情。我们也可以将我们的场景视为任何其width以某种方式依赖于视口width的元素。

然后,我们将元素绝对定位,确保.cube位于场景的正中间,并且它具有perspective-style: preserve-3d,以便其子元素也可以在3D中进行转换。

body {
  height: 100vh;
  perspective: 20em;
}

div { position: absolute; }

.cube {
  top: 50%; left: 50%;
  transform-style: preserve-3d;
}

下一步将像以前一样使用padding调整立方体面的大小,对吧?好吧,如果我们这样做,我们会发现padding实际上被评估为0px

在开发工具中查看计算出的样式:我们的%值填充被评估为0px

这是因为它们不再是body的子元素,而是.cube元素的子元素,而.cube元素是一个绝对定位的0x0元素。

我们可以通过使用padding技巧调整立方体元素的大小,然后使其子元素具有相同的大小来解决此问题。

$edge-len: 16%;

.cube {
  /* previous styles */
  margin: -.5*$edge-len;
  padding: .5*$edge-len;

  &__face {
    top: 0; right: 0; bottom: 0; left: 0;
    background: orange;
  }
}

我们还在.cube元素上添加了一个负margin,以便使其中点位于场景的正中间,而不是其左上角。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 使用%值创建响应式立方体 - 步骤1

这是一个开始。所有面元素都具有我们希望它们具有的尺寸,并且它们位于我们想要的位置。现在让我们看看如何以3D方式转换6个面元素,使它们形成一个立方体。

我们首先将6个面分成两类:4个侧面和2个底面。我们将侧面视为右侧、背面、左侧和前面,将底面视为顶部和底部。这是一个任意决定。我们可以将侧面视为底部、前面、顶部和背面,并将底面视为左侧和右侧。我们将DOM顺序中的前4个面元素视为侧面,其他元素视为底面。

接下来我们要做的就是选择我们想要沿其进行平移的轴,即我们想要指向立方体上(前面、底部、右侧……)我们想要放置面元素的位置的轴。它不能是z轴,因为我们需要在这里使用%值,所以我们选择x轴。再次提醒,这完全是任意的——事实上,在你读完本文后,你可以尝试使用y轴代替。

我们取第一个面元素。在没有应用任何变换的情况下,其x轴指向右侧。所以我们让它成为立方体右侧的面。我们沿其x轴的正方向将其平移50%。这使得其垂直中线与立方体右侧面的垂直中线重合。然后我们围绕其y轴将其旋转90°以将其放置到立方体上的所需位置(面向右侧)。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 在立方体的右侧放置面元素

第一个.cube__face元素的CSS为

.cube__face:first-child {
  transform: translateX(50%) rotateY(90deg);
}

为了保持一致性,我们可以用以下等效形式编写它(围绕任何轴旋转没有任何影响)

.cube__face:nth-child(1) {
  transform: rotateY(0deg) translateX(50%) rotateY(90deg);
}

我们继续第二个面,我们将它放在立方体的背面。这意味着我们首先围绕其y轴将其旋转90°,以便其x指向该面(这意味着指向背面)。然后我们沿其x轴的正方向将其平移50%。现在其垂直中线与立方体背面的垂直中线重合。最后一步是再次围绕其y轴将其旋转90°,使其位于立方体上的所需位置(面向背面)。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 在立方体的背面放置面元素

我们写下这个面的transform

.cube__face:nth-child(2) {
  transform: rotateY(90deg) translateX(50%) rotateY(90deg);
}

现在轮到第三个面定位了。这次,在立方体的左侧。我们首先围绕其y轴将其旋转180°,以便我们可以使其x轴指向左侧,即我们想要放置它的位置。然后我们沿此x轴的正方向将其平移50%。这将它的垂直中线放在立方体左侧面的垂直中线上。最后,我们围绕其y轴再旋转90°,使其正确地位于立方体上(面向右侧)。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 在立方体的左侧放置面元素

因此,在这种情况下,变换链为

.cube__face:nth-child(3) {
  transform: rotateY(180deg) translateX(50%) rotateY(90deg);
}

现在我们到了最后一个侧面!我们将这个放在立方体的前面。就像前面的三个一样,我们首先需要做的就是围绕其y轴旋转它,以使其x轴指向前方。我们之前已经看到,可以通过围绕y轴旋转-90°来实现这一点。但是-90°旋转将元素置于与270°旋转相同的位置,因此,出于一致性考虑,我们在这里使用270°。围绕y轴旋转270°后,我们沿其x轴的正方向将元素平移50%。最后,我们围绕其y轴再旋转90°,使其向前而不是向左。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 在立方体的前面放置面元素

最后一个侧面的变换链为

.cube__face:nth-child(4) {
  transform: rotateY(270deg) translateX(50%) rotateY(90deg);
}

现在我们可以继续处理底面了。要将第一个底面放在立方体的底部,第一步是将其旋转,使其x轴指向该方向(向下)。那是围绕其z轴旋转90°。下一步是沿此现在指向下方的x轴的正方向将其平移50%,将其中线带到立方体的底面上。最后,我们围绕其y轴将其旋转90°,使其朝下。

查看CodePen上的Ana Tudor (@thebabydino) 编写的 在立方体的底部放置面元素

这意味着用于定位此面的CSS为

.cube__face(5) {
  transform: rotateZ(90deg) translateX(50%) rotateY(50%);
}

我们来到了最后一个面,需要将其放置在立方体的顶部。为此,我们首先旋转立方体,使其 `x` 轴指向向上——这相当于围绕其 `z` 轴旋转 `-90°`。接下来,我们沿着现在指向向上的 `x` 轴,将其沿正方向平移 `50%`。这样,我们就将该面的中线放置在了立方体顶面的中线上了。最后一步是将其绕 `y` 轴旋转 `90°`,使其正面朝上。

查看 CodePen 上 Ana Tudor (@thebabydino) 编写的 将面元素放置在立方体顶部的示例

.cube__face(6) {
  transform: rotateZ(-90deg) translateX(50%) rotateY(50%);
}

将所有内容组合在一起,我们可以开始注意到一些规律。

.cube__face(1) { /* 1 = 0 + 1 */
  transform: rotateY(0deg) /* 0° = 0*90° */
             translateX(50%) rotateY(50%);
}
.cube__face(2) { /* 2 = 1 + 1 */
  transform: rotateY(90deg) /* 90° = 1*90° */
             translateX(50%) rotateY(50%);
}
.cube__face(3) { /* 3 = 2 + 1 */
  transform: rotateY(90deg) /* 180° = 2*90° */
             translateX(50%) rotateY(50%);
}
.cube__face(4) { /* 4 = 3 + 1 */
  transform: rotateY(270deg) /* 270° = 3*90° */
             translateX(50%) rotateY(50%);
}
.cube__face(5) { /* 5 = 4 + 1 */
  transform: rotateZ(90deg) /* 90° = 1*90° = ((-1)^4)*90° */
             translateX(50%) rotateY(50%);
}
.cube__face(6) { /* 6 = 5 + 1 */
  transform: rotateZ(-90deg) /* -90° = -1*90° = ((-1)^5)*90° */
             translateX(50%) rotateY(50%);
}

首先要注意的是,变换链中的最后两个变换函数始终相同。接下来,我们可以为侧面和底面推导出一个通用公式。对于侧面,第一个变换函数为 `rotateY($i*90deg)`,其中 `$i` 是面的索引。对于底面,第一个变换函数为 `rotate(pow(-1, $i)*90deg)`。这意味着我们可以将其全部压缩到一个循环中,如下所示

@for $i from 0 to 6 {
  .cube__face:nth-child(#{$i + 1}) {
    transform:
      if($i < 4, rotateY($i*90deg),
                 rotateZ(pow(-1, $i)*90deg))
      translateX(50%) rotateY(90deg);
  }
}

将以上代码添加到之前的内容中,结果可以在以下 CodePen 中看到。

查看 CodePen 上 Ana Tudor (@thebabydino) 编写的 使用百分比值创建响应式立方体 - 步骤 2

看起来好像没有任何变化,但是,如果我们将面的透明度设置为半透明,并为其添加某种轮廓,并对 `.cube` 元素的旋转进行动画处理,那么很明显我们现在拥有了一个 3D 形状。甚至还是一个响应式的 3D 形状!

使用百分比值创建的响应式立方体(查看 在线演示)。

我们还可以稍微调整一下,使场景不再是 `body` 元素。

查看 CodePen 上 Ana Tudor (@thebabydino) 编写的 使用百分比值创建响应式立方体 - 步骤 4(场景 != body)

现在看起来还不错。对于非响应式的情况,将一个面放置在立方体上所需的变换链的长度为 `2`——一个旋转和一个 `translateZ()`。

@for $i from 0 to 6 {
  .cube__face:nth-child(#{$i + 1}) {
    transform:
      if($i < 4, rotateY($i*90deg),
                 rotateX(pow(-1, $i)*90deg))
      translateZ(.5*$edge-len);
  }
}

对于使用百分比值创建的响应式立方体,其长度为 `3`——一个旋转,然后是 `translateX(50%)` 和 `rotateY(50%)`。总共只有 `6` 个额外的变换函数——我们可以接受。

问题是,我们想要做的越多,事情就会变得越复杂。

更复杂的示例

假设我们想要创建一个魔方。它是由多个立方体组成的,每个维度上有 `3` 个立方体。总共有 `3*3*3 = 27` 个立方体。这意味着我们有以下结构

.assembly
  - 27.times do
    .cube
      - 6.times do
        .cube__face

每个立方体的创建方式几乎与上面相同,但有一些区别。

最重要的是,现在立方体的父元素不再是场景,而是 `.assembly` 元素。它与场景中的其他所有元素一样,都是绝对定位的,因此默认情况下,其大小为 `0x0`。因此在这种情况下,我们需要根据场景的宽度来调整 `.assembly` 的大小,然后使 `.cube` 元素及其面的大小与 assembly 相同。

$edge-len: 8%;

div {
  position: absolute;
  transform-style: preserve-3d;
}

.assembly {
  top: 50%; left: 50%;
  margin: -.5*$edge-len;
  padding: .5*$edge-len;
  transform: rotateX(-30deg) rotateY(30deg);
}

[class*='cube'] {
  top: 0; right: 0; bottom: 0; left: 0;
}

到目前为止,我们有 `27` 个立方体,所有立方体都位于场景的中央。

查看 CodePen 上 Ana Tudor (@thebabydino) 编写的 使用百分比值创建响应式立方体组合 - 步骤 0

为了创建类似魔方的结构,我们需要将这些立方体沿着空间的三个维度进行分布。在每个维度上,我们都有 `3` 个立方体。第一个立方体沿轴的负方向平移其边长,第二个立方体保持原位,第三个立方体沿轴的正方向平移其边长。这意味着这些平移的值为 `-100%`、`0` 和 `100%`。这三个值可以分别写成 `-1*100%`、`0*100%` 和 `1*100%`。由于我们在每个轴上的索引为 `0`、`1` 和 `2`,这意味着沿着每个轴的平移是索引减去 `1`,然后乘以 `100%`。

因此,我们的基本分布代码如下所示

@for $i from 0 to 3 { // along the x axis
  $x: ($i - 1)*100%;

  @for $j from 0 to 3 { // along the y axis
    $y: ($j - 1)*100%;

    @for $k from 0 to 3 { // along the z axis
      $z: ($k - 1)*100%;
      $idx: $i*3*3 + $j*3 + $k + 1;

      .cube:nth-child(#{$idx}) {
        transform: translate3d($x, $y, $z);
      }
    }
  }
}

问题是,当使用百分比值时,此代码 没有任何作用。如果我们去掉 `$z` 并只使用 `translate($x, $y)`,那么我们就可以看到立方体 沿着前两个维度进行分布

这种沿着 `x` 和 `y` 轴的分布在 Chrome 和 Edge 中看起来完美无缺。Firefox 存在 3D 顺序问题,但我们可以通过在静态情况下使用 `z-index` 轻松修复这些问题。**更新**:此 3D 顺序错误现在已在 Firefox 55+ 中修复。

沿着 x 和 y 轴的分布,预期结果以及我们在 Chrome 和 Edge 中获得的结果(左)与 Firefox 54- 的结果(右)

根据我们旋转组合的方式,`x` 轴指向后面,我们希望后面的立方体位于前面的立方体后面。因此,`$i` 值越高,`z-index` 应该越低——这意味着我们在计算值时用减号将其加上。`y` 轴指向下方,我们希望上方的立方体位于下方的立方体上方。因此,`$j` 值越高,`z-index` 应该越低——这意味着我们也用减号将其加上。`z` 轴指向右侧,我们希望左侧的立方体位于右侧立方体的后面。这意味着 `$k` 值越高,`z-index` 越高,因此我们需要用加号将其加上。我们得到以下结果

z-index: $k - $i - $j;

现在在 Firefox 中也看起来不错了。

查看 CodePen 上 Ana Tudor (@thebabydino) 编写的 使用百分比值创建响应式立方体组合 - 步骤 3

但是第三个维度呢?好吧,我们可以像以前一样:绕每个立方体的 `y` 轴旋转,使其 `x` 轴指向其 `z` 轴最初指向的方向,然后沿着此 `x` 轴平移 `$z`。这意味着我们的变换链将变为

transform: translate($x, $y) rotateY(90deg) translateX($z);

这解决了问题。

响应式立方体组合(查看 在线演示)。

我们得到了一个漂亮、响应式、魔方结构。但这是以每个立方体增加 `2` 个额外的变换函数为代价的。我们有 `27` 个立方体,因此总共有 `54` 个额外的变换函数。我们的 CSS 变得更大很多了。

使用视口单位创建响应式 3D 形状

这应该更容易吧?是的,但是……取决于我们选择的单位以及我们可能希望如何对 3D 形状进行动画处理,我们可能会遇到错误。

我更喜欢使用视口单位而不是百分比,因为它没有同样的限制和复杂性。我可以根据视口高度来调整形状的大小,而不仅仅是宽度。甚至可以根据较小的视口尺寸来调整!并且创建形状的方式与使用 `px` 或 `em` 时相同,无需向链中添加额外的变换函数。

但是,我们需要了解一些浏览器问题。

Edge 尚不支持 `vmax`

这并没有让我感到困扰,因为我很少需要 `vmax` 大小的形状,而且在极少数情况下,我能够以某种方式解决这个问题。

当前解决方案

当想要创建一个大小取决于最大视口尺寸的正方形时,想到的第一个解决方法是将其 `width` 和 `height` 设置为具有 `vw` 单位的值,并将其 `min-width` 和 `min-height` 设置为具有 `vh` 单位的相同值。

.boo {
  width: 20vw; height: 20vw;
  min-width: 20vh; min-height: 20vh;
}

它运行得非常完美。

测试 `vmax` 模拟(查看 在线演示)。

我们可以通过 使用 Sass 使其更易于维护。

$edge-len-landscape: 20vw;
$edge-len-portrait: $edge-len-landscape*1vh/1vw;

.boo {
  width: $edge-len-landscape;
  height: $edge-len-landscape;
  min-width: $edge-len-portrait;
  min-height: $edge-len-portrait;
}

现在,如果我们想要更改边长对最大尺寸的依赖程度,我们只需要更改 `$edge-len-landscape` 的值,而无需担心可能忘记在某个地方更改某个值。

不幸的是,当我们想要在 3D 中进行操作时,这还不够。为了将面放置在中间,我们需要一个取决于边长的负边距。为了创建一个立方体,我们需要将每个面平移一个取决于边长的量。但是哪一个?纵向还是横向的?

我们有两个今天可行的选择:我们可以使用方向(或纵横比)媒体查询,或者我们可以像以前一样在 `translate()` 函数内使用百分比值。

所以让我们回到我们的立方体示例。

使用**第一种方法**,我们将上述模拟与我们在非响应式情况下应用的常规变换链结合起来,并为变换部分添加一个媒体查询

$edge-len-landscape: 20vw;
$edge-len-portrait: $edge-len-landscape*1vh/1vw;

.cube__face {
  margin: -.5*$edge-len-landscape;
  width: $edge-len-landscape;
  height: $edge-len-landscape;
  min-width: $edge-len-portrait;
  min-height: $edge-len-portrait;

  @for $i from 0 to 6 {
    &:nth-child(#{$i + 1}) {
      transform: if($i < 4, rotateY($i*90deg), 
          rotateX(pow(-1, $i)*90deg))
        translateZ(.5*$edge-len-landscape);
    }
  }

  @media (orientation: portrait) {
    margin: -.5*$edge-len-portrait;

    @for $i from 0 to 6 {
      &:nth-child(#{$i + 1}) {
        transform: if($i < 4, rotateY($i*90deg), 
            rotateX(pow(-1, $i)*90deg))
          translateZ(.5*$edge-len-portrait);
      }
    }
  }
}

这看起来有点太多了,所以如果我们使用一个 mixin 可能更好。

$edge-len: 20vw;

@mixin faces($l: $edge-len) {
  margin: -.5*$l;
  width: $l; height: $l;

  @for $i from 0 to 6 {
    &:nth-child(#{$i + 1}) {
        transform:
          if($i < 4, rotateY($i*90deg),
            rotateX(pow(-1, $i)*90deg))
          translateZ(.5*$l);
      }
  }
}

.cube__face {
  @include faces();

  @media (orientation: portrait) {
    @include faces($edge-len*1vh/1vw);
  }
}

以上代码的结果是一个根据最大视口尺寸进行响应的立方体。

根据最大视口尺寸自适应的立方体,不使用vmax(参见在线演示)。

使用**第二种方法**,我们将%值的平移与模拟vmax的尺寸调整结合使用。除了在完全使用百分比时所使用的transform链之外,我们还需要在开头添加一个translate(-50%, -50%)来补偿依赖于边的负margin,以便我们的正方形从场景中间开始定位。

如果这还不够清楚,请考虑以下情况:我们将所有元素都绝对定位,并将.cube置于场景的中间(top: 50%; left: 50%;),然后调整其面的大小(使用vmax模拟方法)。但这会导致我们的面的角位于场景的中间,这不是我们想要的。

参见CodePen上的Ana Tudor(@thebabydino)创作的绝对定位和相对大小 #0

即使我们不知道要应用哪个负边距,因为我们不知道是处于纵向模式还是横向模式,我们仍然可以通过translate(-50%, -50%)来解决这个问题——这将元素向左平移其width的一半(无论它是什么,我们不需要知道)并向上平移其height的一半。这就是我们让立方体的面从正确位置开始的方式。

参见CodePen上的Ana Tudor(@thebabydino)创作的绝对定位和相对大小 #1

现在我们必须将面分布在.cube上,但是如果我们简单地添加来自所有%情况的transform链,那么它们会覆盖transform: translate(-50%, -50%),并且.cube不再正确定位。

参见CodePen上的Ana Tudor(@thebabydino)创作的根据最大视口尺寸调整大小的立方体(无vmax!)——定位失败

这就是为什么我们需要在每个面的其他transform函数之前链接translate(-50%, -50%)的原因。

@for $i from 0 to 6 {
  .cube__face:nth-child(#{$i + 1}) {
    transform: translate(-50%, -50%)
      if($i < 4, rotateY($i*90deg),
        rotateZ(pow(-1, $i)*90deg))
      translateX(50%) rotateY(50deg);
  }
}

这样做可以得到我们想要的结果。

根据最大视口尺寸自适应的立方体,不使用vmax(参见在线演示)。

未来的解决方案

目前,CSS 变量在 Edge 中被列为“开发中”,因此,一旦它们得到支持,我们应该能够开始使用它们,从而获得比上述两种方法更简洁的解决方法。基本思路是为边长使用一个 CSS 变量。我们最初将其值设置为vw,然后在媒体查询中将其值更改为vh即可解决问题

.boo {
  --edge-len: 20vw;

  width: var(--edge-len);
  height: var(--edge-len);
}

@media (orientation: portrait) {
  .boo { --edge-len: 20vh; }
}

对于创建 3D 形状,事情变得有点棘手,因为某些属性需要的值不等于边长,而是根据边长计算得出的。例如,对于我们的立方体,我们需要在面上设置一个等于边长一半的margin,然后还需要将面平移边长的一半。解决方案?calc()来救援!

.cube__face {
  --edge-len: 20vw;

  margin: calc(-.5*var(--edge-len));
  width: var(--edge-len); height: var(--edge-len);

  @for $i from 0 to 6 {
    &:nth-child(#{$i + 1}) {
      transform: if($i < 4, rotateY($i*90deg),
          rotateX(pow(-1, $i)*90deg))
        translateZ(calc(.5*var(--edge-len)));
    }
  }
}

@media (orientation: portrait) {
  .cube__face { --edge-len: 20vh; }
}

参见CodePen上的Ana Tudor(@thebabydino)创作的使用 CSS 变量根据最大视口尺寸调整大小的立方体

这在 Chrome 和 Firefox 中运行良好,但当变量也出现在 Edge 中时,它会在 Edge 中运行吗?好吧,Edge 中还有一个问题。当用于沿xy轴的平移值时,calc()可在平移函数内使用,但不能用于沿z轴的平移值。如果我们在translateZ()内或在translate3d()内用于沿z轴的平移值(第三个参数)中使用calc(),则Edge 中变换的计算值最终将为none

想到的第一个解决方法是从另一个 CSS 变量--inradius开始,从中计算--edge-len

.cube__face {
  --inradius: 10vw;
  --edge-len: calc(2*var(--inradius));

  margin: calc(-1*var(--inradius));
  width: var(--edge-len); height: var(--edge-len);

  @for $i from 0 to 6 {
    &:nth-child(#{$i + 1}) {
      transform: if($i < 4, rotateY($i*90deg),
          rotateX(pow(-1, $i)*90deg))
        translateZ(var(--inradius));
    }
  }
}

@media (orientation: portrait) {
  .cube__face { --inradius: 10vh; }
}

立方体及其任何正方形面的内半径都等于边长的一半。

参见CodePen上的Ana Tudor(@thebabydino)创作的使用 CSS 变量根据最大视口尺寸调整大小的立方体 #2

一旦 CSS 变量到达,这应该在 Edge 中运行,但我们必须等到实际发生时才能确定。**更新**:3D 形状在 Edge 15 中有效,尽管面部闪烁的原因与 CSS 变量无关。

在平移函数中使用vmin值在 Edge 中失败

这听起来非常不方便,因为当想要创建响应式 3D 形状时,vmin似乎是首选单位。幸运的是,这有一个方便的解决方法:以vmin设置font-size,然后使用em

参见CodePen上的Ana Tudor(@thebabydino)创作的地球是平的吗?(纯 CSS 3D)

在视口调整大小后,使用视口单位的动画平移在大多数浏览器中失败

如果我们不使用视口单位对平移进行动画,则这不是问题。

但是,这对我来说是最成问题的问题,因为我经常希望将事物动画化,而不是简单地旋转 3D 装配体,而且我还没有找到解决此问题的办法。

考虑以下示例:我们有一个矩形,对其进行动画处理,使其从屏幕左侧移动到右侧(在线演示)。

.boo {
  margin-left: -5em;
  width: 10em; height: 7.5em;
  animation: ani 1s ease-in-out infinite alternate;
}

@keyframes ani {
  to { transform: translate(100vw); }
}

在调整视口大小之前,所有浏览器中的所有内容看起来都很好。

但是,如果我们增加或减少视口width,Chrome 和 Safari 仍然会动画到与之前相同的绝对值。就像100vw值被其px等效值替换了一样。矩形不再动画到视口的右侧,而是如果我们减小了视口width则超出它,如果我们增加了它则到中间的某个点。在这种情况下,用于先前 Edge 问题的font-size技巧无济于事。

Edge 也做了一些奇怪的事情——调整大小后,元素被平移到视口右侧,然后消失,然后闪烁回屏幕上。

Firefox 是唯一正确处理此问题的浏览器。

对于一个简单的 3D 示例,假设我们有一个带有两个相同立方体的 3D 装配体。

<div class='assembly'>
  <div class='cube'>
    <div class='cube__face'></div>
    <!-- 5 more cube faces here -->
  </div>
  <div class='cube'>
    <div class='cube__face'></div>
    <!-- 5 more cube faces here -->
  </div>
</div>

或者,DRY 预处理器版本

.assembly
  - 2.times do
    .cube
      - 6.times do
        .cube__face

它们的边长以vmin单位(好吧,以em单位,但font-size设置为vmin值,以便避免之前提到的 Edge 错误)表示。我们将.assembly放在场景的正中间,然后将立方体向右和向左偏移视口宽度(即25vw)的四分之一。因此,在我们实际在 3D 中执行任何操作之前的基本样式看起来像这样

$edge-len: 16em;
$offset: 25vw;

body {
  height: 100vh;
  perspective: 32em;
}

div {
  position: absolute;
  transform-style: preserve-3d;
}

.assembly { top: 50%; left: 50%; }

.cube {
  &:nth-child(1) { left: -$offset; }
  &:nth-child(2) { left: $offset; }

  &__face {
    margin: -.5*$edge-len;
    width: $edge-len; height: $edge-len;
    background: url($image); /* so we can see it */
  }
}

到目前为止的结果可以在以下 Pen 中看到

参见CodePen上的Ana Tudor(@thebabydino)创作的碰撞的立方体——步骤 #0

从这一点开始,我们可以使用基于索引的符号切换来稍微压缩立方体的偏移量,以便我们可以在循环中设置它们。-$offset = -1*$offset$offset = 1*$offset。此外,-1 = (-1)^1 = (-1)^(0 + 1)1 = (-1)*(-1) = (-1)^2 = (-1)^(1 + 1)。这给了我们

.cube {
  @for $i from 0 to 2 {
    &:nth-child(#{$i + 1}) {
      left: pow(-1, $i + 1)*25vw;
    }
  }
}

面的位置与非响应式情况完全相同。

@for $i from 0 to 6 {
  .cube__face:nth-child(#{$i + 1}) {
    transform:
      if($i < 4, rotateY($i*90deg),
        rotateX(pow(-1, $i)*90deg))
      translateZ(.5*$edge-len);
  }
}

我们现在有两个立方体

参见CodePen上的Ana Tudor(@thebabydino)创作的碰撞的立方体——步骤 #1

下一步是将它们动画化,使它们发生碰撞。这意味着将第一个立方体向右平移(在x轴的正方向)并将第二个立方体向左平移(x轴的负方向)。将它们平移$offset会使它们都位于中间重叠,但这不是我们想要的。我们希望第一个立方体的右面与第二个立方体的左面在中间接触。这意味着它们都必须离中间一个边长的一半。就像我们将它们沿一个方向平移$offset,然后沿相反方向平移边长的一半一样。因此,@keyframes如下所示

.cube {
  animation: move 2s ease-in infinite alternate;

  @for $i from 0 to 2 {
    &:nth-child(#{$i + 1}) {
      left: pow(-1, $i + 1)*25vw;
      animation-name: move#{$i + 1};
    }
  }
}

@keyframes move1 {
  to {
    transform: translateX(calc(1*(#{$offset} - #{.5*$edge-len})));
  }
}
@keyframes move2 {
  to {
    transform: translateX(calc(-1*(#{$offset} - #{.5*$edge-len})));
  }
}

我们可以通过在.cube循环中生成@keyframes来使这段代码更高效

.cube {
  animation: move 2s ease-in infinite alternate;

  @for $i from 0 to 2 {
    &:nth-child(#{$i + 1}) {
      left: pow(-1, $i + 1)*25vw;
      animation-name: move#{$i + 1};
    }

    @at-root {
      @keyframes move#{$i + 1} {
        to {
          transform: translateX(calc(#{pow(-1, $i)}*(#{$offset} - #{.5*$edge-len})));
        }
      }
    }
  }
}

结果在所有浏览器中看起来都很棒。也就是说……直到我们调整视口大小。WebKit 浏览器不会更新关键帧中的平移量以匹配新的视口,Edge 也不会。

在视口调整大小后,视口相关的平移量不会在 WebKit 浏览器和 Edge 中更新(参见在线演示)。

在这种情况下,导致问题变得更复杂的情况是通过截断从一种 3D 形状变形为另一种形状。

参见CodePen上的Ana Tudor(@thebabydino)创作的四面体截断序列(交互式,~响应式)

在这种情况下调整视口大小会导致当我们开始从其顶点截断四面体时打开的三角形面不再正确定位,因为它们在 3D 中的位置也由一个平移值决定,该值取决于四面体的边长(该边长使用视口单位,以便整个 3D 形状随着视口调整大小而缩放)。


1 我见过很多在这些面元素上添加.front.back.left.right.top.bottom类的演示和教程,我个人认为这毫无意义,甚至有害。如果我们在 3D 中旋转整个立方体(我们通常希望这样做),那么.front面从我们的角度来看就不再是正面了,这可能会造成混淆。给它们每个不同的名称会让人分心,让人忘记它们都是相似的实体,我们把哪个放在哪里并不重要——根据我们选择的通用分布公式,第一个按 DOM 顺序的元素可以放在立方体的前面、右边或底部。使用一个通用的与索引相关的公式,我们可以将它们全部在 3D 中定位,这是放弃这些类的另一个非常好的理由。