如今,使用 来创建复杂的形状是一项简单的任务clip-path,但为形状添加边框总是很痛苦。没有强大的 CSS 解决方案,我们总是需要为每个特定情况生成特定的“hacky”代码。在本文中,我将向您展示如何使用 CSS Paint API 解决此问题。
探索 CSS Paint API 系列:
第 1 部分: 图像碎片效应
第 2 部分: Blob 动画
第 3 部分: 多边形边框(您来了! )
第 4 部分: 舍入形状
在我们深入研究第三个实验之前,以下是我们正在构建的内容的简要概述。而且,请注意,我们在这里所做的一切仅在基于 Chromium 的浏览器中受支持,因此您需要在 Chrome、Edge 或 Opera 中查看演示。
你会发现那里没有复杂的 CSS 代码,而是一个通用代码,我们只调整几个变量来控制形状。
主要思想
为了实现多边形边框,我将依靠 CSSclip-path属性和使用 Paint API 创建的自定义蒙版的组合。
我们从一个基本的矩形开始。
我们申请clip-path获得我们的多边形形状。
我们应用自定义蒙版来获得我们的多边形边框
CSS 设置
这是clip-path我们将要执行的步骤的 CSS :.box { --path: 50% 0,100% 100%,0 100%; width: 200px; height: 200px; background: red; display: inline-block; clip-path: polygon(var(--path));}
到目前为止没有什么复杂的,但请注意 CSS 变量的使用--path。整个技巧依赖于那个单一变量。由于我将使用 aclip-path和 a mask,因此两者都需要使用相同的参数,因此是--path变量。而且,是的,Paint API 将使用相同的变量来创建自定义蒙版。
整个过程的CSS代码变为:.box { --path: 50% 0,100% 100%,0 100%; --border: 5px; width: 200px; height: 200px; background: red; display: inline-block; clip-path: polygon(var(--path)); -webkit-mask: paint(polygon-border)}
除了 之外clip-path,我们还应用了自定义蒙版,此外还添加了一个额外的变量--border来控制边框的粗细。如您所见,到目前为止,一切仍然是非常基本和通用的 CSS。毕竟,这是使 CSS Paint API 非常适合使用的原因之一。
JavaScript 设置
我强烈建议阅读我上一篇文章的第一部分,以了解 Paint API 的结构。
现在,让我们看看paint()当我们跳入 JavaScript 时函数内部发生了什么:const points = properties.get('--path').toString().split(',');const b = parseFloat(properties.get('--border').value);const w = size.width;const h = size.height;const cc = function(x,y) { // ...}var p = points[0].trim().split(" ");p = cc(p[0],p[1]);ctx.beginPath();ctx.moveTo(p[0],p[1]);for (var i = 1; i < points.length; i++) { p = points[i].trim().split(" "); p = cc(p[0],p[1]); ctx.lineTo(p[0],p[1]);}ctx.closePath();ctx.lineWidth = 2*b;ctx.strokeStyle = '#000';ctx.stroke();
获取和设置 CSS 自定义属性的能力是它们如此出色的原因之一。我们可以让 JavaScript 首先读取--path变量的值,然后将其转换为点数组(见上面的第一行)。所以,这意味着50% 0,100% 100%,0 100%成为面具的点,即points = ["50% 0","100% 100%","0 100%"]。
然后我们循环遍历这些点以使用moveTo和绘制多边形lineTo。这个多边形与在 CSS 中使用clip-path属性绘制的多边形完全相同。
最后,在绘制完形状后,我给它添加了一个描边。我使用定义了笔触的粗细,并使用lineWidth设置了纯色strokeStyle。换句话说,只有形状的笔触是可见的,因为我没有用任何颜色填充形状(即它是透明的)。
现在我们要做的就是更新路径和厚度以创建任何多边形边界。值得注意的是,我们在这里不限于纯色,因为我们使用的是 CSSbackground属性。我们可以考虑渐变或图像。
现场演示
如果我们需要添加内容,我们必须考虑一个伪元素。否则,内容会在此过程中被剪辑。支持内容并不是非常困难。我们将mask属性移动到伪元素。我们可以保留clip-path主元素上的声明。
到目前为止的问题?
我知道在查看最后一个脚本后,您可能有一些迫切的问题要问。请允许我先发制人地回答一些我敢打赌你会想到的事情。
那是什么cc()功能?
我正在使用该函数将每个点的值转换为像素值。对于每个点,我都得到了x和y坐标 - 使用points.trim().split(" ")- 然后我转换这些坐标,使它们在 canvas 元素中可用,从而允许我们使用这些点进行绘制。const cc = function(x,y) { var fx=0,fy=0; if (x.indexOf('%') > -1) { fx = (parseFloat(x)/100)*w; } else if(x.indexOf('px') > -1) { fx = parseFloat(x); } if (y.indexOf('%') > -1) { fy = (parseFloat(y)/100)*h; } else if(y.indexOf('px') > -1) { fy = parseFloat(y); } return [fx,fy];}
逻辑很简单:如果它是一个百分比值,我使用宽度(或高度)来找到最终值。如果它是一个像素值,我只是简单地得到没有单位的值。例如,如果我们有[50% 20%]宽度等于200px且高度等于 的位置100px,那么我们得到[100 20]。如果是[20px 50px],那么我们得到[20 50]。等等。
clip-path如果遮罩已经将元素剪裁到形状的笔划,为什么还要使用 CSS ?
只使用面具是我想到的第一个想法,但我偶然发现了这种方法的两个主要问题。第一个与stroke()工作方式有关。来自MDN:
笔触与路径的中心对齐;换句话说,笔画的一半画在内侧,一半画在外侧。
那种“一半内,一半外”让我很头疼,而且在把所有东西放在一起时,我总是以一种奇怪的溢出结束。这就是 CSS 的clip-path帮助所在;它夹住了外部,只保留了内侧——不再溢出!
您会注意到ctx.lineWidth = 2*b. 我将边框厚度加倍,因为我将剪下它的一半,以整个形状所需的正确厚度结束。
第二个问题与形状的可悬停区域有关。众所周知,遮罩不会影响该区域,我们仍然可以悬停/与整个矩形进行交互。再次,伸手clip-path解决问题,另外我们将交互限制在形状本身。
下面的演示说明了这两个问题。第一个元素有掩码和剪辑路径,而第二个只有掩码。我们可以清楚地看到溢出问题。尝试将鼠标悬停在第二个上,以查看即使光标位于三角形之外我们也可以更改颜色。
为什么要使用@property边界值?
这是一个有趣且相当棘手的部分。默认情况下,自定义属性(如--border)被视为“CSSUnparsedValue”,这意味着它们被视为字符串。从CSS 规范:
“ CSSUnparsedValue ”对象表示引用自定义属性的属性值。它们由字符串片段列表和变量引用组成。
使用@property,我们可以注册自定义属性并为其指定类型,以便浏览器可以识别它并将其作为有效类型而不是字符串处理。在我们的例子中,我们将边框注册为一种<length>类型,以便稍后它成为CSSUnitValue。这是什么也做是允许我们使用任何长度单位(px,em,ch,vh,等)的边界值。
这听起来可能有点复杂,但让我尝试用 DevTools 屏幕截图来说明差异。
我console.log()在我定义的变量上使用5em。第一个已注册,但第二个未注册。
在第一种情况下,浏览器识别类型并将其转换为像素值,这很有用,因为我们只需要函数内部的像素值paint()。在第二种情况下,我们将变量作为字符串获取,这不是很有用,因为我们无法在函数内部将em单位转换为px单位paint()。
尝试所有单位。它将始终以paint()函数内部的计算像素值作为结果。
怎么样的--path变化?
我想对--path变量使用相同的方法,但不幸的是,我认为我将 CSS 推到了它在这里可以做的极限。使用@property,我们可以注册复杂类型,甚至是多值变量。但这对于我们需要的路径仍然不够。
我们可以使用+和#符号来定义空格分隔或逗号分隔的值列表,但我们的路径是空格分隔的百分比(或长度)值的逗号分隔列表。我会使用类似的东西[<length-percentage>+]#,但它不存在。
对于路径,我不得不将其作为字符串值进行操作。这暂时将我们限制在百分比和像素值上。为此,我定义了cc()将字符串值转换为像素值的函数。
我们可以在CSS 规范中阅读:
语法字符串的内部语法是CSS 值定义语法的一个子集。预期规范的未来级别将扩展允许的语法的复杂性,允许自定义属性更接近 CSS 属性允许的全部范围。
即使语法扩展为能够注册路径,我们仍然会遇到问题,以防我们需要calc()在我们的路径中包含:--path: 0 0,calc(100% - 40px) 0,100% 40px,100% 100%,0 100%;
在上面,calc(100% - 40px)是浏览器认为 a 的值<length-percentage>,但浏览器在知道百分比的引用之前无法计算该值。换句话说,我们无法在paint()函数内部获得等效的像素值,因为只有在var().
为了克服这个问题,我们可以扩展cc()函数来进行转换。我们进行了百分比值和像素值的转换,所以让我们将它们合并为一个转换。我们将考虑 2 种情况:calc(P% - Xpx)和calc(P% + Xpx)。我们的脚本变成:const cc = function(x,y) { var fx=0,fy=0; if (x.indexOf('calc') > -1) { var tmp = x.replace('calc(','').replace(')',''); if (tmp.indexOf('+') > -1) { tmp = tmp.split('+'); fx = (parseFloat(tmp[0])/100)*w + parseFloat(tmp[1]); } else { tmp = tmp.split('-'); fx = (parseFloat(tmp[0])/100)*w - parseFloat(tmp[1]); } } else if (x.indexOf('%') > -1) { fx = (parseFloat(x)/100)*w; } else if(x.indexOf('px') > -1) { fx = parseFloat(x); } if (y.indexOf('calc') > -1) { var tmp = y.replace('calc(','').replace(')',''); if (tmp.indexOf('+') > -1) { tmp = tmp.split('+'); fy = (parseFloat(tmp[0])/100)*h + parseFloat(tmp[1]); } else { tmp = tmp.split('-'); fy = (parseFloat(tmp[0])/100)*h - parseFloat(tmp[1]); } } else if (y.indexOf('%') > -1) { fy = (parseFloat(y)/100)*h; } else if(y.indexOf('px') > -1) { fy = parseFloat(y); } return [fx,fy];}
我们indexOf()用来测试 的存在calc,然后,通过一些字符串操作,我们提取两个值并找到最终的像素值。
因此,我们还需要更新这一行:p = points[i].trim().split(" ");
…到:p = points[i].trim().split(/(?!\(.*)\s(?![^(]*?\))/g);
由于我们需要考虑calc(),使用空格字符不适用于拆分。那是因为calc()也包含空格。所以我们需要一个正则表达式。不要问我——这是在 Stack Overflow 上尝试了很多之后才奏效的方法。
以下是基本演示,用于说明我们迄今为止为支持所做的更新 calc()
请注意,我们已将calc()表达式存储在--v我们注册为<length-percentage>. 这也是技巧的一部分,因为如果我们这样做,浏览器就会使用正确的格式。无论calc()表达式的复杂程度如何,浏览器总是将其转换为格式calc(P% +/- Xpx). 出于这个原因,我们只需要在paint()函数内部处理该格式。
在下面不同的示例中,我们calc()为每个示例使用了不同的表达式:
如果您检查每个框的代码并查看 的计算值--v,您总会发现相同的格式,这非常有用,因为我们可以进行任何我们想要的计算。
应该注意的是,使用变量--v不是强制性的。我们可以calc()直接在路径中包含 。我们只需要确保我们插入了正确的格式,因为浏览器不会为我们处理它(请记住,我们无法注册路径变量,因此它是浏览器的字符串)。当我们需要calc()在路径中有很多并且为每个创建一个变量会使代码太长时,这会很有用。我们将在最后看到一些示例。
我们可以有虚线边框吗?
我们可以!它只需要一个指令。该<canvas>元素已经有一个内置函数来绘制虚线笔划setLineDash():
所述setLineDash() 的帆布2D API的方法CanvasRenderingContext2D接口设置抚摸线时所使用的线虚线图案。它使用一组值来指定描述模式的线和间隙的交替长度。
我们所要做的就是引入另一个变量来定义我们的破折号模式。
现场演示
在 CSS 中,我们简单地添加了一个 CSS 变量 ,--dash掩码中的内容如下:// ...const d = properties.get('--dash').toString().split(',');// ...ctx.setLineDash(d);
我们还可以使用 控制偏移量lineDashOffset。稍后我们将看到控制偏移如何帮助我们实现一些很酷的动画。
为什么不使用@property代替注册破折号变量?
从技术上讲,我们可以将破折号变量注册为 a,<length>#因为它是一个逗号分隔的长度值列表。它确实有效,但 我无法检索函数内的paint()值。我不知道这是一个错误,缺乏支持,还是我只是遗漏了一块拼图。
这是一个演示来说明问题:
我正在--dash使用这个注册变量:@property --dash{ syntax: '<length>#'; inherits: true; initial-value: 0;}
……然后将变量声明为:--dash: 10em,3em;
如果我们检查元素,我们可以看到浏览器正在正确处理变量,因为计算值是像素值
但是我们只得到paint()函数内部的第一个值
在我找到解决方案之前,我一直坚持使用--dash变量作为字符串,例如--path. 在这种情况下没什么大不了的,因为我认为我们不需要超过像素值。
用例!
在探索了这项技术的幕后,现在让我们关注 CSS 部分并查看多边形边框的一些用例。
一组按钮
我们可以轻松生成具有酷炫悬停效果的自定义形状按钮。
请注意如何calc()在最后一个按钮的路径内使用我们之前描述的方式。它工作正常,因为我遵循正确的格式。
面包屑
创建面包屑系统时不再头疼!在下面,您不会发现“hacky”或复杂的 CSS 代码,而是一些非常通用且易于理解的代码,我们只需调整一些变量即可。
卡片显示动画
如果我们对厚度应用一些动画,我们可以获得一些奇特的悬停效果
我们可以使用相同的想法来创建一个展示卡片的动画:
标注和语音气泡
“我们怎么能在那个小箭头上加边框???” 我认为每个人在处理标注或对话气泡类设计时都偶然发现了这个问题。Paint API 使这变得微不足道。
在该演示中,您将找到一些可以扩展的示例。你只需要找到你的对话气泡的路径,然后调整一些变量来控制边框的粗细和箭头的大小/位置。
动画破折号
在我们结束之前的最后一个。这次我们将专注于虚线边框以创建更多动画。我们已经在按钮集合中做了一个,将虚线边框转换为实心边框。让我们解决另外两个问题。
将鼠标悬停在下面,看看我们得到的不错的效果:
使用过 SVG 一段时间的人可能熟悉我们通过动画实现的排序效果stroke-dasharray。克里斯甚至在不久前解决了这个概念。感谢 Paint API,我们可以直接在 CSS 中做到这一点。这个想法几乎与我们在 SVG 中使用的想法相同。我们定义破折号变量:--dash: var(--a),1000;
变量--a从 开始0,所以我们的模式是一条实线(长度等于 0),有一个间隙(长度为 1000);因此没有边界。我们将动画设置--a为一个很大的值来绘制我们的边界。
我们还讨论了 using lineDashOffset,我们可以将其用于另一种动画。将鼠标悬停在下方并查看结果:
最后,一个 CSS 解决方案可以为适用于任何形状的破折号的位置设置动画!
我所做的非常简单。我添加了一个额外的变量 ,--offset我对其应用了从0到的转换N。然后,在paint()函数内部,我执行以下操作:const o = properties.get('--offset');ctx.lineDashOffset=o;
就如此容易!让我们不要忘记使用关键帧的无限动画:
我们可以通过抵消连续运行的动画0到N哪里N是在仪表变量(在我们的情况下,使用的值的总和10+15=25)。我们使用负值来表示相反的方向。