大家好,我是前端西瓜哥。
圆角矩形,其实就是在矩形的基础上在四个角上加上 4 个圆角。
属性上相比矩形多了名为 cornerRadius 属性。
在 CSS 中,这个属性叫做 border-radius,可以指定所有圆角的半径,或指定每个圆角的半径,也可以指定为椭圆。CSS 的属性的值的写法也是异常灵活。
在 SVG 中的 rect 元素中,属性是 rx、ry,表达的是所有圆角的 椭圆 半径,不支持指定单个圆角的半径。如果要指定单个圆角,我们在渲染上通常会改为用 path 去拟合。
这里会忽略椭圆角的场景,假设都是圆角。椭圆会有另一套规则,目前我用不到。
圆角修正
指定圆角的值,然后 4 个角就画出对应半径的圆角,看起来一切都运转良好。
但有一个重要的问题,如果矩形宽高也不足以分配半径,该如何处理?
比如宽只有 5,但左上圆角半和右上圆角的半径和超过了 5。
为此需要设计一套规则,在必要的时候修正圆角半径值。
这里我选择使用 Figma 的规则:
-
求某圆角半径的修正值,分别计算对应两边的对应的圆角的半径和,判断是否超出边长度;
-
如果没超出边长度,修正值为原值;
-
如果超出边长度,则基于比例进行分配,作为修正值。
-
取 2 个修正值中最小的一个。
以下图为例,
左上圆角为 5,右上圆角为 4,它们的和为 9,小于等于宽 10,不需要重新计算,修正值为原值 5;
右下角为 6,相加为 11,超过了 10,按比例重新分配 5/(5+6)
乘以变长得到 4.545,作为修正值;
取其中最小值作为最终修正值。
这样就能保证这个圆角在修正后和相邻圆角有完全足够的放置空间。
它有一个缺点,就是一些场景下不能充分利用空间。一些大半径明明看着两边都有空间,但依旧很小。
这是因为相邻圆角过大,强行让当前圆角按比例变小了,但此时相邻圆角可能也因为其他圆角而变形,最后也没有使用这些空间,所以看起来没有被充分利用。
代码实现
/* 计算修正后的圆角半径 */
const calcRenderCornerRadii = (size, radii) => {
const edges = [size.width, size.height, size.width, size.height];
const correctedRadii = [0, 0, 0, 0];
for (let i = 0; i < radii.length; i++) {
const r = radii[i];
// 半径为 0,其实没啥好修正的,不可能更小了
if (r === 0) {
correctedRadii[i] = r;
continue;
}
const prevIndex = (i - 1 + edges.length) % edges.length;
const nextIndex = (i + 1) % edges.length;
const edge1 = edges[prevIndex];
const edge2 = edges[i];
const r1 = radii[prevIndex];
const r2 = radii[nextIndex];
let correctedR1 = r;
let correctedR2 = r;
// 计算修正值 1
if (r1 + r > edge1) {
correctedR1 = (r / (r1 + r)) * edge1;
}
// 计算修正值 2
if (r2 + r > edge2) {
correctedR2 = (r / (r2 + r)) * edge2;
}
// 取小的
correctedRadii[i] = Math.min(correctedR1, correctedR2);
}
return correctedRadii;
};
圆弧拟合
本文讨论的是如何将圆弧转路径(path),下面看下转换的逻辑。
路径为了进行简化,通常是转为直线和三阶贝塞尔曲线的组合,很多图形编辑器都是这样的。
因此我们需要将圆角拟合成三阶贝塞尔(cubic bezier)。
用三阶贝塞尔拟合圆弧我之前详细讲解过,这里就不展开讲了,可以看我的这篇文章:
这里是 1/4 圆,拟合用到的 K 为 0.5522847498307936
,后面我们直接实现根据不同的圆角朝向套公式就好了。
我们用 SVG 的 path 的表达方式,长这样子:
const pathCmds = [
{
type: 'M', // 起始点
points: [
{
x: 0,
y: 2,
},
],
},
{
type: 'C', // cubic 三阶贝塞尔
points: [
{ // 控制点 1
x: 0,
y: 0.9,
},
{ // 控制点 2
x: 0.9,
y: 0,
},
{ // 锚点 2
x: 2,
y: 0,
},
],
},
{
type: 'L', // 直线
points: [
{
x: 6,
y: 0,
},
],
},
// ...
{
type: 'Z', // 闭合
points: [],
},
];
我们从第一个圆角开始(M),顺时针不断地新增曲线(C)和直线(L),围成最终路径,最后记得用 Z 命令闭合。
这里有两个点注意:
-
圆角半径为 0 时,直接跳过;
-
直线可能也不需要画,看上一个圆角(修正后)和下一个圆角(修正)的值是否超过当前边,不超过,才需要新增 L 绘制直线命令。
代码实现
const K = 0.5522847498307936;
const roundRectToPathCmds = (
rect: IRect,
cornerRadii: number[] = [],
): IPathCommand[] => {
// 得到修正好的圆角半径值数组
const radii = calcCorrectedCornerRadii(rect, cornerRadii);
const { minX, minY, maxX, maxY } = rectToBox(rect);
const commands: IPathCommand[] = [
{ type: 'M', points: [{ x: minX, y: minY + radii[0] }] },
];
// left top
if (radii[0]) {
commands.push({
type: 'C',
points: [
{ x: minX, y: minY + radii[0] - radii[0] * K },
{ x: minX + radii[0] - radii[0] * K, y: minY },
{ x: minX + radii[0], y: minY },
],
});
}
// top line (skip if full width)
if (radii[0] + radii[1] < rect.width) {
commands.push({
type: 'L',
points: [{ x: maxX - radii[1], y: minY }],
});
}
// right top
if (radii[1]) {
commands.push({
type: 'C',
points: [
{ x: maxX - radii[1] + radii[1] * K, y: minY },
{ x: maxX, y: minY + radii[1] - radii[1] * K },
{ x: maxX, y: minY + radii[1] },
],
});
}
// right line (skip if full height)
if (radii[1] + radii[2] < rect.height) {
commands.push({
type: 'L',
points: [{ x: maxX, y: maxY - radii[2] }],
});
}
// right bottom
if (radii[2]) {
commands.push({
type: 'C',
points: [
{ x: maxX, y: maxY - radii[2] + radii[2] * K },
{ x: maxX - radii[2] + radii[2] * K, y: maxY },
{ x: maxX - radii[2], y: maxY },
],
});
}
// bottom line (skip if full width)
if (radii[2] + radii[3] < rect.width) {
commands.push({
type: 'L',
points: [{ x: minX + radii[3], y: maxY }],
});
}
// left bottom
if (radii[3]) {
commands.push({
type: 'C',
points: [
{ x: minX + radii[3] - radii[3] * K, y: maxY },
{ x: minX, y: maxY - radii[3] + radii[3] * K },
{ x: minX, y: maxY - radii[3] },
],
});
}
// left line (skip if full height)
if (radii[3] * radii[1] <= rect.height) {
commands.push({
type: 'L',
points: [{ x: minX, y: minY + radii[0] }],
});
}
// close
commands.push({
type: 'Z',
points: [],
});
return commands;
};
完整实现
下面是完整的 typeScript 实现。
interface IRect {
x: number;
y: number;
width: number;
height: number;
}
interface ISize {
width: number;
height: number;
}
interface IBox {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
const rectToBox = (rect: IRect): IBox => {
return {
minX: rect.x,
minY: rect.y,
maxX: rect.x + rect.width,
maxY: rect.y + rect.height,
};
};
interface IPoint {
x: number;
y: number;
}
interface IPathCommand {
type: string;
points: IPoint[];
}
const calcCorrectedCornerRadii = (size: ISize, radii: number[]) => {
const edges = [size.width, size.height, size.width, size.height];
const correctedRadii = [0, 0, 0, 0];
for (let i = 0; i < radii.length; i++) {
const r = radii[i];
if (r === 0) {
correctedRadii[i] = r;
continue;
}
const prevIndex = (i - 1 + edges.length) % edges.length;
const nextIndex = (i + 1) % edges.length;
const edge1 = edges[prevIndex];
const edge2 = edges[i];
const r1 = radii[prevIndex];
const r2 = radii[nextIndex];
let correctedR1 = r;
let correctedR2 = r;
if (r1 + r > edge1) {
correctedR1 = (r / (r1 + r)) * edge1;
}
if (r2 + r > edge2) {
correctedR2 = (r / (r2 + r)) * edge2;
}
correctedRadii[i] = Math.min(correctedR1, correctedR2);
}
return correctedRadii;
};
const K = 0.5522847498307936;
const roundRectToPathCmds = (
rect: IRect,
cornerRadii: number[] = [],
): IPathCommand[] => {
const radii = calcCorrectedCornerRadii(rect, cornerRadii);
const { minX, minY, maxX, maxY } = rectToBox(rect);
const commands: IPathCommand[] = [
{ type: 'M', points: [{ x: minX, y: minY + radii[0] }] },
];
// left top
if (radii[0]) {
commands.push({
type: 'C',
points: [
{ x: minX, y: minY + radii[0] - radii[0] * K },
{ x: minX + radii[0] - radii[0] * K, y: minY },
{ x: minX + radii[0], y: minY },
],
});
}
// top line (skip if full width)
if (radii[0] + radii[1] < rect.width) {
commands.push({
type: 'L',
points: [{ x: maxX - radii[1], y: minY }],
});
}
// right top
if (radii[1]) {
commands.push({
type: 'C',
points: [
{ x: maxX - radii[1] + radii[1] * K, y: minY },
{ x: maxX, y: minY + radii[1] - radii[1] * K },
{ x: maxX, y: minY + radii[1] },
],
});
}
// right line (skip if full height)
if (radii[1] + radii[2] < rect.height) {
commands.push({
type: 'L',
points: [{ x: maxX, y: maxY - radii[2] }],
});
}
// right bottom
if (radii[2]) {
commands.push({
type: 'C',
points: [
{ x: maxX, y: maxY - radii[2] + radii[2] * K },
{ x: maxX - radii[2] + radii[2] * K, y: maxY },
{ x: maxX - radii[2], y: maxY },
],
});
}
// bottom line (skip if full width)
if (radii[2] + radii[3] < rect.width) {
commands.push({
type: 'L',
points: [{ x: minX + radii[3], y: maxY }],
});
}
// left bottom
if (radii[3]) {
commands.push({
type: 'C',
points: [
{ x: minX + radii[3] - radii[3] * K, y: maxY },
{ x: minX, y: maxY - radii[3] + radii[3] * K },
{ x: minX, y: maxY - radii[3] },
],
});
}
// left line (skip if full height)
if (radii[3] * radii[1] <= rect.height) {
commands.push({
type: 'L',
points: [{ x: minX, y: minY + radii[0] }],
});
}
// close
commands.push({
type: 'Z',
points: [],
});
return commands;
};
用法:
const pathCmds = roundRectToPathCmds(
{ x: 0, y: 0, width: 10, height: 10 },
[5, 4, 0, 6],
);
看看效果
结尾
我是前端西瓜哥,关注我,学习更多平面几何知识。
相关阅读,