探索 CSS Paint API:圆角形状

Avatar of Temani Afif
Temani Afif

DigitalOcean 提供适用于您旅程各个阶段的云产品。立即开始使用 $200 免费试用额度!

为复杂形状添加边框很麻烦,但为复杂形状圆角简直是噩梦!幸运的是,CSS Paint API 可以帮您解决这个问题!这就是我们要在本系列文章的 “探索 CSS Paint API” 中探讨的内容。

探索 CSS Paint API 系列


这就是我们的目标。与我们在这个系列中探讨的其他一切一样,请注意目前只有 Chrome 和 Edge 支持此功能。

实时演示

如果您一直关注本系列文章的其他部分,您可能会注意到一个模式正在形成。通常,当我们使用 CSS Paint API 时

  • 我们会编写一些易于调整的基本 CSS。
  • 所有复杂的逻辑都在幕后,在 paint() 函数中完成。

实际上我们可以在没有 Paint API 的情况下做到这一点

可能有很多方法可以在复杂形状上添加圆角,但我将与您分享我在自己的工作中使用过的三种方法。

我已经听到您说:如果您已经知道 三种 方法,那么您为什么要使用 Paint API 呢? 好问题。我使用它是因为我所知道的这三种方法都很困难,而且其中两种方法与 SVG 特别相关。我对 SVG 没什么意见,但纯 CSS 解决方案使维护工作更容易,而且在理解代码时更容易让人理解。

关于这三种方法...

使用 clip-path: path()

如果您是 SVG 大师,此方法适合您。clip-path 属性接受 SVG 路径。这意味着我们可以轻松地传入复杂圆角形状的路径并完成。如果您已经拥有所需的形状,这种方法非常容易,但如果您想要可调整形状(例如,您想要调整半径),则不合适。

以下是一个圆角六边形形状的示例。尝试调整曲率和形状大小吧!您将不得不编辑那个看起来很奇怪的路径才能做到。

我想您可以参考克里斯编写的 SVG 路径语法图解指南。但即使参考了该指南,绘制出您想要的点和曲线仍然需要大量工作。

使用 SVG 滤镜

我从 Lucas Bebber 关于创建果冻效果的帖子 中发现了这种技巧。您可以在那里找到所有技术细节,但其理念是将 SVG 滤镜应用于任何元素以使其圆角。

我们只需使用 clip-path 创建我们想要的形状,然后在父元素上应用 SVG 滤镜。要控制半径,我们调整 stdDeviation 变量。

这是一个很好的技巧,但同样,它需要深入了解 SVG 知识才能当场进行调整。

使用 Ana Tudor 的纯 CSS 方法

是的,Ana Tudor 找到了一个纯 CSS 技巧来实现果冻效果,我们可以用它来为复杂形状添加圆角。她可能现在正在写一篇关于它的文章。在此之前,您可以参考 她制作的幻灯片,其中她解释了它的工作原理

以下是一个示例,我用她的技巧替换了 SVG 滤镜

再次,另一个巧妙的技巧!但就易于使用而言?这里也不太好,尤其是在我们考虑需要透明度、图像等更复杂情况时。要找到正确的 filtermix-blend-mode 和其他属性组合才能使一切正常,需要付出一些努力。

改用 CSS Paint API

除非您有杀手级的纯 CSS 方法可以为复杂形状添加圆角,而您一直瞒着我(快分享吧!),否则您可能会明白我为什么决定使用 CSS Paint API。

这背后的逻辑依赖于我在 介绍多边形边框的文章中 使用的相同代码结构。我使用的是 --path 变量来定义我们的形状,使用 cc() 函数来转换我们的点,以及我们在过程中将要介绍的其他一些技巧。我强烈建议您阅读那篇文章,以更好地理解我们在这里做的事情。

首先,CSS 设置

我们首先从一个经典的矩形元素开始,并在 --path 变量中定义我们的形状(上面的形状 2)。--path 变量的行为与我们在 clip-path: polygon() 中定义的路径相同。使用 Clippy 来生成它。

.box {
  display: inline-block;
  height: 200px;
  width: 200px;

  --path: 50% 0,100% 100%,0 100%;
  --radius: 20px;
  -webkit-mask: paint(rounded-shape);
}

到目前为止还没有什么复杂的东西。我们应用自定义蒙版,并定义 --path--radius 变量。后者将用于控制曲率。

接下来,JavaScript 设置

除了由路径变量定义的点(上面显示为红色点)之外,我们还添加了更多点(上面显示为绿色点),这些点只是形状每段的中点。然后我们使用 arcTo() 函数来构建最终形状(上面的形状 4)。

添加中点非常容易,但使用 arcTo() 有点棘手,因为我们必须理解它的工作原理。 根据 MDN

[它] 在当前子路径中添加一个圆弧,使用给定的控制点和半径。如果指定参数需要,则会自动用一条直线将该圆弧连接到路径的最新点。

此方法通常用于创建圆角。

此方法需要控制点是额外中点的主要原因。它还需要半径(我们将其定义为名为 --radius 的变量)。

如果我们继续阅读 MDN 的文档

理解 arcTo() 的一种方法是想象两条直线段:一条从起点到第一个控制点,另一条从那里到第二个控制点。没有 arcTo(),这两条线段会形成一个尖角:arcTo() 创建一个适合此尖角并将其平滑的圆弧。换句话说,圆弧与这两条线段相切。

每个圆弧/尖角都使用三个点构建。如果您查看上面的图形,请注意,对于每个尖角,我们每侧都有一个红色点和两个绿色点。每个红绿组合都会创建一个线段,以获得上面详细介绍的两个线段。

让我们放大一个尖角,以更好地理解正在发生的事情

我们用黑色说明了两个线段。
蓝色圆圈说明半径。

现在想象一下,我们有一条路径,从第一个绿色点到下一个绿色点,绕着那个圆圈移动。我们对每个尖角执行此操作,就得到了我们的圆角形状。

以下是代码中的样子

// We first read the variables for the path and the radius.
const points = properties.get('--path').toString().split(',');
const r = parseFloat(properties.get('--radius').value);

var Ppoints = [];
var Cpoints = [];
const w = size.width;
const h = size.height;
var N = points.length;
var i;
// Then we loop through the points to create two arrays.
for (i = 0; i < N; i++) {
  var j = i-1;
  if(j<0) j=N-1;
  
  var p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
  // One defines the red points (Ppoints)
  p = cc(p[0],p[1]);
  Ppoints.push([p[0],p[1]]);
  var pj = points[j].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
  pj = cc(pj[0],pj[1]);
  // The other defines the green points (Cpoints)
  Cpoints.push([p[0]-((p[0]-pj[0])/2),p[1]-((p[1]-pj[1])/2)]);
}

/* ... */

// Using the arcTo() function to create the shape
ctx.beginPath();
ctx.moveTo(Cpoints[0][0],Cpoints[0][1]);
for (i = 0; i < (Cpoints.length - 1); i++) {
  ctx.arcTo(Ppoints[i][0], Ppoints[i][1], Cpoints[i+1][0],Cpoints[i+1][1], r);
}
ctx.arcTo(Ppoints[i][0], Ppoints[i][1], Cpoints[0][0],Cpoints[0][1], r);
ctx.closePath();

/* ... */

ctx.fillStyle = '#000';
ctx.fill();

最后一步是用纯色填充我们的形状。现在我们有了圆角形状,可以将其用作任何元素的蒙版。

就是这样!现在我们要做的就是构建我们的形状并像我们想要的那样控制半径——一个我们可以使用@property来进行动画的半径,这会让事情变得更加有趣!

实时演示

这种方法有什么缺点吗?

是的,它确实有一些缺点,你可能在最后一个例子中已经注意到了。第一个缺点与可悬停区域有关。由于我们使用的是mask,我们仍然可以与初始矩形形状进行交互。请记住,我们在使用多边形边框时遇到了同样的问题,并且使用了clip-path来解决它。不幸的是,clip-path在这里没有帮助,因为它也会影响圆角。

让我们以最后一个例子为例,添加clip-path。注意我们是如何丢失了“内向”曲线的。

六边形和三角形形状没有问题,但其他的形状缺少一些曲线。只保留“外向”曲线(借助clip-path)同时修复可悬停区域可能是一个有趣的特性。但我们不能同时保留所有曲线并减少可悬停区域。

第二个问题?它与使用较大的半径值有关。将鼠标悬停在下面的形状上,看看我们得到了哪些疯狂的结果。

这实际上不是一个“主要”缺点,因为我们可以控制半径,但如果我们错误地使用了一个过大的半径值,最好避免这种情况。我们可以通过将半径值限制在一个范围内来解决这个问题,该范围将其限制在最大值。对于每个角,我们计算一个半径,它允许我们拥有最大的弧度,而不会有任何溢出。我不会深入探讨背后的数学逻辑(😱),但以下是如何限制半径值的最终代码。

var angle = 
Math.atan2(Cpoints[i+1][1] - Ppoints[i][1], Cpoints[i+1][0] - Ppoints[i][0]) -
Math.atan2(Cpoints[i][1]   - Ppoints[i][1], Cpoints[i][0]   - Ppoints[i][0]);
if (angle < 0) {
  angle += (2*Math.PI)
}
if (angle > Math.PI) {
  angle = 2*Math.PI - angle
}
var distance = Math.min(
  Math.sqrt(
    (Cpoints[i+1][1] - Ppoints[i][1]) ** 2 + 
    (Cpoints[i+1][0] - Ppoints[i][0]) ** 2),
  Math.sqrt(
    (Cpoints[i][1] - Ppoints[i][1]) ** 2 + 
    (Cpoints[i][0] - Ppoints[i][0]) ** 2)
  );
var rr = Math.min(distance * Math.tan(angle/2),r);

r是我们定义的半径,rr是我们实际使用的半径。它等于r或没有溢出允许的最大值。

如果你将鼠标悬停在该演示中的形状上,我们将不再获得奇怪的形状,而是“最大圆角形状”(我刚刚创造了这个词)。注意,规则多边形(如三角形和六边形)在逻辑上具有一个圆作为它们的“最大圆角形状”,因此我们可以拥有不同的形状之间的酷炫过渡或动画。

我们可以有边框吗?

可以!我们所要做的就是在我们的paint()函数中使用stroke()而不是fill()。因此,我们不使用

ctx.fillStyle = '#000';
ctx.fill();

…而是使用这个

ctx.lineWidth = b;
ctx.strokeStyle = '#000';
ctx.stroke();

这引入了另一个变量b,它控制边框的粗细。

你是否注意到我们有一些奇怪的溢出?我们在上一篇文章中遇到了同样的问题,这是由于stroke()的工作方式造成的。我在那篇文章中引用了MDN,在这里也会再次引用。

描边与路径的中心对齐;换句话说,一半的描边绘制在内部,另一半绘制在外部。

同样,“一半内侧,一半外侧”让我们很头疼!为了解决这个问题,我们需要使用另一个蒙版来隐藏外侧,第一个是我们使用fill()的蒙版。首先,我们需要在paint()函数中引入一个条件变量,以选择我们是想绘制形状还是只绘制其边框。

以下就是我们的内容

if(t==0) {
  ctx.fillStyle = '#000';
  ctx.fill();
} else {
  ctx.lineWidth = 2*b;
  ctx.strokeStyle = '#000';
  ctx.stroke();
}

接下来,我们将第一种类型的蒙版(t=0)应用于主元素,并将第二种类型(t=1)应用于伪元素。应用于伪元素的蒙版会生成边框(带有溢出问题的边框)。应用于主元素的蒙版通过隐藏边框的外侧来解决溢出问题。如果你想知道,这就是我们将边框粗细的两倍添加到lineWidth的原因。

实时演示

看到了吗?我们有完美的圆角形状作为轮廓,我们可以调整悬停时的半径。并且可以在形状上使用任何类型的背景。

我们只用了一点 CSS 就做到了这一切。

div {
  --radius: 5px; /* Defines the radius */
  --border: 6px; /* Defines the border thickness */
  --path: /* Define your shape here */;
  --t: 0; /* The first mask on the main element */
  
  -webkit-mask: paint(rounded-shape);
  transition: --radius 1s;
}
div::before {
  content: "";
   background: ..; /* Use any background you want */
  --t: 1; /* The second mask on the pseudo-element */
  -webkit-mask: paint(rounded-shape); /* Remove this if you want the full shape */
}
div[class]:hover {
  --radius: 80px; /* Transition on hover */
}

让我们不要忘记,我们可以使用setLineDash()轻松地引入虚线,就像我们在上一篇文章中做的那样。

实时演示

控制半径

在我们看到的所有示例中,我们始终考虑一个应用于每个形状所有角的半径。如果我们可以像border-radius属性最多接受四个值那样,单独控制每个角的半径,那就很有意思了。所以让我们扩展--path变量来考虑更多参数。

实际上,我们的路径可以表示为[x y]值的列表。我们将创建一个[x y r]值的列表,其中我们将引入第三个值表示半径。此值不是必需的;如果省略,它将回退到主半径。

.box {
  display: inline-block;
  height: 200px;
  width: 200px;

  --path: 50% 0 10px,100% 100% 5px,0 100%;
  --radius: 20px;
  -webkit-mask: paint(rounded-shape);
}

在上面,第一个角的半径为10px,第二个角的半径为5px,由于我们没有为第三个角指定值,它将继承由--radius变量定义的20px

以下是我们用于值的 JavaScript 代码

var Radius = [];
// ...
var p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
if(p[2])
  Radius.push(parseInt(p[2]));
else
  Radius.push(r);

这定义了一个数组,它存储每个角的半径。然后,在拆分每个点的值后,我们测试是否有第三个值(p[2])。如果已定义,我们使用它;如果没有,我们使用默认半径。之后,我们使用Radius[i]而不是r

实时演示

这个小小的添加对于我们想要禁用形状特定角的半径时是一个不错的功能。实际上,让我们在接下来的几个不同的示例中看一下。

更多示例!

我使用这个技巧制作了一系列演示。我建议将半径设置为 0,以便更好地查看形状并了解路径是如何创建的。请记住,--path变量的行为与我们在clip-path: polygon()中定义的路径相同。如果你正在寻找一个可以使用的路径,尝试使用 Clippy 生成一个路径.

示例 1:CSS 形状

可以使用这种技术创建许多花哨的形状。以下是一些没有使用任何额外的元素、伪元素或 hack 代码制作的形状。

示例 2:对话框

上一篇文章中,我们在对话框元素上添加了边框。现在我们可以改进它并使用这种新方法来圆角。

如果你将这个示例与原始实现进行比较,你可能会注意到完全相同的代码。我只对 CSS 进行了一两处更改以使用新的 Worklet。

示例 3:框架

以下是一些用于你的内容的酷炫框架。当我们需要渐变边框时,不再会有头疼的问题!

只需使用--path变量来创建你自己的响应式框架,并使用你想要的任何颜色。

示例 4:分隔符

现在不再需要 SVG 来创建那些现在很流行的波浪形分隔符。

请注意,CSS 很轻并且相对简单。我只是更新了路径以生成分隔符的新实例。

示例 5:导航菜单

以下是一个经典的设计模式,我相信我们中的许多人都在某个时候遇到过:我们如何反转半径?你可能在导航设计中见过它。

一种略微不同的做法。

示例 6:粘稠效果

如果我们玩弄路径值,可以实现一些花哨的动画。
下面是一个想法,我将过渡应用于路径的一个值,但我们得到的效果相当酷。

这个灵感来自 Ana Tudor 的演示

另一个有不同动画的想法

另一个更复杂的动画示例

弹跳球怎么样

示例 7:形状变形

玩弄较大的半径值,我们可以创建不同形状之间的酷炫过渡,尤其是在圆形和规则多边形之间。

如果我们添加一些边框动画,我们将得到“呼吸”形状!

让我们把这件事总结一下

我希望你喜欢深入了解 CSS Paint API。在本系列中,我们将 `paint()` 应用于许多现实生活中的示例,其中拥有 API 使我们能够以以前无法通过 CSS 实现的方式操作元素——或者无需求助于黑客或疯狂的魔数。我坚信 CSS Paint API 使看似复杂的问题变得更容易以直接的方式解决,并将成为我们反复使用的功能。也就是说,当浏览器支持赶上它的时候。

如果你一直关注本系列,或者只是偶然发现本文,我很想知道你对 CSS Paint API 的看法,以及你如何想象在工作中使用它。是否有当前的设计趋势会从中受益,比如波浪形的分割线?或 blob 设计?实验并享受乐趣!

这个来自 我之前的文章


探索 CSS Paint API 系列