使用自定义属性简化 CSS 立方体

Avatar of Ana Tudor
Ana Tudor

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

我知道有很多纯 CSS 立方体教程。 我自己也做过几个。 但到了 2017 年中期,当 CSS 自定义属性在所有主要桌面浏览器中都得到支持时,它们都感觉……过时了,而且很 WET。 我认为我应该做点什么来解决这个问题,所以这篇文章就诞生了。 它将向您展示当今构建 CSS 立方体的最有效路径,同时还解释了您应该避开的一些常见但不太理想的立方体编码模式。 让我们开始吧!

HTML 结构

HTML 结构如下:一个 .cube 元素,它包含 .cube__face 子元素(6 个)。 我们使用 Haml,这样就可以尽可能少地编写代码

.cube
  - 6.times do
    .cube__face

我们不使用 .front.back 和类似的类。 它们没有用,因为它们会使代码膨胀,并使其逻辑性降低。 相反,我们将使用 :nth-child() 来定位面。 我们不需要担心浏览器的支持,因为我们在这里构建的是使用 3D 变换的东西,这需要更新得多的浏览器支持!

基本样式

所有这些元素都是绝对定位的

[class*='cube'] { position: absolute }

.cube 是场景元素的子元素,在这种情况下是 body,因为我们希望尽可能保持简单。 如果我们在场景中有多个 3D 形状,并且我们希望它们以 3D 方式交互,那么我们的立方体将是该组合的子元素,而该组合将是场景的子元素。

我们使 body 覆盖整个视窗并设置 perspective,以便更近的东西看起来更大,而更远的东西看起来更小。

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

当我经常想要做的事情是,当全高 body 是场景时,在 .cube 上设置 font-size,使其依赖于最小视窗尺寸。 如果我随后以 em 单位设置立方体尺寸,这将使我们的整个立方体随着视窗很好地缩放。

.cube { font-size: 8vmin }

我不直接在 vmin 单位中设置立方体尺寸的原因是 Edge 浏览器中的一个错误

然后我们给 .cube 元素一个 transform-stylepreserve-3d,这样它的立方体子元素就不会被展平到它的平面中,以防我们决定对其进行动画处理,并将它放在场景的中间,使用 topleft 偏移量。 这是立方体的初始定位,最好使用偏移量,而不是为此使用 translate() 变换。 我已经看到,有时人们对此感到困惑,因为他们听说,出于性能原因,最好使用变换而不是偏移量……这是真的,但这适用于对位置进行动画处理,而不是对初始位置进行动画处理。 这里非常简单的规则是:使用偏移量或边距,在初始定位时,无论哪种方法更方便,从该初始位置开始,使用变换对位置进行动画处理。

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

然后我们选择一个立方体边长,并将其设置为立方体面的 widthheight。 我们还给面赋予一个负边距,等于立方体边长的一半,这样它们就位于正中间。 同样,这与立方体面的初始定位有关。 我们还赋予它们一个 box-shadow,只是为了让我们可以看到它们。

$cube-edge: 8em;

.cube__face {
  margin: -.5*$cube-edge;
  width: $cube-edge; height: $cube-edge;
  box-shadow: 0 0 0 2px;
}

我经常看到代码中在所有东西上都设置了 transform-style: preserve-3d。 这是不必要的,是对 preserve-3d 工作原理的误解。 它只需要在将要应用 3D 变换的东西上设置(立即、在用户交互之后、通过自动运行的动画……无论如何)并且 具有 3D 变换的子元素。 在我们的特定情况下,这仅仅是 .cube 元素。 场景不会在 3D 中进行变换,.cube__face 元素没有子元素。

我看到的另一个不必要的事情是在 .cube 元素上设置显式尺寸。 此元素不可见。 我们没有在其中设置任何文本,我们没有设置和背景、边框或阴影。 它在这里的唯一目的是作为一个容器,我们可以对它的位置进行动画处理,以便以相同的方式同时轻松地移动所有面子元素。 在这个绝对定位的 .cube 元素上不设置任何尺寸意味着它的尺寸被计算为 0x0,因此在它的面子元素上设置任何 % 值偏移也是没有意义的。 top: 0top: 50% 或任何其他百分比值对于父元素具有 0x0 尺寸的元素来说是完全相同的。 同样适用于所有其他偏移量(rightbottomleft)。

有人问我,为什么不将 .cubetopleft 设置为 calc(50% - #{.5*$cube-edge}) 并完全从 .cube__face 中删除 margin,如果我这么关心压缩代码的话。 嗯,这是因为两者并没有真正产生相同的结果,即使 .cube__face 元素最终都在屏幕的中间。 为了说明这一点,让我们给 .cube 元素一个红色的 box-shadow,以便我们能看到它,并并排查看这两种情况

请参阅 thebabydino 在 CodePen 上的 Pen (@thebabydino)。

在上面的演示中,我们的 .cube 元素在两种情况下定位不同。 当对它的偏移量使用 calc() 值并跳过其子元素的边距时,它的位置不再与场景的中心重合,而是与它的面子元素的左上角重合。 那又怎样? 反正它在我们实际的演示中是不可见的……

虽然那是真的,但不同的位置也意味着不同的 transform-origin。 而且如果我们决定旋转或缩放我们的 .cube(这是我们决定要做的),这就会改变事情。 所以考虑以下立方体的关键帧动画

@keyframes rot { to { transform: rotateY(1turn) } }

这是绕立方体的 y 轴旋转。 这两种情况的结果并不相同

请参阅 thebabydino 在 CodePen 上的 Pen (@thebabydino)。

在这两种情况下,面都围绕其父立方体的 y 轴旋转,但这个 y 轴相对于面的位置不同。 它在初始情况下与面的 y 轴重合,而在第二种情况下与面的左边缘重合。 这就是我不将立方体面的负边距引入父立方体的偏移量的原因:它会影响立方体在 3D 中的动画。

用变换构建立方体

我们在上面的演示中还没有得到一个立方体。 为了做到这一点,我们需要在 3D 中定位面。 有多种变换组合可以实现相同的效果,但最有效和最合乎逻辑的方法是从以 90° 的增量围绕它们平面中的一个轴(xy)旋转前四个面开始,然后围绕同一平面中的另一个轴旋转剩余的两个面 ±90°。 然后我们沿着垂直于它们平面的轴(它们的 z)轴链式地进行一个半立方体边长的平移。

关于平移和旋转以及如何获得用于创建长方体的 transform 链的详细解释可以在 这篇文章中找到。 立方体的情况是一个简化版本,其中沿三个轴的所有尺寸都相等。

考虑到我们选择围绕它们的 y 轴旋转前四个面,我们的 transform 链看起来如下

.cube__face:nth-child(1) {
  transform: rotateY(  0deg) translateZ(.5*$cube-edge)
}
.cube__face:nth-child(2) {
  transform: rotateY( 90deg) translateZ(.5*$cube-edge)
}
.cube__face:nth-child(3) {
  transform: rotateY(180deg) translateZ(.5*$cube-edge)
}
.cube__face:nth-child(4) {
  transform: rotateY(270deg) translateZ(.5*$cube-edge)
}
.cube__face:nth-child(5) {
  transform: rotateX( 90deg) translateZ(.5*$cube-edge)
}
.cube__face:nth-child(6) {
  transform: rotateX(-90deg) translateZ(.5*$cube-edge)
}

现在我们用它们的 rotate3d(i, j, k, a) 等效项替换 rotateY(ay)rotateX(ax) 组件。 rotate3d() 函数中的 ijk 是旋转轴在坐标系的 xyz 轴上的单位向量的分量,而 a 是绕该旋转轴的旋转角度。

由于 rotateY() 中的旋转轴是 y 轴,因此单位向量在另外两个轴(ix 轴上,kz 轴上)上的分量是 0,而在 y 轴上的分量(j)是 1。 另外,在这种情况下 aay

类似地,在 rotateX() 的情况下,我们有 i1jk0,而 aax。 因此,我们使用 rotate3d 的等效链将是

.cube__face:nth-child(1) {
  transform: rotate3d(0 /* i */, 1 /* j */, 0 /* k */,   0deg /*  0*90° */) 
    translateZ(.5*$cube-edge)
}
.cube__face:nth-child(2) {
  transform: rotate3d(0 /* i */, 1 /* j */, 0 /* k */,  90deg /*  1*90° */) 
    translateZ(.5*$cube-edge)
}
.cube__face:nth-child(3) {
  transform: rotate3d(0 /* i */, 1 /* j */, 0 /* k */, 180deg /*  2*90° */) 
    translateZ(.5*$cube-edge)
}
.cube__face:nth-child(4) {
  transform: rotate3d(0 /* i */, 1 /* j */, 0 /* k */, 270deg /*  3*90° */) 
    translateZ(.5*$cube-edge)
}
.cube__face:nth-child(5) {
  transform: rotate3d(1 /* i */, 0 /* j */, 0 /* k */,  90deg /*  1*90° */) 
    translateZ(.5*$cube-edge)
}
.cube__face:nth-child(6) {
  transform: rotate3d(1 /* i */, 0 /* j */, 0 /* k */, -90deg /* -1*90° */) 
    translateZ(.5*$cube-edge)
}

我们在上面的代码中注意到了一些事情。 首先,k 分量始终为 0。 然后,i 分量对于前四个面为 0,对于剩余的两个面为 1,而 j 分量对于前四个面为 1,对于最后两个面为 0。 最后,角度值始终可以写成一个乘数乘以 90°

这意味着我们可以在代码中引入 CSS 变量,这样我们就不必重复这些变换函数

.cube__face {
  transform: rotate3d(var(--i), var(--j), 0, calc(var(--m)*90deg)) 
    translateZ(.5*$cube-edge);
	
  &:nth-child(1) { --i: 0; --j: 1; --m:  0; }
  &:nth-child(2) { --i: 0; --j: 1; --m:  1; }
  &:nth-child(3) { --i: 0; --j: 1; --m:  2; }
  &:nth-child(4) { --i: 0; --j: 1; --m:  3; }
  &:nth-child(5) { --i: 1; --j: 0; --m:  1; }
  &:nth-child(6) { --i: 1; --j: 0; --m: -1; }
}

由于--i--j在最初四个面都保持相同的值,而只有在最后两个面才改变,我们可以将它们的默认值分别设置为01,然后在第56个面时,将它们分别切换为10。这两个面可以通过:nth-child(n + 5)选择。此外,我们可以将--m的默认值设置为0,从而完全消除对:nth-child(1)规则的需要。

.cube__face {
  transform: rotate3d(var(--i, 0), var(--j, 1), 0, calc(var(--m, 0)*90deg)) 
    translateZ(.5*$cube-edge);
	
  &:nth-child(n + 5) { --i: 1; --j: 0 }

  &:nth-child(2 /* 2 = 1 + 1 */) { --m:  1 }
  &:nth-child(3 /* 3 = 2 + 1 */) { --m:  2 }
  &:nth-child(4 /* 4 = 3 + 1 */) { --m:  3 }
  &:nth-child(5 /* 5 = 4 + 1 */) { --m:  1 /*  1 = pow(-1, 4) */ }
  &:nth-child(6 /* 6 = 5 + 1 */) { --m: -1 /* -1 = pow(-1, 5) */ }
}

更进一步,我们注意到,无论是1还是0--j都可以用calc(1 - var(--i))替换,而--m要么是前四个面的面索引,要么是最后两个面的面索引的-1次方。这使我们能够消除--j变量,并在循环中设置乘数--m

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

结果可以在下面看到

Black cube wireframe.
静态立方体 (实时演示).

这里最大的区别在于编译后的代码。使用这种 CSS 变量方法,我们只需编写一次变换函数

.cube__face {
  --i: 0;
  transform: rotate3d(var(--i), calc(1 - var(--i)), 0, calc(var(--m, 0)*90deg)) 
    translateZ(4em);
}

.cube__face:nth-child(n + 5) { --i: 1 }

.cube__face:nth-child(2) { --m: 1 }
.cube__face:nth-child(3) { --m: 2 }
.cube__face:nth-child(4) { --m: 3 }
.cube__face:nth-child(5) { --m: 1 }
.cube__face:nth-child(6) { --m: -1 }

如果没有 CSS 变量,我们所能做到的最好的方法仍然需要为每个面重复编写变换函数

.cube__face:nth-child(1) {
  transform: rotateY(0deg) translateZ(4em)
}
.cube__face:nth-child(2) {
  transform: rotateY(90deg) translateZ(4em)
}
.cube__face:nth-child(3) {
  transform: rotateY(180deg) translateZ(4em)
}
.cube__face:nth-child(4) {
  transform: rotateY(270deg) translateZ(4em)
}
.cube__face:nth-child(5) {
  transform: rotateX(90deg) translateZ(4em)
}
.cube__face:nth-child(6) {
  transform: rotateX(-90deg) translateZ(4em)
}

动画立方体

我们可以向.cube元素添加一个关键帧animation

.cube { animation: ani 2s ease-in-out infinite }

@keyframes ani {
  50% { transform: rotateY(90deg) rotateX(90deg) scale3d(.5, .5, .5) }
  100% { transform: rotateY(180deg) rotateX(180deg) }
}

结果可以在下面看到

Animated gif. Black cube wireframe, scaling down and then back up as it rotates around its vertical axis.
动画立方体 (实时演示).

当前支持状态和跨浏览器版本

那些没有使用 WebKit 浏览器的人可能已经注意到上面的演示不工作。这是因为,目前,Firefox 和 Edge 不支持将calc()值替换为除长度值之外的任何其他值。这包括rotate3d()中的无单位值和角度值。一个使跨浏览器的方法是不将--j替换为calc(1 - var(--i))的等效值,而是使用一个角度--a自定义属性,而不是calc(var(--m)*90deg)

.cube__face {
  transform: rotate3d(var(--i, 0), var(--j, 1), 0, var(--a)) 
    translateZ(.5*$cube-edge);
  
  &:nth-child(n + 5) { --i: 1; --j: 0 }
  
  @for $f from 1 to 6 {
    &:nth-child(#{$f + 1}) { --a: if($f < 4, $f, pow(-1, $f))*90deg }
  }
}

这确实意味着我们现在有一点冗余,但并不糟糕,而且我们的结果现在是跨浏览器的

添加文本和背景

接下来,我们可以向立方体的每个面添加文本。要么对所有面都一样

.cube
  - 6.times do
    .cube__face Boo!

…要么每个面都不同(我们在这里切换到 Pug,因为它允许我们编写比 Haml 在这种情况下更少的代码)

- var txt = ['ginger', 'anise', 'nutmeg', 'cinnamon', 'vanilla', 'cloves'];
- var n = txt.length;

.cube
  while n--
    .cube__face #{txt[n]}

在这种情况下,我们还设置了text-align: center,将line-height设置为$cube-edge,并调整$cube-edgefont-size的值,以获得最佳的文本匹配

$cube-edge: 5em;

.cube {
 font: 8vmin/ #{$cube-edge} cookie, cursive;
 text-align: center;
}

我们得到以下结果

Black cube wireframe rotated in 3D with text on every one of the cube faces.
带有文本的立方体 (实时演示,动画).

我们也可以为我们的每个面添加一些柔和的渐变背景

$pastels: (#feffaa, #b2ff90) (#fbc2eb, #a6c1ee) (#84fab0, #8fd3f4) (#a1c4fd, #c2e9fb) 
  (#f6d365, #fda085) (#ffecd2, #fcb69f);

.cube__face {
  background: linear-gradient(var(--ga), var(--gs));
  
  @for $f from 0 to 6 {
    &:nth-child(#{$i + 1}) {
      --ga: random(360)*1deg; /* gradient angle */
      --gs: nth($pastels, $f + 1); /* gradient stops */
    }
  }
}

上面的代码为我们提供了漂亮的柔和立方体

Cube rotated in 3D with a different pastel gradient background for each of its faces.
柔和立方体 (实时演示,动画).

一个用例

我使用这种创建长方体的方法,在一个由Dave Whyte 的动画循环启发的演示中。

Animated gif. Cuboidal bricks are falling one by one to form the uppermost circular ring on top of a structure
建造工厂 (实时演示,仅 WebKit)

拖动时旋转立方体

在此之后,还有一个需要解决的问题:如果立方体不是使用 CSS 关键帧自动动画,而是通过拖动来旋转,该怎么办?让我们看看如何做到这一点!

我们首先选择.cube元素,并确定拖动过程中各个阶段将要发生的事情。在mousedown/touchstart事件中,我们将所有内容锁定到位,以进行立方体旋转。这意味着将拖动标志设置为true,并读取事件发生的点的坐标,这些坐标也是mousemove/touchmove检测到的第一个移动的起点坐标。在mousemove/touchmove事件中,如果拖动标志为true,我们将旋转立方体。在mouseup/touchend事件中,同样,只有在拖动标志为true时,我们执行释放动作:我们将拖动标志再次设置为false,并清除初始坐标。

const _C = document.querySelector('.cube');

let drag = false, x0 = null, y0 = null;

/* helper function to handle both mouse and touch */
function getE(ev) { return ev.touches ? ev.touches[0] : ev };

function lock(ev) {
  let e = getE(ev);

  drag = true;
  x0 = e.clientX;
  y0 = e.clientY;
};

function rotate(ev) {
  if(drag) { /* rotation happens here */ }
};

function release(ev) {
  if(drag) {
    drag = false;
    x0 = y0 = null;
  }
};

addEventListener('mousedown', lock, false);
addEventListener('touchstart', lock, false);

addEventListener('mousemove', rotate, false);
addEventListener('touchmove', rotate, false);

addEventListener('mouseup', release, false);
addEventListener('touchend', release, false);

现在剩下的就是填写rotate()函数的内容了!

对于mousemove/touchmove监听器捕获的每一个微小移动,我们都有一个起点和一个终点。终点坐标(x,y)是每次mousemove/touchmove触发时通过clientXclientY读取的坐标。起点的坐标(x0,y0)要么与先前微小移动的终点坐标相同,要么,如果之前没有移动,则与mousedown/touchstart触发的点的坐标相同。这意味着,在rotate()函数中完成所有其他操作后,我们将x0设置为x,将y0设置为y

function rotate(ev) {
  if(drag) {
    let e = getE(ev), 
        x = e.clientX, y = e.clientY;
    
    /* rotation code here */
    	
    x0 = x;
    y0 = y;
  }
};

接下来,我们计算当前微小移动的终点和起点沿两个轴(dxdy)以及对角线(d)的坐标差。如果d0,则我们没有真正移动(也许不应该触发任何事件,但以防万一),因此我们退出函数,不做任何其他操作,甚至不将x0y0分别设置为xy - 在这种情况下它们是相同的。

function rotate(ev) {
  if(drag) {
    let e = getE(ev), 
        x = e.clientX, y = e.clientY, 
        dx = x - x0, dy = y - y0, 
        d = Math.hypot(dx, dy);
		
    if(d) {
      /* actual rotation happens here */
      
      x0 = x;
      y0 = y;
    }
  }
};

我们处理从可能以某种方式变换的先前状态开始的拖动旋转的方式如下:我们将对应于当前微小移动的rotate3d()与当前微小移动开始时计算的立方体transform值进行链式连接。也就是说,除非计算的transform值为none,在这种情况下,我们将它与空值进行链式连接。我们可以将整个transform链写入样式表或作为内联样式,或者…我们可以再次使用 CSS 变量!

在 CSS 中,我们将.cube元素的transform属性设置为rotate3d(var(--i), var(--j), 0, var(--a)),它与变换链的先前值var(--p)进行链式连接。为了简化操作,我们将旋转轴单位向量沿z轴的成分保持为0

.cube {
  transform: rotate3d(var(--i), var(--j), 0, var(--a)) var(--p);
}

因为我们已经完成了以上操作,并且 CSS 变量是继承的,所以现在我们需要明确地为.cube__face元素设置--i--j,分别设置为01。否则,将应用从.cube元素继承的值,而不是在var()中指定的默认值。

.cube__face {
  --i: 0; --j: 1;
  transform: rotate3d(var(--i), var(--j), 0, var(--a)) 
    translateZ(.5*$cube-edge);
}

回到 JavaScript,我们读取计算的transform值,并将其设置为--p变量。旋转角度取决于当前微小移动的起点和终点之间的距离d和一个常数A。我们将结果限制到两位小数。对于向上移动的方向,即沿y轴的负方向,我们将立方体绕x轴顺时针旋转。这意味着我们将--i成分设置为-dy。对于向右移动的方向,即沿x轴的正方向,我们将立方体绕y轴顺时针旋转,这意味着我们将--j成分设置为dx

const A = .2;

function rotate(ev) {
  if(drag) {
    let e = getE(ev), 
        x = e.clientX, y = e.clientY, 
        dx = x - x0, dy = y - y0, 
        d = Math.hypot(dx, dy);
		
    if(d) {
      _C.style.setProperty('--p', getComputedStyle(_C).transform.replace('none', ''));
      _C.style.setProperty('--a', `${+(A*d).toFixed(2)}deg`);
      _C.style.setProperty('--i', +(-dy).toFixed(2));
      _C.style.setProperty('--j', +(dx).toFixed(2));
      
      x0 = x;
      y0 = y;
    }
  }
};

最后,我们可以为这些自定义属性设置一些任意的默认值,以便立方体的初始位置看起来比从正面看更像 3D。

.cube {
  transform: rotate3d(var(--i, -7), var(--j, 8), 0, var(--a, 47deg)) 
    var(--p, unquote(' '));
}

unquote(' ')值是由于使用 Sass 造成的。虽然在纯 CSS 中,空空格是 CSS 自定义属性的有效值,但当 Sass 看到类似var(--p, )的内容时,它会抛出一个错误,因此我们需要使用unquote()来引入那个“无值”默认值。

所有上述操作的结果是一个可以使用鼠标和触摸拖动的立方体

查看 thebabydino 在 CodePen 上的Pen (@thebabydino)。