使用 CSS 变量简化 Apple Watch 呼吸 App 动画

Avatar of Ana Tudor
Ana Tudor

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

当我看到关于如何重现此动画的原始文章时,我的第一个想法是,它可以通过使用预处理器,尤其是 CSS 变量来简化。所以让我们深入研究并看看如何做!

Animated gif. Shows the result we want to get: six initially coinciding circles move out from the middle of the screen while the whole assembly scales up and rotates.
我们想要重现的结果。

结构

我们保持完全相同的结构。

为了避免多次编写相同的内容,我选择使用预处理器。

我选择的预处理器始终取决于我想做什么,因为在很多情况下,像 Pug 这样的东西提供了更大的灵活性,但在其他时候,Haml 或 Slim 允许我编写最少的代码,甚至不必引入我以后不需要的循环变量。

直到最近,我可能还会在这种情况下使用 Haml。但是,我目前偏向于另一种技术,它允许我避免在 HTML 和 CSS 预处理器代码中都设置项目数量,这意味着如果我需要在某些时候使用不同的值,则不必同时修改两者。

为了更好地理解我的意思,请考虑以下 Haml 和 Sass

- 6.times do
  .item
$n: 6; // number of items

/* set styles depending on $n */

在上面的示例中,如果我更改 Haml 代码中的项目数量,那么我需要在 Sass 代码中也更改它,否则就会出错。以或多或少明显的方式,结果不再是预期的结果。

因此,我们可以通过将圆圈的数量设置为稍后在 Sass 代码中使用的 CSS 变量的值来解决此问题。并且,在这种情况下,我感觉使用 Pug 更好。

- var nc = 6; // number of circles

.watch-face(style=`--nc: ${nc}`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--i: ${i}`)

我们还以类似的方式为每个.circle元素设置了索引。

基本样式

我们保持body上的完全相同的样式,那里没有更改。

就像结构一样,我们使用预处理器来避免多次编写几乎相同的内容。我选择 Sass 是因为我对它最熟悉,但对于像此演示这样简单的东西,Sass 没有什么特别之处使其成为最佳选择——LESS 或 Stylus 也能很好地完成工作。只是我编写 Sass 代码的速度更快,仅此而已。

但是我们为什么要使用预处理器呢?

好吧,首先,我们使用变量$d表示圆圈的直径,这样如果我们想要使它们更大或更小,以及控制它们在动画过程中向外延伸的距离,我们只需要更改此变量的值即可。

如果有人想知道为什么不在这里使用 CSS 变量,那是因为我更喜欢只在需要我的变量是动态的时候才走这条路。直径的情况并非如此,那么为什么要编写更多内容,然后可能还要想出解决我们可能遇到的 CSS 变量错误的变通方法呢?

$d: 8em;

.circle {
  width: $d; height: $d;
}

请注意,我们没有在包装器(.watch-face)上设置任何尺寸。我们不需要。

一般来说,如果一个元素的目的仅仅是作为绝对定位元素的容器,一个我们对其应用组变换(动画或非动画)的容器,并且此容器没有可见的文本内容、背景、边框、盒阴影……那么就没有必要在其上设置显式尺寸。

这样做的一个副作用是,为了将我们的圆圈保持在中间,我们需要为它们提供一个负margin,即半径的负值(即直径的一半)。

$d: 8em;
$r: .5*$d;

.circle {
  margin: -$r;
  width: $d; height: $d;
}

我们还为它们提供了与原始文章中相同的border-radiusmix-blend-modebackground,并得到以下结果

Chrome screenshot. Shows the expected result we get at this point after applying these properties.
到目前为止的预期结果(在线演示)。

好吧,我们在 WebKit 浏览器和 Firefox 中得到了上述结果,因为 Edge 尚未支持mix-blend-mode(尽管您可以投票以实施,如果您希望看到它得到支持,请这样做,因为您的投票确实有效),所以它向我们展示了一些有点丑陋的东西

Edge screenshot. No mix-blend-mode support means the overlapping regions don't look any different from the non-overlapping ones and the result is uglyish.
Edge 的结果看起来不太好。

为了解决这个问题,我们使用@supports

.circle {
  /* same styles as before */
  
  @supports not (mix-blend-mode: screen) {
    opacity: .75
  }
}

不完美,但好多了

Edge screenshot. Shows the result we get when we use partial transparency to get a result that's more like the mix-blend-mode one in other browsers.
使用@supportsopacity修复 Edge 中缺少mix-blend-mode支持(在线演示)。

现在让我们稍微看一下我们想要获得的结果

Screenshot of the desired circular distribution with annotations. The whole thing is split into two halves (a left one and a right one) by a vertical midline. The first three circles are in the right half and have a bluish green background, while the last three of the six circles are in the left half and have a yellowish green background. The circles are numbered starting from the topmost one in the right half and then they go clockwise.
期望的结果。

我们总共有六个圆圈,其中三个在左半部分,另外三个在右半部分。它们都具有某种绿色的background,左半部分的圆圈更偏向黄色,右半部分的圆圈更偏向蓝色。

如果我们从右半部分最上面的圆圈开始,然后顺时针编号,我们会发现前三个圆圈在右半部分,具有蓝绿色background,后三个圆圈在左半部分,具有黄绿色background

此时,我们已将所有圆圈的background设置为黄蓝色。这意味着我们需要为前六个圆圈中的前半部分覆盖它。由于我们不能在选择器中使用 CSS 变量,因此我们从 Pug 代码中执行此操作

- var nc = 6; // number of circles

style .circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }
.watch-face(style=`--nc: ${nc}`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--i: ${i}`)

如果您需要复习一下,:nth-child(-n + a)会选择我们在n ≥ 0整数值下获得的有效索引处的项目。在我们的例子中,a = .5*nc = .5*6 = 3,所以我们的选择器是:nth-child(-n + 3)

如果我们将n替换为0,则得到3,这是一个有效索引,因此我们的选择器匹配第三个圆圈。

如果我们将n替换为1,则得到2,这也是一个有效索引,因此我们的选择器匹配第二个圆圈。

如果我们将n替换为2,则得到1,再次有效,因此我们的选择器匹配第一个圆圈。

如果我们将n替换为3,则得到0,这不是一个有效索引,因为索引在此处不是基于0的。此时,我们停止,因为如果我们继续,很明显我们不会获得任何其他正值。

下面的 Pen 说明了它是如何工作的——一般规则是:nth-child(-n + a)会选择前a个项目

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

回到我们的圆形分布,到目前为止的结果如下所示

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

定位

首先,我们将包装器设置为相对定位,并将其.circle子元素设置为绝对定位。现在它们都在中间重叠。

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

为了理解接下来需要做什么,让我们看一下下面的插图

SVG Illustration. Shows the circles in their initial (overlapping dead in the middle) and final positions (with the rightmost one being highlighted in its final position). The segment between the central point of this circle in the initial position and in the final position is a horizontal segment of length equal to the circle radius.
最右边的圆圈从其初始位置到其最终位置(在线)。

圆圈在初始位置的中心点在同一水平线上,并且距离最右边的圆圈一个半径。这意味着我们可以通过沿x轴平移半径$r到达此最终位置。

但是其他圆圈呢?它们在最终位置的中心点也距离其初始位置一个半径,只是沿着其他直线。

SVG Illustration. Shows the circles in their initial (overlapping dead in the middle) and final positions. The segments connecting the initial position of their central points (all dead in the middle) and the final positions of the same points are highlighted. They're all segments of length equal to the circle radius.
所有圆圈:初始位置(位于中间)距离每个圆圈的最终位置一个半径(在线)。

这意味着,如果我们首先旋转它们的坐标系,直到它们的x轴与中心点初始位置和最终位置之间的直线重合,然后将它们平移一个半径,我们就可以以非常相似的方式使它们都处于正确的最终位置。

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

好吧,但是将每个圆圈旋转多少角度呢?

好吧,我们从一个点周围的圆圈上我们有360°这一事实开始。

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

我们有六个圆圈均匀分布,因此任何两个连续圆圈之间的旋转差为360°/6 = 60°。由于我们不需要旋转最右边的.circle(第二个),因此它的角度为,这将前一个(第一个)置于-60°,后一个(第二个)置于60°,依此类推。

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

请注意,-60°300° = 360° - 60°在圆圈上占据相同的位置,因此我们是通过顺时针(正)旋转300°到达那里,还是通过绕圆圈反方向旋转60°(这给了我们负号)都没有关系。我们将在代码中使用-60°选项,因为它使我们更容易在我们案例中发现一个方便的模式。

所以我们的变换如下所示

.circle {
  &:nth-child(1 /* = 0 + 1 */) {
    transform: rotate(-60deg /* -1·60° = (0 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(2 /* = 1 + 1 */) {
    transform: rotate(  0deg /*  0·60° = (1 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(3 /* = 2 + 1 */) {
    transform: rotate( 60deg /*  1·60° = (2 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(4 /* = 3 + 1 */) {
    transform: rotate(120deg /*  2·60° = (3 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(5 /* = 4 + 1 */) {
    transform: rotate(180deg /*  3·60° = (4 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(6 /* = 5 + 1 */) {
    transform: rotate(240deg /*  4·60° = (5 - 1)·360°/6 */) translate($r);
  }
}

这给了我们我们一直在追求的分布

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

然而,这段代码非常重复,可以轻松地进行压缩。对于其中的任何一个,旋转角度都可以写成当前索引和项目总数的函数。

.circle {
  /* previous styles */
  
  transform: rotate(calc((var(--i) - 1)*360deg/var(--nc))) translate($r);
}

在 WebKit 浏览器和 Firefox 57+ 中有效,但在 Edge 和旧版 Firefox 浏览器中失败,因为它们不支持在 rotate() 函数中使用 calc()

幸运的是,在这种情况下,我们可以选择在 Pug 代码中计算并设置各个旋转角度,然后在 Sass 代码中按此使用。

- var nc = 6, ba = 360/nc;

style .circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }
.watch-face
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i - 1)*ba}deg`)
.circle {
  /* previous styles */
  
  transform: rotate(var(--ca)) translate($r);
}

在这种情况下,我们实际上不需要之前的自定义属性来做任何其他事情,所以我们只是去掉了它们。

现在,我们得到了一个紧凑的代码,并且是跨浏览器的分布版本,正是我们想要的。

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

很好,这意味着我们完成了最重要的部分!现在是填充内容……

收尾工作

我们将 transform 声明从类中取出,并将其放入一组 @keyframes 中。在类中,我们用无平移的情况替换它。

.circle {
  /* same as before */
  
  transform: rotate(var(--ca))
}

@keyframes circle {
  to { transform: rotate(var(--ca)) translate($r) }
}

我们还在 .watch-face 元素上添加了用于脉冲动画的 @keyframes 集。

@keyframes pulse {
  0% { transform: scale(.15) rotate(.5turn) }
}

请注意,我们不需要 0%from)和 100%to)关键帧。每当这些关键帧缺失时,它们的动画属性值(在我们的例子中仅为 transform 属性)将从我们在没有 animation 的情况下在动画元素上具有的值生成。

circle 动画案例中,它是 rotate(var(--ca))。在 pulse 动画案例中,scale(1) 给我们与 none 相同的矩阵,这是 transform 的默认值,因此我们甚至不需要在 .watch-face 元素上设置它。

我们将 animation-duration 设置为 Sass 变量,以便如果我们将来需要更改它,只需在一个地方更改即可。最后,我们在 .watch-face 元素和 .circle 元素上设置 animation 属性。

$t: 4s;

.watch-face {
  position: relative;
  animation: pulse $t cubic-bezier(.5, 0, .5, 1) infinite alternate
}

.circle {
  /* same as before */
  
  animation: circle $t infinite alternate
}

请注意,我们没有为 circle 动画设置时间函数。这在原始演示中是 ease,我们没有显式设置它,因为它是默认值。

就是这样——我们得到了动画结果

我们还可以调整平移距离,使其不完全是 $r,而是一个稍微小一点的值(例如 .95*$r)。这也可以使 mix-blend-mode 效果更有趣。

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

额外:一般情况!

以上内容针对的是六个 .circle 花瓣的特定情况。现在,我们将了解如何对其进行调整,使其适用于任意数量的花瓣。等等,我们需要做的不仅仅是从 Pug 代码中更改圆形元素的数量吗?

好吧,让我们看看如果我们只这样做会发生什么。

Screenshots. They show the result we get for nc equal to 6, 8 and 9. When nc is 6, we have the previous case: splitting the whole thing into two halves with a vertical line, we have the first three (bluish green) circles in the right half and the last three (yellowish green) circles in the left half. When nc is 8, we also have the first half of the circles (the first four, bluish green) on one side of a line splitting the assembly into two geometrically symmetrical halves and the last four circles (yellowish green) on the other side of the same line. This line however isn't vertical anymore. In the nc = 9 case, all circles are yellowish green.
nc 等于 6(左)、8(中)和 9(右)的结果。

结果看起来还不错,但它们并没有完全遵循相同的模式——将前半部分的圆圈(蓝绿色)放在垂直对称线的右侧,后半部分(黄绿色)放在左侧。

nc = 8 的情况下,我们非常接近,但对称线不是垂直的。然而,在 nc = 9 的情况下,我们所有的圆圈都具有黄绿色的 background

因此,让我们看看为什么会发生这些情况以及如何获得我们真正想要的结果。

使 :nth-child() 为我们服务

首先,请记住,我们正在使用这段小程序使一半的圆圈具有蓝绿色的 background

.circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }

但在 nc = 9 的情况下,我们有 .5*nc = .5*9 = 4.5,这使得我们的选择器为 :nth-child(-n + 4.5)。由于 4.5 不是整数,因此选择器无效,并且 background 不会应用。因此,我们在这里做的第一件事是将 .5*nc 值向下取整。

style .circle:nth-child(-n + #{~~(.5*nc)}) { background: #529ca0 }

这样更好,因为对于 nc 值为 9,我们得到的选择器为 .circle:nth-child(-n + 4),这将使我们前 4 个项目应用蓝绿色的 background

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

但是,如果 nc 为奇数,我们仍然没有相同数量的蓝绿色和黄绿色圆圈。为了解决这个问题,我们使中间的圆圈(从第一个到最后一个)具有渐变 background

我们所说的“中间的圆圈”是指与起点和终点距离相等的圆圈。以下交互式演示说明了这一点,以及当圆圈总数为偶数时,我们没有中间圆圈的事实。

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

好吧,我们如何获得这个圆圈?

在数学上,这是包含前 ceil(.5*nc) 个项目的集合与包含除前 floor(.5*nc) 个项目之外的所有项目的集合之间的交集。如果 nc 为偶数,则 floor(.5*nc)ceil(.5*nc) 相等,我们的交集为空集 。以下 Pen 说明了这一点。

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

我们使用 :nth-child(-n + #{Math.ceil(.5*nc)}) 获取前 ceil(.5*nc) 个项目,但另一个集合呢?

一般来说,:nth-child(n + a) 选择除前 a - 1 个项目之外的所有项目。

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

因此,为了获取除前 floor(.5*nc) 个项目之外的所有项目,我们使用 :nth-child(n + #{~~(.5*nc) + 1})

这意味着我们有以下选择器用于中间圆圈。

:nth-child(n + #{~~(.5*nc) + 1}):nth-child(-n + #{Math.ceil(.5*nc)})

让我们看看这会给我们带来什么。

  • 如果我们有 3 个项目,我们的选择器是 :nth-child(n + 2):nth-child(-n + 2),这将为我们提供第二个项目({2, 3, 4, ...}{2, 1} 集合之间的交集)。
  • 如果我们有 4 个项目,我们的选择器是 :nth-child(n + 3):nth-child(-n + 2),这不会捕获任何内容({3, 4, 5, ...}{2, 1} 集合之间的交集为空集 )。
  • 如果我们有 5 个项目,我们的选择器是 :nth-child(n + 3):nth-child(-n + 3),这将为我们提供第三个项目({3, 4, 5, ...}{3, 2, 1} 集合之间的交集)。
  • 如果我们有 6 个项目,我们的选择器是 :nth-child(n + 4):nth-child(-n + 3),这不会捕获任何内容({4, 5, 6, ...}{3, 2, 1} 集合之间的交集为空集 )。
  • 如果我们有 7 个项目,我们的选择器是 :nth-child(n + 4):nth-child(-n + 4),这将为我们提供第四个项目({4, 5, 6, ...}{4, 3, 2, 1} 集合之间的交集)。
  • 如果我们有 8 个项目,我们的选择器是 :nth-child(n + 5):nth-child(-n + 4),这不会捕获任何内容({5, 6, 7, ...}{4, 3, 2, 1} 集合之间的交集为空集 )。
  • 如果我们有 9 个项目,我们的选择器是 :nth-child(n + 5):nth-child(-n + 5),这将为我们提供第五个项目({5, 6, 7, ...}{5, 4, 3, 2, 1} 集合之间的交集)。

现在我们可以选择中间的项目(当我们总共有奇数个项目时),让我们为它提供一个渐变 background

- var nc = 6, ba = 360/nc;

style .circle:nth-child(-n + #{~~(.5*nc)}) { background: var(--c0) }
  | .circle:nth-child(n + #{~~(.5*nc) + 1}):nth-child(-n + #{Math.ceil(.5*nc)}) {
  |   background: linear-gradient(var(--c0), var(--c1))
  | }
.watch-face(style=`--c0: #529ca0; --c1: #61bea2`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i - 1)*ba}deg`)

我们使用从上到下的渐变的原因是,最终,我们希望此项目位于底部,由组件的垂直对称线分成两半。这意味着我们首先需要将其旋转,直到其 x 轴指向下方,然后沿其 x 轴的新方向向下平移。在此位置,项目的顶部位于组件的右半部分,项目的底部位于组件的左半部分。因此,如果我们想要从组件的右侧到组件的左侧的渐变,则这是该实际 .circle 元素上的从上到下的渐变。

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

使用此技术,我们现在解决了一般情况下的背景问题。

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

现在剩下的就是使对称轴垂直。

驯服角度

为了了解我们需要在这里做什么,让我们关注顶部所需的定位。在那里,我们希望始终有两个圆圈(DOM 顺序中第一个在右侧,最后一个在左侧)相对于垂直轴对称放置,该垂直轴将我们的组件分成两个互相镜像的半部分。

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。

它们是对称的,这意味着垂直轴将它们之间的角度距离 ba(即 360° 除以圆圈总数 nc)分成两个相等的部分。

SVG Illustration. Shows the circles distributed around a central point with the two at the top (symmetrical with respect to the vertical axis that splits the whole assembly into two mirrored halves) being highlighted. This middle axis splits the angular distance between the central points of these two circles into two equal halves.
垂直对称线与顶部角度中心点的径向线形成的角度都等于半个底角(实时)。

因此,两者都比垂直对称轴偏离半个底角(其中底角 ba360° 除以圆圈总数 nc),一个沿顺时针方向,另一个沿逆时针方向。

对称轴的上半部分位于 -90°(相当于 270°)。

SVG Illustration. Shows degrees around a circle in 90° steps. We start from the right (3 o'clock). This is the 0° and, in general, any multiple of 360° angle. Going clockwise, we have 90° down (at 6 o'clock), 180° on the left (at 9 o'clock) and 270° at the top (12 o'clock). Going the other way from 0°, we have -90° at the top (12 o'clock), -180° on the left (9 o'clock) and so on.
圆周上的度数值(实时)。

因此,为了到达 DOM 顺序中的第一个圆圈(右侧顶部的那个),我们从 开始,沿负方向移动 90°,然后沿正方向(顺时针方向)移动半个底角。这使得第一个圆圈位于 .5*ba - 90 度。

SVG Illustration. Shows graphically how to get the angular position of the first circle. Starting from 0° (3 o'clock), we go in the negative direction by 90° (getting at 12 o'clock). Afterwards, we go back in the positive direction by half a base angle.
如何获得第一个圆圈放置的角度(实时)。

之后,每个其他圆圈都位于前一个圆圈的角度加上一个底角。这样,我们有

  • 第一个圆圈(索引 0,选择器 :nth-child(1))位于 ca₀ = .5*ba - 90
  • 第二个圆圈(索引 1,选择器 :nth-child(2))位于 ca₁ = ca₀ + ba = ca₀ + 1*ba
  • 第三个圆圈(索引 2,选择器 :nth-child(3))位于 ca₂ = ca₁ + ba = ca₀ + ba + ba = ca₀ + 2*ba
  • 一般来说,索引为 k 的圆圈位于 caₖ = caₖ₋₁ + ba = ca₀ + k*ba

因此,索引为 i 的圆圈的当前角度为 .5*ba - 90 + i*ba = (i + .5)*ba - 90

- var nc = 6, ba = 360/nc;

//- same as before
.watch-face(style=`--c0: #529ca0; --c1: #61bea2`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i + .5)*ba - 90}deg`)

这给了我们最终的 Pen,我们只需要从 Pug 代码中更改 nc 即可更改结果

查看 thebabydino 在 CodePen 上创建的 Pen@thebabydino)。