在 Web 上创建阴阳加载器

Avatar of Ana Tudor
Ana Tudor on

DigitalOcean 提供适用于您旅程各个阶段的云产品。立即开始使用 价值 200 美元的免费积分!

我之前偶然发现了一些 这样的 动画,这给了我一个想法,要尽可能少用代码,不使用外部库,使用各种方法创建自己的版本,其中一些方法利用了我们现在可以使用的一些更现代的功能,例如 CSS 变量。本文将指导您完成构建这些演示的过程。

在其他任何事情之前,这是我们试图实现的动画

Animated gif. The yin and yang symbol is rotating while its two lobes alternate increasing and decreasing in size - whenever one is increasing, it squishes the other one down.
预期结果:一个旋转的 ☯ 符号,其两个叶片的大小不断增大和减小。

无论我们选择哪种方法来重新创建上述动画,我们总是从静态阴阳形状开始,该形状看起来如下所示

The static yin and yang symbol.
静态阴阳符号 (实时演示)。

此起始形状的结构由以下插图描述

The structure of the yin and yang symbol. The two lobes are circular arcs (half circles) whose radii are half the radius of the big circle enclosing the symbol. The two small circles are in the middle of the two lobes and their diameters are half of those of the half circle lobes.
静态符号的结构 (实时演示)。

首先,我们有一个直径为 d 的大圆圈。在这个圆圈的内部,我们紧紧地嵌入了两个较小的圆圈,每个圆圈的直径是我们初始大圆圈直径的一半。这意味着这两个较小圆圈的直径等于大圆圈的半径 r(或 .5*d)。在这两个直径为 r 的圆圈的内部,我们有一个更小的同心圆。如果我们要绘制穿过所有这些圆圈的中心点的直径——上面插图中的线段 AB,它与内部圆圈的交点将其分成 6 个相等的小段。这意味着其中一个最小圆圈的直径为 r/3(或 d/6),其半径为 r/6

知道了所有这些,让我们开始第一个方法!

普通 HTML + CSS

在这种情况下,我们可以用一个元素及其两个伪元素来完成它。构建符号的原理由以下动画说明(由于整个事物都会旋转,因此我们切换轴线无关紧要)

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

实际元素是大圆圈,它有一个从上到下的渐变,在中间有一个急剧的过渡。伪元素是我们放置在其上的较小圆圈。其中一个较小圆圈的直径是大圆圈直径的一半。两个较小圆圈都与大圆圈垂直居中对齐。

所以让我们开始编写可以实现此目标的代码!

首先,我们为大圆圈确定一个直径 $d。我们使用视窗单位,以便在调整大小后一切都能很好地缩放。我们将此直径值设置为其 widthheight,我们使用 border-radius 使元素圆形,并通过在中间从 blackwhite 的急剧过渡来提供从上到下的渐变 background

$d: 80vmin;

.☯ {
  width: $d; height: $d;
  border-radius: 50%;
  background: linear-gradient(black 50%, white 0);
}

到目前为止,一切都很好

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

现在让我们继续使用伪元素创建的较小圆圈。我们让我们的元素 display: flex,并通过设置 align-items: center 使其子元素(在本例中为伪元素)与它垂直居中对齐。我们让这些伪元素具有其父元素的一半 height50%),并确保在水平方向上,它们分别覆盖大圆圈的一半。最后,我们使用 border-radius 使它们圆形,并提供一个虚拟 background,并设置 content 属性,以便我们可以看到它们

.☯ {
  display: flex;
  align-items: center;
  /* same styles as before */
	
  &:before, &:after {
    flex: 1;
    height: 50%;
    border-radius: 50%;
    background: #f90;
    content: '';
  }
}

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

接下来,我们需要为它们提供不同的背景

.☯ {
  /* same styles as before */
	
  &:before, &:after {
    /* same styles as before */
    background: black;
  }
	
  &:after { background: white }
}

现在我们正在取得进展!

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

在我们获得静态符号之前,剩下的就是为这两个伪元素提供边框。black 应该有一个 white 边框,而 white 应该有一个 black 边框。这些边框应该是伪元素直径的三分之一,这是大圆圈直径的一半的三分之一——这给了我们 $d/6

.☯ {
  /* same styles as before */
	
  &:before, &:after {
    /* same styles as before */
    border: solid $d/6 white;
  }
	
  &:after {
    /* same styles as before */
    border-color: black;
  }
}

但是,结果看起来不太正确

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

这是因为,在垂直方向上,border 会累加到 height 上,而不是从 height 中减去。在水平方向上,我们没有设置 width,因此它会从可用空间中减去。这里有两个可能的修复方法。一种是在伪元素上设置 box-sizing: border-box。第二种是将伪元素的 height 更改为 $d/6——我们将选择这种方法

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

我们现在有了基本形状,所以让我们继续动画!此动画涉及从第一个伪元素缩小到其原始大小的一半(这意味着缩放因子 $f.5)的状态,而第二个伪元素扩展以占据所有剩余可用空间——意味着大圆圈的直径(是其原始大小的两倍)减去第一个圆圈的直径(是其原始大小的 $f)的状态,到第二个伪元素缩小到其原始大小的 $f,而第一个伪元素扩展到其原始大小的 2 - $f 的状态。第一个伪元素圆圈相对于其最左侧点进行缩放(因此我们需要设置 transform-origin0 50%),而第二个伪元素相对于其最右侧点进行缩放(100% 50%)。

$f: .5;
$t: 1s;

.☯ {
  /* same styles as before */
	
  &:before, &:after {
    /* same styles as before */
    transform-origin: 0 50%;
    transform: scale($f);
    animation: s $t ease-in-out infinite alternate;
  }
	
  &:after {
    /* same styles as before */
    transform-origin: 100% 50%;
    animation-delay: -$t;
  }
}

@keyframes s { to { transform: scale(2 - $f) } }

我们现在有了我们一直在追求的形状变化动画

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

最后一步是让整个符号旋转

$t: 1s;

.☯ {
  /* same styles as before */
  animation: r 2*$t linear infinite;
}

@keyframes r { to { transform: rotate(1turn) } }

我们得到了 最终结果

但是,我们还可以做一件事来使编译后的 CSS 更有效率:使用 CSS 变量消除冗余!

white 可以用 HSL 格式编写为 hsl(0, 0%, 100%)。色调和饱和度无关紧要,任何亮度为 100% 的值都是 white,因此我们只需将它们都设置为 0 以简化操作。类似地,black 可以编写为 hsl(0, 0%, 0%)。色调和饱和度同样无关紧要,任何亮度为 0% 的值都是 black。鉴于此,我们的代码变为

.☯ {
  /* same styles as before */
	
  &:before, &:after {
    /* same styles as before */
    border: solid $d/6 hsl(0, 0%, 100% /* = 1*100% = (1 - 0)*100% */);
    transform-origin: 0 /* = 0*100% */ 50%;
    background: hsl(0, 0%, 0% /* 0*100% */);
    animation: s $t ease-in-out infinite alternate;
    animation-delay: 0 /* = 0*-$t */;
  }
	
  &:after {
    /* same styles as before */
    border-color: hsl(0, 0%, 0% /* = 0*100% = (1 - 1)*100% */);
    transform-origin: 100% /* = 1*100% */ 50%;
    background: hsl(0, 0%, 100% /* = 1*100% */);
    animation-delay: -$t /* = 1*-$t */;
  }
}

从上面可以得出

  • 我们 transform-originx 分量对于第一个伪元素是 calc(0*100%),对于第二个伪元素是 calc(1*100%)
  • 我们的 border-color 对于第一个伪元素是 hsl(0, 0%, calc((1 - 0)*100%)),对于第二个伪元素是 hsl(0, 0%, calc((1 - 1)*100%))
  • 我们的 background 对于第一个伪元素是 hsl(0, 0%, calc(0*100%)),对于第二个伪元素是 hsl(0, 0%, calc(1*100%))
  • 我们的 animation-delay 对于第一个伪元素是 calc(0*#{-$t}),对于第二个伪元素是 calc(1*#{-$t})

这意味着我们可以使用一个充当开关的自定义属性,它对于第一个伪元素是 0,对于第二个伪元素是 1

.☯ {
  /* same styles as before */
	
  &:before, &:after {
    /* same styles as before */
    --i: 0;
    border: solid $d/6 hsl(0, 0%, calc((1 - var(--i))*100%));
    transform-origin: calc(var(--i)*100%) 50%;
    background: hsl(0, 0%, calc(var(--i)*100%));
    animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;
  }
	
  &:after { --i: 1 }
}

这消除了两次写入所有这些规则的必要性:我们现在所要做的就是翻转开关!不幸的是,这 目前只在 WebKit 浏览器中有效,因为 Firefox 和 Edge 不支持使用 calc() 作为 animation-delay 值,而且 Firefox 也不支持在 hsl() 中使用它。

更新:Firefox 57+ 支持使用 calc() 作为 animation-delay 值,Firefox 59+ 也支持在 hsl() 中使用它。

画布 + JavaScript

虽然有些人可能认为这种方法太过复杂,但我真的很喜欢它,因为它需要的代码量与 CSS 相同,而且它具有良好的支持和性能。

我们从一个 canvas 元素和一些基本样式开始,这些样式只是为了将其置于其容器(在本例中为 body 元素)的中间并使其可见。我们还使用 border-radius 使其呈圆形,以便在 canvas 上绘制时简化操作。

$d: 80vmin;

body {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: lightslategray;
}

canvas {
  width: $d; height: $d;
  border-radius: 50%;
  background: white;
}

到目前为止,一切都很好——我们有一个白色圆盘

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

好的,现在让我们继续进行 JavaScript 部分!在其他任何事情之前,我们需要获取 canvas 元素、2D 上下文并设置 canvas 元素的 widthheight 属性(否则我们绘制在 canvas 上的内容会拉伸)。然后,我们需要有一个大圆圈的半径。我们让此半径等于 canvas 元素计算大小的一半,并在完成此操作后,我们平移上下文,以便将 canvas0,0 点置于正中间(它最初位于左上角)。我们确保在每次调整大小后重新计算半径和 widthheight 属性,因为在 CSS 中,我们使 canvas 尺寸取决于视窗。

const _C = document.querySelector('canvas'), 
      CT = _C.getContext('2d');

let r;

function size() {
  _C.width = _C.height = Math.round(_C.getBoundingClientRect().width);
	
  r = .5*_C.width;
	
  CT.translate(r, r);
};

size();

addEventListener('resize', size, false);

完成此操作后,我们可以继续在画布上绘制。绘制什么?好吧,一个由三个弧组成的形状,如下面的插图所示

Illustration showing how half of the main shape of the symbol is made up of three half circle arcs. The first arc is half a circle following the contour of the symbol's outer circle shape, clockwise from -180° to 0°. The second one is another half a circle of half the radius of the first, going clockwise from the point where the previous arc started, 0° on its smaller support circle to 180° on the same circle. The third one is another half circle, going anticlockwise from the point where the previous arc ended, 0° on its support circle, to -180° on its support circle.
三个弧形形状的结构 (实时演示)。

为了在 2D 画布上绘制弧线,我们需要了解一些信息。首先,是该弧线所属圆形的中心点的坐标。然后,我们需要知道这个圆形的半径以及弧线起点和终点相对于圆形局部坐标系中 x 轴的角度。最后,我们需要知道是从起点到终点顺时针还是逆时针(如果我们没有指定,默认是顺时针)。

第一个弧线位于大圆上,大圆的直径等于画布尺寸,并且由于我们将 canvas0,0 点放置在这个圆形的正中间,这意味着我们知道第一组坐标(它是 0,0)和圆形半径(它是 r)。这个弧线的起点是这个圆形的左端点 - 这个点在 -180°(或 )处。终点是圆形的右端点,位于 (在弧度制中也是 0)。如果你需要回顾圆形上的角度,可以查看这个辅助演示

这意味着我们可以创建一个路径并向其中添加这个弧线,并且为了查看我们到目前为止所做的,我们可以关闭这个路径(在本例中,这意味着用直线将弧线的终点连接到起点)并填充它(使用默认填充色,即 黑色

CT.beginPath();
CT.arc(0, 0, r, -Math.PI, 0);

CT.closePath();
CT.fill();

结果可以在以下 Pen 中看到

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

现在让我们继续第二个弧线。它所属圆形的中心点的坐标是 .5*r,0,其半径为 .5*r(大圆半径的一半)。它从 0π,以顺时针方向移动。因此,我们在关闭路径之前添加到路径中的弧线是

CT.arc(.5*r, 0, .5*r, 0, Math.PI);

添加这个弧线后,我们的形状变为

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

现在我们还剩下一个弧线要添加。半径与上一个弧线相同(.5*r),第一组坐标是 -.5*r,0。这个弧线从 0,它是第一个不是顺时针方向的弧线,所以我们需要更改那个标志

CT.arc(-.5*r, 0, .5*r, 0, -Math.PI, true);

现在我们有了我们想要的形状

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

接下来,我们将在这个路径中添加黑色圆形。我们不会创建另一个路径,因为目的是将具有相同填充色的所有形状分组到同一个路径中,以提高性能。调用 fill() 很昂贵,因此我们不想比真正需要时更频繁地调用它。

圆形只是从 360°(或从 02*π)的弧线。这个圆形的中心点与我们绘制的最后一个弧线(-.5*r, 0)的中心点重合,其半径是前两个弧线半径的三分之一。

CT.arc(-.5*r, 0, .5*r/3, 0, 2*Math.PI);

现在我们离完成整个符号越来越近了

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

剩下的就是创建一个 白色 圆形,它关于 y 轴对称于 黑色 圆形。这意味着我们需要切换到 白色 填充色,开始一个新的路径,然后向其中添加一个弧线,使用与我们添加 黑色 圆形形状时几乎相同的命令 - 唯一的区别是我们反转了 x 坐标的符号(这次是 +,而不是 -)。之后,我们关闭该路径并填充它。

CT.fillStyle = 'white';
CT.beginPath();
CT.arc(.5*r, 0, .5*r/3, 0, 2*Math.PI);
CT.closePath();
CT.fill();

现在我们有了静态的符号!

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

对于动画,我们希望从较小的弧线之一缩小到其原始半径的一半(所以我们使用 F.5 的缩放因子)的状态,到另一个弧线相应扩展的状态。

在初始状态下,鉴于较小弧线的半径最初为 .5*r,则第一个弧线在按因子 F 缩小后的半径为 r1 = F*.5*r。由于较小圆形的半径需要加起来等于大圆的半径 r,我们得出第二个较小圆形的半径为 r2 = r - r1 = r - F*.5*r

为了获得第一个较小弧线在初始状态下的原点的 x 坐标,我们需要从它开始的点的 x 坐标中减去它的半径。这样,我们得出这个坐标是 r - r1 = r2。类似地,为了获得第二个较小弧线原点的 x 坐标,我们需要将它的半径加到它结束的点的坐标上。这样,我们得出这个坐标是 -r + r2 = -(r - r2) = -r1

Initial vs. final state of the animation. The initial state shows the first lobe (second arc of the three arc shape) shrunken to the minimum possible (its radius being F*.5*r, where F is a value between 0 and 1), while the other lobe has expanded to fill the remaining state. In the final state, things are reversed: the first lobe has expanded, while the second one has shrunk.
动画的初始状态与最终状态(实时演示)。

对于最终状态,两个半径的值是反转的。第二个是 F*.5*r,而第一个是 r - F*.5*r

在我们的动画的每一帧中,我们将第一个较小弧线的当前半径从最小值(F*.5*r)增加到最大值(r - F*.5*r),然后我们开始将其减小到最小值,然后循环重复,同时相应地缩放另一个较小弧线的半径。

为了做到这一点,我们首先在 size() 函数中设置最小和最大半径

const F = .5;

let rmin, rmax;

function size() {
  /* same as before */
  rmin = F*.5*r;
  rmax = r - rmin;
};

在任何时间点,第一个较小弧线的当前半径是 k*rmin + (1 - k)*rmax,其中这个 k 因子不断从 1 变为 0,然后又回到 1。这听起来类似于 [0, 360°] 区间上的余弦函数。在 处,余弦函数的值为 1。然后它开始下降,并一直下降,直到它到达 180°,此时它达到最小值 -1,之后该函数的值开始再次上升,直到它到达 360°,此时它再次为 1

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

好吧,但是余弦函数的值在 [-1, 1] 区间内,我们需要一个函数提供 [0, 1] 区间内的值。那么,如果我们向余弦函数中添加 1,那么我们将整个图形向上移动,现在值位于 [0, 2] 区间内

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

[0, 2] 不是 [0, 1],所以我们还需要做的是将整个东西除以 2(或乘以 .5,是一样的)。这将把我们的图形压缩到所需的区间。

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

很好,但是那个角度是怎么回事?我们没有从 360° 的角度。如果我们要使用 requestAnimationFrame,我们只有当前帧的数字,它从 0 开始,然后不断增加。那么,在开始时,我们为一个动画周期设置了总帧数 T(第一个弧线从最小半径值到最大半径值,然后又回来)。

对于每一帧,我们计算当前帧数 (t) 与总帧数的比率。在一个周期中,这个比率从 01。如果我们将这个比率乘以 2*Math.PI(与 360° 相同),那么结果在整个周期中从 02*Math.PI。所以这将是我们的角度。

const T = 120;

(function ani(t = 0) {
  let k = .5*(1 + Math.cos(t/T*2*Math.PI)), 
      cr1 = k*rmin + (1 - k)*rmax, cr2 = r - cr1;
	
})();

下一步是在这个函数中放入实际绘制符号的代码。开始、关闭、填充路径、更改填充色的代码保持不变,创建大弧线所需的代码也保持不变。发生变化的是

  • 较小弧线的半径 - 它们分别是 cr1cr2
  • 较小弧线的中心点的 x 坐标 - 它们分别位于 cr2-cr1
  • 黑色白色 圆形的半径 - 它们分别是 cr2/3cr1/3
  • 这些圆形的中心点的 x 坐标 - 它们分别位于 -cr1cr2

所以我们的动画函数变为

const T = 120;

(function ani(t = 0) {
  let k = .5*(1 + Math.cos(t/T*2*Math.PI)), 
      cr1 = k*rmin + (1 - k)*rmax, cr2 = r - cr1;
	
  CT.beginPath();
  CT.arc(0, 0, r, -Math.PI, 0);
  CT.arc(cr2, 0, cr1, 0, Math.PI);
  CT.arc(-cr1, 0, cr2, 0, -Math.PI, true);
  CT.arc(-cr1, 0, cr2/3, 0, 2*Math.PI);
  CT.closePath();
  CT.fill();

  CT.fillStyle = 'white';
  CT.beginPath();
  CT.arc(cr2, 0, cr1/3, 0, 2*Math.PI);
  CT.closePath();
  CT.fill();
})();

这给了我们动画的初始状态

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

在我们真正开始动画化弧线的半径之前,我们还需要处理几件事。首先,如果我们现在就开始动画,我们只是会在之前帧绘制的内容之上绘制每一帧的形状,这将造成一片混乱。为了避免这种情况,我们需要在每一帧绘制任何内容之前清除画布。我们清除的是可见的部分,它位于画布尺寸矩形内部,该矩形的左上角位于 -r,-r

CT.clearRect(-r, -r, _C.width, _C.width);

我们需要解决的第二个小问题是,我们切换到 白色 填充色,但是在下一帧的开始,我们需要 黑色 填充色。因此,我们需要在每一帧的第一个路径开始之前进行此切换

CT.fillStyle = 'black';

现在我们可以真正开始动画了

requestAnimationFrame(ani.bind(this, ++t));

这给了我们变形动画,但我们仍然需要旋转整个东西。在解决这个问题之前,让我们再次看看 k 的公式

let k = .5*(1 + Math.cos(t/T*2*Math.PI))

T2*Math.PI 在整个动画过程中都是常数,因此我们可以将该部分提取出来并将其存储为一个常数角度 A

const T = 120, A = 2*Math.PI/T;

(function ani(t = 0) {
  let k = .5*(1 + Math.cos(t*A));

  /* same as before */
})();

现在对于每一帧,我们也可以在清除画布后将上下文旋转 A 度。

CT.rotate(A);

这种旋转在每一帧中不断累加,现在我们有了我们一直想要的旋转和变形动画

SVG + JavaScript

我们从一个 SVG 元素开始,并使用与 canvas 案例中几乎相同的 CSS。

$d: 80vmin;

body {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: lightslategray;
}

svg {
  width: $d; height: $d;
  border-radius: 50%;
  background: white;
}

这将给我们一个白色的圆盘。

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

不太令人兴奋,所以让我们继续在 SVG 画布上绘制一些东西。就像在 canvas 案例中一样,我们将绘制一条由相同三个弧线(一个半径等于 SVG viewBox 大小一半的大弧线,以及两个半径等于静态情况下大弧线半径一半的小弧线)和两个小圆圈(半径等于它们共享中心点的较小弧线的 1/3)组成的路径。

所以我们首先选择一个半径 r 值,并用它设置 svg 元素的 viewBox

- var r = 1500;

svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))

下一步是添加由三个弧线组成的 path。在 SVG 中创建 pathcanvas 有点不同。在这里,形状由路径数据 d 属性描述,在我们的例子中,它由以下部分组成:

  • 一个“移动到”(M)命令,之后我们指定路径起点(在本例中也是大弧线起点)的坐标。
  • 每个弧线之后都有一个“弧线到”(A)命令,之后我们描述我们的弧线;每个弧线都从上一个弧线的终点开始,或者在第一个弧线的情况下,从路径的起点开始。

让我们仔细看看“弧线到”(A)命令的组成部分。

  • 我们弧线沿其坐标系 x 轴的半径 - 在大弧线的情况下等于 r,在两个较小弧线的情况下等于 .5*r
  • 我们弧线沿其坐标系 y 轴的半径 - 在圆弧的情况下等于沿 x 轴的半径,就像我们这里的情况一样(只有在椭圆弧的情况下才不同,但这超出了本文的范围)。
  • 我们弧线坐标系的旋转 - 这只影响椭圆弧的情况下弧线的形状,所以对于圆弧来说,我们可以安全地始终将其设为 0 以简化操作。
  • 大弧线标志 - 如果我们的弧线大于半圆,则为 1,否则为 0;由于我们的弧线正好是半圆,它们不超过半圆,因此在本例中它始终为 0
  • 扫掠标志 - 如果弧线从起点到终点顺时针方向移动,则为 1,否则为 0;在我们的例子中,前两个弧线是顺时针方向的,而第三个不是,所以我们对三个弧线使用的值分别是 110
  • 弧线终点的 x 坐标 - 这是我们需要为每个弧线确定的值。
  • 弧线终点的 y 坐标 - 也是我们需要为每个弧线确定的值。

在这一点上,我们已经知道我们需要的大部分内容。我们仍然需要弄清楚的是弧线终点的坐标。所以让我们考虑下面的图示。

Illustration showing how half of the main shape of the symbol is made up of three half circle arcs. The first arc is half a circle following the contour of the symbol's outer circle shape, clockwise from (-r,0) to (r,0). The second one is another half a circle of half the radius of the first, going clockwise from the point where the previous arc started to (0,0). The third one is another half circle of the same radius as the previous one, going anticlockwise from the point where the previous arc ended to (-r,0).
三个弧线形状的结构,以及弧线终点的坐标 (实时演示)。

从上面的图示我们可以看到,第一个弧线(大弧线)从 (-r,0) 开始,到 (r,0) 结束,第二个弧线到 0,0 结束,第三个弧线到 (-r,0) 结束(也是我们路径的起点)。请注意,即使较小弧线的半径发生变化,所有这些点的 y 坐标都保持 0,但第二个弧线终点的 x 坐标只在本例中恰好为 0,当较小弧线的半径正好是大弧线的一半时。在一般情况下,它是 r - 2*r1,其中 r1 是第二个弧线(第一个较小弧线)的半径。这意味着我们现在可以创建我们的路径。

- var r1 = .5*r, r2 = r - r1;

path(d=`M${-r} 0
        A${r} ${r} 0 0 1 ${r} 0
        A${r1} ${r1} 0 0 1 ${r - 2*r1} 0
        A${r2} ${r2} 0 0 0 ${-r} 0`)

这将给我们我们一直在追求的三个弧线形状。

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

现在让我们继续处理小圆圈。我们已经从 canvas 方法中知道它们中心点的坐标和半径。

circle(r=r1/3 cx=r2)
circle(r=r2/3 cx=-r1)

默认情况下,所有这些形状都填充了 黑色,因此我们需要在 (r2,0) 处的圆圈上显式设置 白色 填充。

circle:nth-child(2) { fill: white }

现在我们有了静态形状!

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

接下来,我们将使用 JavaScript 来动画化路径形状以及两个小圆圈的大小和位置。这意味着我们首先要获取这些元素,获取大圆圈的半径 R,并设置一个缩放因子 F,它可以使我们得到弧线可以缩放到的最小半径(RMIN)。我们还设置了总帧数(T)和单位角度(A)。

const _P = document.querySelector('path'), 
      _C = document.querySelectorAll('circle'), 
      _SVG = document.querySelector('svg'), 
      R = -1*_SVG.getAttribute('viewBox').split(' ')[0], 
      F = .25, RMIN = F*R, RMAX = R - RMIN, 
      T = 120, A = 2*Math.PI/T;

动画函数与 canvas 案例中的几乎相同。唯一不同的是,现在为了改变路径形状,我们需要改变它的 d 属性,而为了改变小圆圈的半径和位置,我们需要改变它们的 rcx 属性。但其他一切的工作方式都完全相同。

(function ani(t = 0) {
  let k = .5*(1 + Math.cos(t*A)), 
      cr1 = k*RMIN + (1 - k)*RMAX, cr2 = R - cr1;
	
  _P.setAttribute('d', `M${-R} 0
                        A${R} ${R} 0 0 1 ${R} 0
                        A${cr1} ${cr1} 0 0 1 ${R - 2*cr1} 0
                        A${cr2} ${cr2} 0 0 0 ${-R} 0`);
  _C[0].setAttribute('r', cr1/3);
  _C[0].setAttribute('cx', cr2);
  _C[1].setAttribute('r', cr2/3);
  _C[1].setAttribute('cx', -cr1);
	
  requestAnimationFrame(ani.bind(this, ++t));
})();

这将给我们变形后的形状。

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

只有一件事需要处理,那就是整个符号的旋转,我们将其设置在 _SVG 元素上。

let ca = t*A;

_SVG.style.transform = `rotate(${+ca.toFixed(2)}rad)`;

现在我们得到了想要的结果 使用 SVG 和 JavaScript 也可以得到

SVG + CSS

还有一种方法可以做到这一点,尽管它涉及从 CSS 中更改路径数据等内容,而这只是 Blink 浏览器目前支持的内容(而且它们 甚至不符合最新的规范)。

它也很容易出错,因为我们需要在 SVG viewBox 属性和 Sass 变量中设置相同的半径值。

- var r = 1500;

svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))
  path
  circle
  circle
$d: 65vmin;
$r: 1500;
$r1: .5*$r;
$r2: $r - $r1;
$rmin: .25*$r;
$rmax: $r - $rmax;

我们可以从 CSS 中访问此半径的值,但只能作为自定义属性,如果我们要做类似的事情。

- var r = 1500;

svg(viewBox=[-r, -r, 2*r, 2*r].join(' '))
  style :root { --r: #{r} }

但是,虽然这在某些情况下可能非常有用,但在这里是无用的,因为我们目前无法将 CSS 变量放入我们用 Sass 构建的路径数据字符串中。所以我们不得不将相同的值同时设置在 viewBox 属性和 Sass 代码中。

基本样式是相同的,我们可以用 Sass 创建路径数据,这与 Pug 方法类似。

$r: 1500;
$r1: .5*$r;
$r2: $r - $r1;

path {
  $data: 'M#{-$r} 0' + 
         'A#{$r} #{$r} 0 0 1 #{$r} 0' +
         'A#{$r1} #{$r1} 0 0 1 #{$r - 2*$r1} 0' + 
         'A#{$r2} #{$r2} 0 0 0 #{-$r} 0';
  d: path($data);
}

这将给我们三个弧线形状。

Screenshot of the three arcs shape
三个弧线形状 (实时演示,仅限 Blink)。

对于两个小圆圈,我们设置它们的半径和沿 x 轴的位置。我们还需要确保其中一个为白色。

circle {
  r: $r1/3;
  cx: $r2;
	
  &:nth-child(2) { fill: white }
	
  &:nth-child(3) {
    r: $r2/3;
    cx: -$r1
  }
}

现在我们有了静态形状。

Screenshot of the static yin and yang shape.
静态阴阳形状 (实时演示,仅限 Blink)。

为了获得我们想要的效果,我们需要以下动画。

  • 一个 path 形状的变形 animation,其中较小弧线的第一个的半径从最小可能的半径($rmin: .25*$r)到最大可能的半径($rmax: $r - $rmin),然后反过来,而最后一个弧线的半径从 $rmax$rmin,然后反过来;这可以通过一个从一个极端到另一个极端的关键帧 animation 来完成,然后使用 alternate 值作为 animation-direction
  • 另一个交替的 animation,它将第一个小圆圈的半径从 $rmin/3 扩展到 $rmax/3,然后再次缩小到 $rmin/3;第二个小圆圈使用相同的 animation,只是延迟了正常 animation-duration 的值。
  • 第三个交替的动画,它使两个小圆圈的中心点来回移动;在第一个(白色)小圆圈的情况下,它从 $rmax 移动到 $rmin;在第二个(黑色)圆圈的情况下,它从 -$rmin 移动到 -$rmax;我们可以在这里做的事情是使用 CSS 变量作为开关(它只在 WebKit 浏览器中有效,但是从 CSS 设置路径数据或圆圈半径或偏移量也没有更好的支持)。

所以让我们先看看变形 @keyframes。这些是通过设置与之前几乎相同的路径数据创建的,只是将 $r1 替换为 $rmin,将 $r2 替换为 $rmax 来作为 0% 的关键帧,并将它们反过来作为 100% 的关键帧。

@keyframes m {
  0% {
    $data: 'M#{-$r} 0' + 
           'A#{$r} #{$r} 0 0 1 #{$r} 0' +
           'A#{$rmin} #{$rmin} 0 0 1 #{$r - 2*$rmin} 0' + 
           'A#{$rmax} #{$rmax} 0 0 0 #{-$r} 0';
    d: path($data);
  }
  100% {
    $data: 'M#{-$r} 0' + 
           'A#{$r} #{$r} 0 0 1 #{$r} 0' +
           'A#{$rmax} #{$rmax} 0 0 1 #{$r - 2*$rmax} 0' + 
           'A#{$rmin} #{$rmin} 0 0 0 #{-$r} 0';
    d: path($data);
  }
}

现在我们只需要在 path 元素上设置这个 animation

$t: 1s;

path { animation: m $t ease-in-out infinite alternate }

形状变形部分有效!

Animated gif. Shows the three arcs shape morphing from the state where the first lobe is the minimal one to the state where the second one is minimal.
三个弧线形状变形 (实时演示,仅限 Blink)。

下一步是继续对两个小圆圈进行缩放和移动。缩放 @keyframes 遵循与变形相同的模式。半径值为 0% 时为 $rmin/3100% 时为 $rmax/3

@keyframes s {
    0% { r: $rmin/3 }
  100% { r: $rmax/3 }
}

我们在 circle 元素上设置了这个 animation

circle { animation: s $t ease-in-out infinite alternate }

现在两个小圆圈的半径正在动画化。

Animated gif. Here, the radii of the two small circles are also animated from their minimum to their maximum size and back.
三个弧线形状变形和小圆圈缩放 (实时演示,仅限 Blink)。

这是一个开始,但我们这里有一些问题。首先,第二个小 circle 应该在第一个变大的时候缩小,反之亦然。我们可以通过设置一个 animation-delay 来解决这个问题,该延迟取决于我们最初设置为 0 的 CSS 变量,然后在第二个小圆圈上切换到 1

circle {
  --i: 0;
  animation: s $t ease-in-out calc(var(--i)*#{$t}) infinite alternate
	
  &:nth-child(3) { --i: 1 }
}

如前所述,使用 calc() 作为 animation-delay 值只能在 WebKit 浏览器中使用,但从 CSS 设置 r 的支持更差,因此 animation-delay 不是我们这里遇到的最大问题。结果可以在下面看到。

Animated gif. The radii of the two small circles are also animated from their minimum size to their maximum size and back such that, when the first is at its minimum, the second is at its maximum and the other way around.
三个圆弧的形状变形和小圆圈的缩放(现场演示,仅限 Blink)。

这已经好多了,但我们仍然需要沿x轴动画小圆圈的位置。我们使用一组@keyframes来实现这一点,这些@keyframes使cx$rmax$rmin再返回第一个小circle,并从-$rmin-$rmax再返回第二个小circle。在这两种情况下,我们都有不同的顺序和不同的符号,因此我们需要想出一个满足两者的关键帧动画。

解决顺序问题很容易——我们使用与缩放半径animation相同的animation-delay

但符号怎么办呢?好吧,我们再次使用自定义属性--i。对于第一个小circle,它是0,对于第二个小circle,它是1,所以我们需要一个函数,该函数接收--i,当该变量的值为0时,它返回1,当该变量的值为1时,它返回-1。想到的最简单的函数是将-1的幂次方为--i。不幸的是,CSS 不支持这样做——我们只能在calc()中进行算术运算。但是,calc(1 - 2*var(--i))是另一个可行的解决方案,而且它并不比这个复杂多少。使用这个,我们的代码变成

circle {
  --i: 0;
  --j: calc(1 - 2*var(--i));
  animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;
  animation-name: s, x;
	
  &:nth-child(3) { --i: 1 }
}

@keyframes x {
    0% { cx: calc(var(--j)*#{$rmax}) }
  100% { cx: calc(var(--j)*#{$rmin}) }
}

结果可以在下面看到……它并不像预期的那样

Animated gif. The position of the two small circles should animate smoothly, instead it seems to flip suddenly from the initial one to the final one.
结果(现场演示,仅限 Blink)。

我们看到的是,在两个结束值之间出现了一个突然的50%处的翻转,而不是一个平滑的animation。这不是我们想要的,所以看来我们需要放弃这种策略。

不过我们还有一个选择:将cxtransform结合起来。两个小圆圈始终以使它们中心点之间的距离为$r的方式定位。所以,我们可以将第二个小圆圈定位在-$r处,然后将它们都平移一个在$rmax$rmin之间的距离

circle {
  transform: translate($r2*1px);
  animation: s $t ease-in-out infinite alternate;
  animation-name: s, x;
		
  &:nth-child(3) {
    cx: -$r;
    animation-delay: -$t, 0s
  }
}

@keyframes x {
    0% { transform: translate($rmax*1px) }
  100% { transform: translate($rmin*1px) }
}

这终于像我们想要的那样工作了!

Animated gif. The position of the small circles animates smoothly between the initial and the final one for each.
正确的动画(现场演示,仅限 Blink)

我们可以在这里做的一件事是,为了简化代码,可以删除最初的$r1$r2值,并将它们替换为每个动画的0%关键帧中的值

path {
  d: path('M#{-$r} 0A#{$r} #{$r} 0 0 1 #{$r} 0' +
          'A#{$rmin} #{$rmin} 0 0 1 #{$r - 2*$rmin} 0' + 
          'A#{$rmax} #{$rmax} 0 0 0 #{-$r} 0');
  animation: m $t ease-in-out infinite alternate
}

circle {
  r: $rmin/3;
  transform: translate($rmax*1px);
  animation: s $t ease-in-out infinite alternate;
  animation-name: s, x;
	
  &:nth-child(3) {
    cx: -$r;
    animation-delay: -$t, 0s
  }
}

@keyframes m {
  to {
    d: path('M#{-$r} 0A#{$r} #{$r} 0 0 1 #{$r} 0' +
            'A#{$rmax} #{$rmax} 0 0 1 #{$r - 2*$rmax} 0' + 
            'A#{$rmin} #{$rmin} 0 0 0 #{-$r} 0');
  }
}

@keyframes s { to { r: $rmax/3 } }

@keyframes x { to { transform: translate($rmin*1px) } }

视觉效果完全相同,只是代码更少了。

最后一步是让 SVG 元素本身无限旋转

svg { animation: r 2*$t linear infinite }

@keyframes r { to { transform: rotate(1turn) } }

可以在这个 Pen中看到完成的加载动画。

所以,这就是它——一个加载动画,四种不同的方法从头开始为网页重现它。我们在这里探索的并不是所有的内容在今天都可以在实践中使用。例如,对最后一种方法的支持非常差,性能也很糟糕。但是,探索如今正在成为可能的界限是一次有趣的练习,也是一个很好的学习机会。