作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Bojan Kverh的头像

Bojan Kverh

博士(CE),曾任助理教授,有20多年的工作经验, Bojan是真正的C/ c++, Linux, and Qt expert.

Expertise

工作经验

28

Share

Introduction

The 当前图形设计的趋势 就是在各种形状中使用很多圆角. 我们可以在许多网页、移动设备和桌面应用程序上观察到这一事实. 最显著的例子是应用程序按钮, 当单击时,哪些用于触发某些操作. 而不是严格意义上的90度角矩形, 它们通常被画成圆角. 圆角让用户界面感觉更流畅、更美观. 我并不完全相信这一点,但我的设计师朋友告诉我.

圆角让用户界面感觉更流畅、更美观

圆角让用户界面感觉更流畅、更美观.

用户界面的视觉元素是由设计师创造的, 程序员只需要把它们放在正确的位置. But what happens, 当我们必须在飞行中生成圆角形状时, 我们不能预加载它? 一些编程库在创建带有圆角的预定义形状方面提供了有限的功能, but usually, 它们不能用于更复杂的情况. For example, Qt framework has a class QPainter,用于绘制派生的所有类 QPaintDevice,包括小部件、像素图和图像. 它有一个方法叫做 drawRoundedRect,顾名思义,它绘制一个圆角矩形. 但如果我们需要更复杂的形状,我们必须自己实现它. 对于多边形,一个由一组直线线段围成的平面形状,我们如何做到这一点呢? 如果我们用铅笔在纸上画一个多边形, 我的第一个想法是使用橡皮擦并删除每个角落的一小部分线条,然后用圆弧连接剩余的部分. 整个过程如下图所示.

如何手动创建圆角

Class QPainter 是否有一些重载的方法命名 drawArc,可以画圆弧. 它们都需要参数, 哪一个定义了圆弧的中心和大小, 起始角和弧长. 而对于一个未旋转的矩形,很容易确定这些参数的必要值, 当我们处理更复杂的多边形时,情况就完全不同了. 另外,我们必须对每个多边形顶点重复这个计算. 这种计算是一项冗长而令人厌烦的工作, 在这个过程中,人类很容易出现各种计算错误. However, 让计算机为人类工作是软件开发人员的工作, 反之亦然. So, 这里我将展示如何开发一个简单的类, 哪一个可以把一个复杂的多边形变成一个圆角的形状. 该类的用户只需要附加多边形顶点,而该类将完成其余的工作. 我在这个任务中使用的基本数学工具是 Bezier curve.

Bezier curves

有很多数学书籍和网络资源描述了贝塞尔曲线的理论, 所以我将简要概述相关属性.

By definition, 贝塞尔曲线是二维曲面上两点之间的曲线, 轨迹由一个或多个控制点控制. 严格地说, 没有附加控制点的两点之间的曲线, 也是一条贝塞尔曲线. However, 因为这导致两点之间有一条直线, 它不是特别有趣, nor useful.

二次贝塞尔曲线

二次贝塞尔曲线有一个控制点. 该理论认为两点之间的二次贝塞尔曲线 P0 and P2 有控制点 P1 定义如下:

B(t) = (1 - t)2P0 + 2t(1 - t)P1 + t2P2,其中0≤t≤1 (1)

So when t is equal to 0, B(t) will yield P0, when t is equal to 1, B(t) will yield P2,但在其他情况下,的值 B(t) 也将取决于 P1. 因为这个表达式 2t(1 - t) 最大值是 t = 0.5,这就是影响的地方 P1 on B(t) 将是最伟大的. We can think of P1 就像一个假想的重力源,它把函数轨迹拉向自己. 下图显示了一些二次贝塞尔曲线的例子及其起点, 终点和控制点.

二次贝塞尔曲线

那么,我们如何用贝塞尔曲线来解决这个问题呢? 下图给出了一个解释.

如何使用代码创建圆角

如果我们想象删除一个多边形顶点和它周围连接线段的一小部分, 我们可以把线段的末端看作 P0,另一线段的端点为 P2 被删除的顶点是 P1. 我们对这组点应用二次贝塞尔曲线,瞧, 这是我们想要的圆角.

使用c++ /Qt实现QPainter

Class QPainter 没有办法画出二次贝塞尔曲线吗. 虽然按照公式(1)从头开始实现它很容易, Qt库提供了一个更好的解决方案. 还有另一个强大的2D绘图类: QPainterPath. Class QPainterPath 是线条和曲线的集合,以后可以添加和使用 QPainter object. 有一些重载方法可以将贝塞尔曲线添加到当前集合中. 特别是方法 quadTo 会加一条二次贝塞尔曲线吗. 曲线从电流开始 QPainterPath point (P0), while P1 and P2 必须传递给 quadTo as parameters.

QPainter’s method drawPath 是用来画直线和曲线的集合吗 QPainterPath 对象,它必须作为参数给定,具有活动的笔和画笔.

让我们看看类的声明:

类RoundedPolygon:公共QPolygon
{
public:
    RoundedPolygon()
    {    SetRadius(10); }
    无效SetRadius(unsigned int radius)
    {    m_iRadius = iRadius; }
    const QPainterPath& GetPath();

private:
    GetLineStart(int i) const;
    QPointF GetLineEnd(int i) const;
    浮点GetDistance(QPoint pt1, QPoint pt2) const;
private:
    QPainterPath m_path;
    unsigned int m_radius;
};

我决定子类化 QPolygon 这样我就不用自己实现添加顶点和其他东西了. 除了构造函数, 它只是把半径设为一个合理的初始值, 这个类还有另外两个公共方法:

  • SetRadius 方法将半径设置为给定值. 半径是每个顶点附近直线的长度(以像素为单位), 哪些将被删除(或?, more precisely, (未绘制)为圆角.
  • GetPath 是所有计算发生的地方吗. 它将返回 QPainterPath 对象从多边形生成的点添加到 RoundedPolygon.

来自私有部分的方法只是辅助方法 GetPath.

让我们看看实现,我将从私有方法开始:

GetDistance(QPoint pt1, QPoint pt2) const
{
    float fD = (pt1.x() - pt2.x())*(pt1.x() - pt2.x()) +
   		 (pt1.y() - pt2.y()) * (pt1.y() - pt2.y());
    返回sqrtf (fD);
}

这里不需要解释太多,该方法返回给定两点之间的欧几里得距离.

QPointF RoundedPolygon::GetLineStart(int i) const
{
    QPointF pt;
    QPoint pt1 = at(i);
    QPoint pt2 = at((i+1) % count());
    float fRat = m_uirdius / GetDistance(pt1, pt2);
    if (fRat > 0.5f)
   	 fRat = 0.5f;

    pt.setX((1.0f-fRat)*pt1.x() + fRat*pt2.x());
    pt.setY((1.0f-fRat)*pt1.y() + fRat*pt2.y());
    return pt;
}

Method GetLineStart 计算点的位置 P2 从上一个图中,如果点按顺时针方向添加到多边形中. 更准确地说,它会返回一个点,也就是 m_uiRadius pixels away from i方向上的第一个顶点 (i+1)-th vertex. 在访问 (i+1)-th vertex, 我们要记住在多边形中, 最后一个顶点和第一个顶点之间也有一条线段, 这使得它是一个闭合的形状, 因此这个表达式 (i+1)%count(). 这还可以防止方法超出范围而访问第一个点. Variable fRat 保存半径与。之间的比率 i-线段长度. 还有一种检查可以防止 fRat 从有一个值 0.5. If fRat had a value over 0.5,那么两个连续的圆角就会重叠,视觉效果就会很差.

当从点出发时 P1 to P2 在一条直线上,完成30%的距离, 我们可以用公式确定我们的位置 0.7 • P1 + 0.3 • P2. 一般来说,如果我们达到了整个距离的一小部分 α = 1 表示完整距离,当前位置为 (1 - α)•p1 + α•p2.

This is how the GetLineStart 方法确定该点的位置 m_uiRadius pixels away from i方向上的第一个顶点 (i+1)-th.

QPointF RoundedPolygon::GetLineEnd(int i) const
{
    QPointF pt;
    QPoint pt1 = at(i);
    QPoint pt2 = at((i+1) % count());
    float fRat = m_uirdius / GetDistance(pt1, pt2);
    if (fRat > 0.5f)
   	 fRat = 0.5f;
    pt.setX(fRat*pt1.x() + (1.0f - fRat)*pt2.x());
    pt.setY(fRat*pt1.y() + (1.0f - fRat)*pt2.y());
    return pt;
}

这种方法与 GetLineStart. 它计算点的位置 P0 for the (i+1)-th vertex, not i-th. 换句话说,如果我们从 GetLineStart(i) to GetLineEnd(i) for every i between 0 and n-1, where n 多边形的顶点数是多少, 我们将得到一个顶点被擦除的多边形及其附近的环境.

现在,主类方法:

const QPainterPath& RoundedPolygon: GetPath ()
{
    m_path = QPainterPath();

    if (count() < 3) {
   	 qWarning() << "Polygon should have at least 3 points!";
   	 return m_path;
    }

    QPointF pt1;
    QPointF pt2;
    for (int i = 0; i < count(); i++) {
   	 pt1 = GetLineStart(i);

   	 if (i == 0)
   		 m_path.moveTo(pt1);
   	 else
   		 m_path.quadTo ((i), pt1);

   	 pt2 = GetLineEnd(i);
   	 m_path.lineTo(pt2);
    }

    //关闭最后一个角
    pt1 = GetLineStart(0);
    m_path.quadTo ((0), pt1);

    return m_path;
}

在此方法中,我们构建 QPainterPath object. 如果多边形没有至少三个顶点, 我们处理的不再是二维图形, and in this case, 该方法发出警告并返回空路径. 当有足够的积分可用时, 我们循环遍历多边形的所有直线段(线段的数量为, of course, 等于顶点的个数), 计算圆角之间的每个直线段的起点和终点. 我们在这两点之间画一条直线,在前一条线段的末端和当前线段的起点之间画一条二次贝塞尔曲线, 使用当前顶点的位置作为控制点. After the loop, 我们必须在最后和第一个线段之间用贝塞尔曲线闭合路径因为在环路中我们比贝塞尔曲线多画了一条直线.

Class RoundedPolygon 用法和结果

现在是时候看看如何在实践中使用这个类了.

    QPixmap pix1(300,200);
    QPixmap pix2(300,200);
    pix1.fill(Qt::white);
    pix2.fill(Qt::white);
    QPainter P1(&pix1);
    QPainter P2(&pix2);

    P1.setRenderHints (QPainter:抗锯齿);
    P2.setRenderHints (QPainter:抗锯齿);
    P1.setPen (QPen (Qt:蓝色,2));
    P1.setBrush (Qt:红色);

    P2.setPen (QPen (Qt:蓝色,2));
    P2.setBrush (Qt:红色);

    RoundedPolygon聚;

    poly << QPoint(147, 187) << QPoint(95, 187)
   	   << QPoint(100, 175) << QPoint(145, 165) << QPoint(140, 95)
   	   << QPoint(5, 85) << QPoint(5, 70) << QPoint(140, 70) << QPoint(135, 45)
   	   << QPoint(138, 25) << QPoint(145, 5) << QPoint(155, 5) << QPoint(162, 25)
   	   << QPoint(165, 45) << QPoint(160, 70) << QPoint(295, 70) << QPoint(295, 85)
   	   << QPoint(160, 95) << QPoint(155, 165) << QPoint(200, 175)
   		   << QPoint(205, 187) << QPoint(153, 187) << QPoint(150, 199);

    P1.drawPolygon(聚);
    P2.drawPath(poly.GetPath());

    pix1.save("1.png");
    pix2.save("2.png");

这段源代码非常简单. 初始化两个之后 QPixmaps and their QPainters, we create a RoundedPolygon 对象,并用点填充. Painter P1 绘制正多边形,而 P2 draws the QPainterPath 圆角,由多边形生成. 两个结果像素图都保存到它们的文件中,结果如下:

圆角使用QPainter

Conclusion

我们已经看到,从多边形生成圆角形状并不是那么困难, 特别是如果我们使用一个好的编程框架,如Qt. 这个过程可以通过我在这篇博客中描述的作为概念证明的类来自动化. 然而,仍有很多改进的空间,例如:

  • 只在选定的顶点处制作圆角,而不是在所有顶点处.
  • 在不同的顶点处制作不同半径的圆角.
  • 实现一个方法, 它生成一个圆角折线(折线在Qt术语就像多边形, 只不过它不是一个闭合形状因为它缺少最后一个顶点和第一个顶点之间的线段).
  • Use RoundedPolygon 生成位图,可以作为背景小部件蒙版来生成形状疯狂的小部件.
  • The RoundedPolygon class is not optimized for speed of execution; I left it as it is for easier understanding of the concept. 优化可能包括在向多边形添加新顶点时计算大量中间值. Also, when GetPath 是否要返回对生成的 QPainterPath,它可以设置一个标志,表明对象是最新的. The next call to GetPath 结果只会是一样的吗 QPainterPath 对象,无需重新计算任何内容. 开发商会, however, 必须确保在任何多边形顶点的每次更改时清除此标志, 在每个新顶点上也是如此, 这让我认为,优化后的类最好从头开始开发,而不是派生 QPolygon. 好消息是,这并不像听起来那么困难.

Altogether, the RoundedPolygon 类,就像它一样,可以在任何时候作为一个工具来使用 designer touch 在没有提前准备像素图或形状的情况下,动态地到我们的GUI.

就这一主题咨询作者或专家.
Schedule a call
Bojan Kverh的头像
Bojan Kverh

Located in 卢布尔雅那(斯洛文尼亚

Member since January 11, 2016

About the author

博士(CE),曾任助理教授,有20多年的工作经验, Bojan是真正的C/ c++, Linux, and Qt expert.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

工作经验

28

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal开发者

Join the Toptal® community.