使用 CSS 变量进行逻辑运算

Avatar of Ana Tudor
Ana Tudor

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

在使用切换变量(一个值为 01 的变量,在 这篇文章 中有更详细的解释)时,我经常希望能够对其进行逻辑运算。CSS 中没有像 not(var(--i))and(var(--i), var(--k)) 这样的函数,但我们可以使用 calc() 函数中的算术运算来模拟这些运算,甚至更多。

本文将向您展示我们需要为每个逻辑运算使用哪些 calc() 公式,并解释它们是如何以及为什么与几个导致本文撰写的用例一起使用。

如何:公式

not

这是一个非常简单的操作:我们从 1 中减去切换变量(我们称之为 --j)。

--notj: calc(1 - var(--j))

如果 --j0,则 --notj1 (1 - 0)。如果 j1,则 --notj0 (1 - 1)。

and

现在,如果您曾经上过电子学课程(特别是像程序逻辑系统或集成电路这样的课程),那么您已经知道我们在这里需要使用哪个公式。但我们不要直接跳进去。

两个操作数的 and 为真,当且仅当两个操作数都为真。我们的两个操作数是两个切换变量(我们称之为 --k--i)。它们中的每一个可以是 01,与另一个无关。这意味着我们可以处于四种可能的场景中:

  • --k: 0--i: 0
  • --k: 0--i: 1
  • --k: 1--i: 0
  • --k: 1--i: 1

如果我们的两个切换变量都是 1,则 and 操作的结果为 1,否则为 0。换个角度看,如果至少一个切换变量为 0,则该结果为 0

现在你需要这样想:什么算术运算的结果是 0,如果至少一个操作数是 0?那就是乘法,因为任何数乘以 0 都等于 0

所以,我们的 --and 公式是:

--and: calc(var(--k)*var(--i))

考虑我们四个可能的场景,我们有:

  • 对于 --k: 0--i: 0,我们有 --and0 (0*0)
  • 对于 --k: 0--i: 1,我们有 --and0 (0*1)
  • 对于 --k: 1--i: 0,我们有 --and0 (1*0)
  • 对于 --k: 1--i: 1,我们有 --and1 (1*1)

nand

由于 nandnot and,我们需要用 and 的公式替换 not 公式中的 --j

--nand: calc(1 - var(--k)*var(--i))

对于我们四个可能的场景,我们得到:

  • 对于 --k: 0--i: 0,我们有 --nand1 (1 - 0*0 = 1 - 0)
  • 对于 --k: 0--i: 1,我们有 --nand1 (1 - 0*1 = 1 - 0)
  • 对于 --k: 1--i: 0,我们有 --nand1 (1 - 1*0 = 1 - 0)
  • 对于 --k: 1--i: 1,我们有 --nand0 (1 - 1*1 = 1 - 1)

or

如果至少一个切换变量为 1,则 or 操作的结果为 1,否则为 0(如果两个变量都是 0)。

这里的第一个直觉是使用加法,但是虽然这可以让我们在 --k--i 都是 0 时得到 0,在一个是 0 另一个是 1 时得到 1,但在两者都是 1 时会得到 2。所以这并不起作用。

但我们可以使用久经考验的 德摩根定律,其中之一指出:

not (A or B) = (not A) and (not B)

这意味着 or 操作的结果是对 --k--i 否定值的 and 操作的否定。将其放入 CSS,我们有:

--or: calc(1 - (1 - var(--k))*(1 - var(--i)))

对于每种情况,我们得到:

  • 对于 --k: 0--i: 0,我们有 --or0 (1 - (1 - 0)*(1 - 0) = 1 - 1*1 = 1 - 1)
  • 对于 --k: 0--i: 1,我们有 --or1 (1 - (1 - 0)*(1 - 1) = 1 - 1*0 = 1 - 0)
  • 对于 --k: 1--i: 0,我们有 --or1 (1 - (1 - 1)*(1 - 0) = 1 - 0*1 = 1 - 0)
  • 对于 --k: 1--i: 1,我们有 --or1 (1 - (1 - 1)*(1 - 1) = 1 - 0*0 = 1 - 0)

nor

由于 nornot or,我们有:

--nor: calc((1 - var(--k))*(1 - var(--i)))

对于我们四个可能的场景,我们得到:

  • 对于 --k: 0--i: 0,我们有 --nor1 ((1 - 0)*(1 - 0) = 1*1)
  • 对于 --k: 0--i: 1,我们有 --nor0 ((1 - 0)*(1 - 1) = 1*0)
  • 对于 --k: 1--i: 0,我们有 --nor0 ((1 - 1)*(1 - 0) = 0*1)
  • 对于 --k: 1--i: 1,我们有 --nor0 ((1 - 1)*(1 - 1) = 0*0)

xor

当两个操作数中一个是 1 另一个是 0 时,xor 操作的结果为 1。乍一看这似乎更棘手,但是,如果我们认为这意味着两个操作数需要不同才能使结果为 1(否则为 0),我们就会发现 calc() 中要使用的正确算术运算:减法!

如果 --k--i 相等,那么从 --k 中减去 --i 将得到 0。否则,如果我们有 --k: 0--i: 1,则相同减法的结果为 -1;如果我们有 --k: 1--i: 0,则结果为 1

接近了,但还不完全!我们在四个场景中的三个场景中得到了我们想要的结果,但我们需要在 --k: 0--i: 1 场景中得到 1,而不是 -1

然而,-101 的共同点是将它们乘以自身会得到它们的绝对值(对于 -11 来说都是 1)。所以真正的解决方案是将这个差值乘以自身:

--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)))

测试我们四个可能的场景,我们有:

  • 对于 --k: 0--i: 0,我们有 --xor0 ((0 - 0)*(0 - 0) = 0*0)
  • 对于 --k: 0--i: 1,我们有 --xor1 ((0 - 1)*(0 - 1) = -1*-1)
  • 对于 --k: 1--i: 0,我们有 --xor1 ((1 - 0)*(1 - 0) = 1*1)
  • 对于 --k: 1--i: 1,我们有 --xor0 ((1 - 1)*(1 - 1) = 0*0)

为什么:用例

让我们看几个使用 CSS 中逻辑运算的例子。请注意,我不会详细介绍这些演示的其他方面,因为它们超出了本文的范围。

仅在小屏幕上隐藏禁用面板

这是我在制作一个交互式演示时遇到的一个用例,该演示允许用户控制各种参数来改变视觉效果。 对于更有经验的用户,还有一个高级控件面板,默认情况下是禁用的。 但是,可以启用它以访问手动控制更多参数。

由于此演示应该具有响应性,因此布局会随视窗大小而改变。 我们也不希望在小屏幕上出现拥挤,因此如果高级控件被禁用并且处于窄屏幕情况下,则没有必要显示它们。

下面的屏幕截图拼贴显示了四种可能场景的结果。

Screenshot collage.
可能案例的拼贴。

那么让我们看看这在 CSS 方面意味着什么!

首先,在 <body> 上,我们使用一个开关,它在窄屏幕情况下从 0 变为宽屏幕情况下的 1。 我们也以这种方式更改 flex-direction(如果您想了解更多关于如何工作的解释,请查看我的第二篇文章,介绍使用 CSS 变量进行 DRY 切换)。

body {
  --k: var(--wide, 0);
  display: flex;
  flex-direction: var(--wide, column);
	
  @media (orientation: landscape) { --wide: 1 }
}

然后我们在高级控件面板上有一个第二个开关。 此第二个开关在复选框未选中时为 0,在复选框为 :checked 时为 1。 在此开关的帮助下,我们为高级控件面板赋予一个禁用外观(通过 filter 链),我们也禁用它(通过 pointer-events)。 在这里,not 很方便,因为我们想在禁用情况下降低对比度和不透明度

.advanced {
  --i: var(--enabled, 0);
  --noti: calc(1 - var(--i));
  filter: 
    contrast(calc(1 - var(--noti)*.9)) 
    opacity(calc(1 - var(--noti)*.7));
  pointer-events: var(--enabled, none);
	
  [id='toggle']:checked ~ & { --enabled: 1 }
}

我们希望高级控件面板在处于宽屏幕情况下(因此如果 --k1)保持展开状态,无论复选框是否为 :checked或者如果复选框为 :checked(因此如果 --i1),无论我们是否处于宽屏幕情况下。

这正是 or 操作!

因此,我们计算一个 --or 变量

.advanced {
  /* same as before */
  --or: calc(1 - (1 - var(--k))*(1 - var(--i)));
}

如果此 --or 变量为 0,则意味着我们处于窄屏幕情况下并且复选框未选中,因此我们希望将高级控件面板的 height 和其垂直 margin 设置为 0

.advanced {
  /* same as before */
  margin: calc(var(--or)*#{$mv}) 0;
  height: calc(var(--or)*#{$h});
}

这给了我们想要的结果 (实时演示)。

使用相同的公式来定位 3D 形体的多个面

这是我在这个夏天进行的个人项目CSS-ing 约翰逊多面体时遇到的一个用例。

让我们看看其中一个形状,例如,陀螺形五角锥台 (J25),以了解逻辑运算在这里如何有用。

我们想要获得的形状。

此形状由一个没有大的十边形底部的五角锥台和一个没有顶部十边形的十边反棱柱组成。 下面的交互式演示显示了如何通过将这些部件的面展开图折叠成 3D 形状,然后连接起来,得到我们想要的形状。

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

如上所示,这些面要么是反棱柱的一部分,要么是锥台的一部分。 这里我们引入第一个开关变量 --i。 对于反棱柱的一部分的面,它为 0,对于锥台的一部分的面,它为 1。 反棱柱的面有 .mid 类,因为我们可以将另一个锥台添加到另一个反棱柱底座上,然后反棱柱将在中间。 锥台的面有 .cup 类,因为这部分看起来像一个咖啡杯……没有把手!

锥台看起来像一个倒置的向上杯子,没有把手。
.mid { --i: 0 }
.cup { --i: 1 }

只关注侧面,它们可以有一个顶点向上或向下。 这里我们引入第二个变量 --k。 如果它们有一个顶点向上(这样的面有 .dir 类),则它为 0,如果它们反向并且有一个顶点向下(这些面有 .rev 类),则它为 1

.dir { --k: 0 }
.rev { --k: 1 }

反棱柱有 10 个侧面(都是三角形)向上,每个侧面都附着在十边形底部的边缘上,该边缘也是复合形状的底座。 它还有 10 个侧面(也都是三角形)向下,每个侧面都附着在另一个十边形底部的边缘上(一个也是锥台的十边形底部,因此不是复合形状的底座)。

锥台有 10 个侧面向上,三角形和五边形交替出现,每个侧面都附着在也是反棱柱底座的十边形底部(因此它也不是复合形状的底座)。 它还有 5 个侧面,都是三角形,向下,每个侧面都附着在五边形底部的边缘上。

下面的交互式演示允许我们通过一次突出显示一个组来更好地查看这四组面。 你可以使用底部的箭头选择要突出显示的面组。 你也可以启用绕 y 轴旋转,并改变形状的倾斜度。

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

如前所述,侧面可以是三角形或五边形。

.s3gon { --p: 0 }
.s5gon { --p: 1 }

由于反棱柱和锥台的所有侧面(.lat)都与每个形状的两个底面之一共用一条边,我们称这些共用边为侧面的底边。

下面的交互式演示突出显示了这些边、它们的端点和它们的中心点,并允许从各种角度查看形状,这得益于围绕 y 轴的自动旋转(可以随时开始/暂停)以及围绕 x 轴的手动旋转(可以通过滑块控制)。

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

为了使事情变得更轻松,我们将 .lat 面面的 transform-origin 设置在它们底边的中间(底部水平边)。

SVG illustration.
突出显示底边及其中心点 (实时)。

我们还确保将这些面放置在适当的位置,以便它们的中心点恰好在包含整个 3D 形状的场景元素的中间。

使 transform-origin 与底边的中心点重合意味着我们对一个面执行的任何旋转都将在其底边的中心点周围发生,如下面的交互式演示所示

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

我们分四步将侧面放置在我们要的位置

  1. 我们围绕它们的 y 轴旋转它们,这样它们的底边现在与它们的最终位置平行。(这也旋转了它们的局部坐标系——元素的 z 轴总是指向元素所面对的方向。)
  2. 我们平移它们,这样它们的底边与它们的最终位置重合(沿着两个部件底面的边缘)。
  3. 如果它们需要有一个顶点向下,我们围绕它们的 z 轴旋转它们半圈。
  4. 我们围绕它们的 x 轴旋转它们到它们的最终位置

下面的交互式演示说明了这些步骤,你可以在其中执行这些步骤并旋转整个形状(使用 y 轴旋转的播放/暂停按钮以及 x 轴旋转的滑块)。

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

y 轴旋转值主要基于面索引,而不太依赖于我们的开关变量,尽管它也取决于这些变量。

结构如下

- var n = 5; //- number of edges/ vertices of small base

section.scene
  //- 3D shape element
  .s3d
    //- the faces, each a 2D shape element (.s2d)
    
    //- lateral (.lat) antiprism (.mid) faces, 
    //- first half pointing up (.dir), others pointing down (.rev)
    //- all of them being triangles (.s3gon)
    - for(var j = 0; j < 4*n; j++)
      .s2d.mid.lat.s3gon(class=j < 2*n ? 'dir' : 'rev')
    
    //- lateral (.lat) rotunda (.cup) faces that point up (.dir), 
    //- both triangles (.s3gon) and pentagons (.s5gon)
    - for(var j = 0; j < n; j++)
      .s2d.cup.lat.s3gon.dir
      .s2d.cup.lat.s5gon.dir
    //- lateral (.lat) rotunda (.cup) faces that point down (.rev)
    //- all of them triangles (.s3gon)
    - for(var j = 0; j < n; j++)
      .s2d.cup.lat.s3gon.rev

    //- base faces, 
    //- one for the antiprism (.mid), 
    //- the other for the rotunda (.cup)
    .s2d.mid.base(class=`s${2*n}gon`)
    .s2d.cup.base(class=`s${n}gon`)

这给了我们以下 HTML

<section class="scene">
  <div class="s3d">
    <!-- LATERAL faces -->
    <div class="s2d mid lat s3gon dir"></div>
    <!-- 9 more identical faces, 
         so we have 10 lateral antiprism faces pointing up -->

    <div class="s2d mid lat s3gon rev"></div>
    <!-- 9 more identical faces, 
         so we have 10 lateral antiprism faces pointing down -->

    <div class="s2d cup lat s3gon dir"></div>
    <div class="s2d cup lat s5gon dir"></div>
    <!-- 4 more identical pairs, 
         so we have 10 lateral rotunda faces pointing up -->

    <div class="s2d cup lat s3gon rev"></div>
    <!-- 4 more identical faces, 
         so we have 5 lateral rotunda faces pointing down -->

    <!-- BASE faces -->
    <div class="s2d mid base s10gon"></div>
    <div class="s2d cup base s5gon"></div>
  </div>
</section>

这意味着面 0... 910 个向上指向的反棱柱侧面,面 10... 1910 个向下指向的反棱柱侧面,面 20... 2910 个向上指向的锥台侧面,面 30... 345 个向下指向的锥台侧面。

所以我们在这里做的就是在侧面上设置一个索引 --idx

$n: 5; // number of edges/ vertices of small base

.lat {
  @for $i from 0 to 2*$n {
    &:nth-child(#{2*$n}n + #{$i + 1}) { --idx: #{$i} }
  }
}

此索引从每组面的 0 开始,这意味着面 0... 910... 1920... 29 的索引从 09,而面 30... 34 的索引从 04。 很好,但是如果我们只是将这些索引与共用十边形的基角1 相乘以获得我们想要在这一步的 y 轴旋转

--ay: calc(var(--idx)*#{$ba10gon});

transform: rotatey(var(--ay))

…那么我们得到以下最终结果。 我在这里显示最终结果,因为很难通过只查看在仅应用围绕 y 轴的旋转后获得的中间结果来查看问题所在。

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

这……不是我们想要的!

所以让我们看看上面的结果有哪些问题以及如何通过使用我们的开关变量和对它们进行布尔运算来解决这些问题。

第一个问题是,向上指向的反棱柱侧面需要偏移半个正十边形的基角。 这意味着在与基角相乘之前,要向 --idx 添加或减去 .5但仅针对这些面

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

我们要定位的面是 --i--k 都为 0 的面,所以我们在这里需要做的是将它们的 nor 的结果乘以 .5

--nor: calc((1 - var(--k))*(1 - var(--i)));
--j: calc(var(--idx) + var(--nor)*.5);
--ay: calc(var(--j)*#{$ba10gon});

transform: rotatey(var(--ay));

第二个问题是,向下指向的锥台侧面没有按照它们应该的那样分布,这样它们每个侧面都与底部的五边形共用一条边,并且与向上指向的三角形锥台侧面共用一个与底部相对的顶点。 这意味着将 --idx 乘以 2,但仅针对这些面。

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

我们现在要定位的是 --i--k 都为 1 的面(即 `and` 操作结果为 1 的面),因此我们需要将 --idx 乘以它们的 `and` 操作结果加 1

--and: calc(var(--k)*var(--i));
--nor: calc((1 - var(--k))*(1 - var(--i)));
--j: calc((1 + var(--and))*var(--idx) + var(--nor)*.5);
--ay: calc(var(--j)*#{$ba10gon});

transform: rotatey(var(--ay));

下一步是使用 translate3d() 进行平移。我们不会向左或向右移动任何面,因此沿 x 轴的值始终为 0。但是,我们会沿垂直方向(y 轴)和向前(z 轴)移动它们。

在垂直方向上,我们希望稍后旋转到指向下方的杯子面,使其底边位于杯子(以及复合形状)的小(五边形)底面所在的平面上。这意味着 --i1--k1 的面向上移动(负方向)复合形状总高度的一半(我们计算出的总高度为 $h)。因此我们需要在此处使用 `and` 操作。

// same as before
--and: calc(var(--i)*var(--k));
--y: calc(var(--and)*#{-.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

我们还希望所有其他杯子面以及最终指向下方的反棱柱面,使其底边位于杯子和反棱柱之间的公共平面上。这意味着 --i1--k0 的面,以及 --i0--k1 的面,向下平移(正方向)复合形状高度的一半,然后向上移动(负方向)反棱柱的高度($h-mid)。而你猜怎么着,这正是 `xor` 操作!

// same as before
--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)));
--and: calc(var(--i)*var(--k));
--y: calc(var(--xor)*#{.5*$h - $h-mid} - 
          var(--and)*#{.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

最后,我们希望保持指向上的反棱柱面位于复合形状(以及反棱柱)的底部基平面。这意味着 --i0--k0 的面,向下平移(正方向)复合形状总高度的一半。因此我们在此处使用 `nor` 操作!

// same as before
--nor: calc((1 - var(--k))*(1 - var(--i)));
--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)));
--and: calc(var(--i)*var(--k));

--y: calc(var(--nor)*#{.5*$h} + 
          var(--xor)*#{.5*$h - $h-mid} - 
          var(--and)*#{.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

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

沿 z 方向,我们希望移动面,使其底边与复合形状的底面的边重合,或者与两个 3D 组件共用的公共底面(它不是复合形状的面)的边重合。对于杯子的顶面(我们稍后会将其旋转到指向下方),放置位置位于五边形的边上,而对于复合形状的所有其他面,放置位置位于十边形的边上。

这意味着 --i1--k1 的面向前平移五边形底面的内切圆半径,而所有其他面向前平移十边形底面的内切圆半径。因此我们在此处需要的操作是 `and` 和 `nand`!

// same as before
--and: calc(var(--i)*var(--k));
--nand: calc(1 - var(--and));
--z: calc(var(--and)*#{$ri5gon} + var(--nand)*#{$ri10gon});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

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

接下来,我们希望使所有 .rev--k1 的面)指向下方。这非常简单,不需要任何逻辑操作,我们只需要在变换链中添加绕 z 轴旋转半圈,但仅针对 --k1 的面。

// same as before
--az: calc(var(--k)*.5turn);

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y), var(--z))
           rotate(var(--az));

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

然后,所有五边形面(--p1 的面)都绕 x 轴旋转一定角度。

--ax: calc(var(--p)*#{$ax5});

对于三角形面(--p0,这意味着我们需要使用 --notp),我们有一个反棱柱面($ax3-mid)的旋转角度,另一个指向上的圆顶面($ax3-cup-dir)的旋转角度,以及另一个指向下的圆顶面($ax3-cup-red)的旋转角度。

反棱柱面是 --i0 的面,因此我们需要在此处将它们的对应角度值乘以 --noti。圆顶面是 --i1 的面,其中指向上的圆顶面是 --k0 的面,而指向下的圆顶面是 --k1 的面。

--notk: calc(1 - var(--k));
--noti: calc(1 - var(--i));
--notp: calc(1 - var(--p));

--ax: calc(var(--notp)*(var(--noti)*#{$ax3-mid} + 
                        var(--i)*(var(--notk)*#{$ax3-cup-dir} + var(--k)*#{$ax3-cup-rev})) +
           var(--p)*#{$ax5});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y), var(--z))
           rotate(var(--az)) 
           rotatex(var(--ax));

这给了我们最终的结果!

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


1 对于任何正多边形(例如我们形状的任何面),对应于一条边的弧线,以及该边两端圆心角之间的角度(我们的底角),都是一个完整的圆(360 度)除以边数。对于等边三角形,该角度为 360°/3 = 120°。对于正五边形,该角度为 360°/5 = 72°。对于正十边形,该角度为 360°/10 = 36°。 ↪️

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