1. Qt 绘图系统

Qt 中提供了强大的 2D 绘图系统,可以使用相同的 API 在屏幕和绘图设备上进行绘制,主要基于 QPainter、QPaintDevice 和 QPaintEngine 这 3 个类。

  1. QPainter

QPainter用来执行绘图操作。

  1. QPaintDevice

QPaintDevice提供绘图设备,是一个二维空间的抽象,可以使用 QPainter 在其上进行绘制;是所有可以进行绘制的对象的基类,它的子类主要有 QWidget、QPixmap、QPicture、QImage、QPrinter 和 QOpenGLPaintDevice 等。

  1. QPaintEngine

QPaintEngine提供了一些接口,用于 QPainter 和 QPaintDevice 内部,使得 QPainter 可以在不同的设备上进行绘制;除了创建自定义的绘图设备类型,一般编程中不需要使用该类。

这一章中将讲解与 Qt 2D 绘图相关的一些知识,包括基本的绘制和填充、Qt 坐标系统等。本章内容可以在帮助中通过 Paint System 关键字查看。

1.1. 基本绘制和填充

绘图系统中由 QPainter 完成具体的绘制操作,其中,提供了大量高度优化的函数来完成 GUI 编程所需要的大部分绘制工作。QPainter 可以绘制一切想要的图形,从最简单的一条直线到其他任何复杂的图形,还可以绘制文本和图片。QPainter 可以在继承自 QPaintDevice 类的任何对象上进行绘制操作。

QPainter 一般在一个部件重绘事件(Paint Event)的处理函数 paintEvent() 中进行绘制,首先要创建 QPainter 对象,然后进行图形的绘制,最后销毁 QPainter 对象。

1.1.1. 基本图形的绘制和填充

QPainter 中提供了一些便捷函数来绘制常用的图形,还可以设置线条、边框的画笔以及进行填充的画刷。

新建 Qt Widgets 应用,项目名称为 mydrawing,基类选择 QWidget,类名为 Widget。建立完成后,在 widget.h 文件中声明重绘事件处理函数:

protected:
    void paintEvent(QPaintEvent *event);

然后到 widget.cpp 文件中添加头文件 #include <QPainter>

  1. 绘制图形
    先在 widget.cpp 文件中对 paintEvent() 函数进行如下定义:
void Widget::paintEvent(QPaintEvent *)
{
    QPainter painter(this);
    painter.drawLine(QPoint(0, 0), QPoint(100, 100));
}

这里先创建了一个 QPainter 对象,使用了 QPainter::QPainter(QPaintDevice * device) 构造函数,并指定了 this 为绘图设备,即表明在 Widget 部件上进行绘制。使用这个构造函数创建的对象会立即开始在设备上进行绘制,自动调用 begin() 函数,然后在 QPainter 的析构函数中调用 end() 函数结束绘制。如果构建 QPainter 对象时不想指定绘制设备,那么可以使用不带参数的构造函数,然后使用 QPainter::begin(QPaintDevice * device) 在开始绘制时指定绘制设备,等绘制完成后再调用 end() 函数结束绘制。上面函数中的代码等价于:

QPainter painter;
painter.begin(this);
painter.drawLine(QPoint(0, 0), QPoint(100, 100));
painter.end();

这两种方式都可以完成绘制,无论使用哪种方式,都要指定绘图设备,否则无法进行绘制。第二行代码使用 drawLine() 函数绘制了一条线段,这里使用了该函数的一种重载形式 QPainter::drawLine(const QPoint &p1, const QPoint &p2),其中,p1 和 p2 分别是线段的起点和终点。这里的 QPoint(0, 0) 就是窗口的原点,默认是窗口的左上角(不包含标题栏)。

可以运行程序查看效果:

除了绘制简单的线条以外,QPainter 还提供了一些绘制其他常用图形的函数,其中最常用的几个如表所列。
QPainter 中常用图形绘制函数介绍

函数 功能 函数 功能
drawArc() 绘制圆弧 drawPoint() 绘制点
drawChord() 绘制弦 drawPolygon() 绘制多边形
drawConvexPolygon() 绘制凸多边形 drawPolyline() 绘制折线
drawEllipse() 绘制椭圆 drawRect() 绘制矩形
drawLine() 绘制线条 drawRoundedRect() 绘制圆角矩形
drawPic() 绘制图片
  1. 使用画笔
    在 paintEvent() 函数中继续添加如下代码:
//创建画笔
QPen pen(Qt::green, 5, Qt::DotLine, Qt::RoundCap, Qt::RoundJoin);
//使用画笔
painter.setPen(pen);
QRectF rectangle(70.0, 40.0, 80.0, 60.0);
int startAngle = 30 * 16;
int spanAngle = 120 * 16;
//绘制圆弧
painter.drawArc(rectangle, startAngle, spanAngle);

运行如下:

QPen 类为 QPainter 提供了画笔来绘制线条和形状的轮廓,

这里使用的构造函数为 QPen::QPen(const QBrush &brush, qreal width, Qt::PenStyle style = Qt::SolidLine, Qt::PenCapStyle cap = Qt::SquareCap, Qt::PenJoinStyle join = Qt::BevelJoin),

几个参数依次为画笔使用的画刷、线宽、画笔风格、画笔端点风格和画笔连接风格,也可以分别使用 setBrush()、setWidth()、setStyle()、setCapStyle() 和 setJoinStyle() 等函数进行设置。
其中,画刷可以为画笔提供颜色;线宽的默认值为 0(宽度为 0 的一个像素);
画笔风格有实线、点线等,还有一个 Qt::NoPen 值,表示不进行线条或边框的绘制。
还可以使用 setDashPattern() 函数来自定义一个画笔风格。

画笔端点风格定义了怎样进行线条端点的绘制,其中Qt::SquareCap 风格表示线条的终点为方形,并且向前延伸了线宽的一半的长度;
Qt::FlatCap 风格也是方形端点,但并没有延长;
使用 Qt::RoundCap 风格的线条是圆形的端点,这些风格对宽度为 0 的线条没有作用。

最后的画笔连接风格定义了怎样绘制两条线的连接。
其中,Qt::BevelJoin 风格填充了两条线之间的空缺三角形;
而 Qt::RoundJoin 使用圆弧来填充这个三角形,这样显得更圆滑;
使用 Qt::MiterJoin 风格,是将两个线条的外部边线进行扩展而相交,然后填充形成的三角形区域。
这 3 种风格对于宽度为 0 的线条没有作用,可以把很宽的线条看作一个矩形来理解这 3 种风格。

painter.setBrush(Qt::Dense4Pattern);
//绘制椭圆
painter.drawEllipse(QPoint(220, 20), 50, 50);
//定义四个点
QPointF points[4] = {
    QPointF(270.0, 80.0),
    QPointF(290.0, 10.0),
    QPointF(350.0, 30.0),
    QPointF(390.0, 70.0)
};
//使用 4 个点绘制多边形
painter.drawPolygon(points, 4);

运行如下:

1.1.2. 渐变填充

QGradient 类就是用来和 QBrush 一起指定渐变填充的。Qt 现在支持 3 种类型的渐变填充:

  • 线性渐变(linear gradient):在开始点和结束点之间插入颜色;
  • 辐射渐变(radial gradient):在焦点和环绕它的圆环间插入颜色;
  • 锥形渐变(Conical):在圆心周围插入颜色。
    这 3 种渐变分别由 QGradient 的 3 个子类来表示,
    QLinearGradient 表示线性渐变,
    QRadialGradient 表示辐射渐变,
    QConicalGradient 表示锥形渐变。
    渐变只是更细腻的显示效果的提升,我们暂时先就不详细讨论了。后面有用到我们在研究。

2. Qt 坐标系统

Qt 的坐标系统是由QPainter类控制的,而QPainter是在绘图设备上进行绘制的。 一个绘图设备的默认坐标系统中,原点(0,0)在其左上角,x 坐标向右增长,y 坐标向下增长。在基于像素的设备上,默认的单位是一个像素,而在打印机上默认的单位是一个点(1/72英寸)。
QPainter的逻辑坐标与绘图设备的物理坐标之间的映射由QPainter的变换矩阵、视口和窗口处理。逻辑坐标和物理坐标默认是一致的。QPainter也支持坐标变换(比如旋转和缩放)。
本节的内容可以在帮助中通过Coordinate System关键字查看。

2.1. 坐标变换

  1. 基本变换

默认的,QPainter在相关设备的坐标系统上进行操作,但是它也完全支持仿射(af-fine)坐标变换(仿射变换的具体概念可以查看其他资料)。
绘图时可以使用QPainter::scale()函数缩放坐标系统,
使用QPainter::rotate()函数顺时针旋转坐标系统,
使用QPainter::translate()函数平移坐标系统,
还可以使用QPainter::shear()围绕原点来扭曲坐标系统。
坐标系统的2D变换由 QTransform类实现,可以使用前面提到的那些便捷函数进行坐标系统变换,当然也可以通过QTransform类实现,而且QTransform类对象可以存储多个变换操作;
当同样的变换要多次使用时,建议使用QTransform类对象。坐标系统的变换是通过变换矩阵实现的,可以在平面上变换一个点到另一个点。
进行所有变换操作的变换矩阵都可以使用QPainter::worldTransform()函数获得;如果要设置一个变换矩阵,可以使用QPainter::setWorldTransform()函数。这两个函数也可以分别使用QPainter::transform()QPainter::setTransform()函数来代替。

在进行变换操作时,可能需要多次改变坐标系统,然后再恢复,这样编码会很乱,而且很容易出现操作错误。这时可以使用QPainter::save()函数来保存QPainter的变换矩阵,它会把变换矩阵保存到一个内部栈中,需要恢复变换矩阵时再使用QPainter::restore()函数将其弹出。

  1. 窗口-视口转换
    使用QPainter进行绘制时,会使用逻辑坐标进行绘制,然后再转换为绘图设备的物理坐标。
    逻辑坐标到物理坐标的映射由QPainter的worldTransform()函数、QPainter的viewport()以及window()函数进行处理。
    其中,视口(viewport)表示物理坐标下指定的一个任意矩形,而窗口(window,与以前讲的窗口部件的概念不同)表示逻辑坐标下的相同矩形。
    默认的,逻辑坐标和物理坐标是重合的,它们都相当于绘图设备上的矩形。
    使用窗口-视口转换可以使逻辑坐标系统适合应用要求,这个机制也可以用来让绘图代码独立于绘图设备。
    例如,可以使用下面的代码来使逻辑坐标以(-50,-50)为原点,宽为100,高为100,(0,0)点为中心:

QPainter painter(this);
painter.setWindow(QRect(-50,-50,100.100));

现在逻辑坐标的(-50,—50)对应绘图设备的物理坐标的(0,0)点。
这样就可以独立于绘图设备,使绘图代码在指定的逻辑坐标上进行操作了。
当设置窗口或者视口矩形时,实际上是执行了坐标的一个线性变换,窗口的4个角会映射到视口对应的4个角,反之亦然。
因此,一个很好的办法是让视口和窗口维持相同的宽高比来防止变形:

int side=qMin(width(),height());
int x=(width()-side/2);   
int y=(height()-side/2);
painter.setViewport(x,y,side,side);

如果设置了逻辑坐标系统为一个正方形,那么也需要使用QPainter::setViewport()函数设置视口为正方形,
例如,这里将视口设置为适合绘图设备矩形的最大矩形。在设置窗口或视口时考虑到绘图设备的大小,就可以使绘图代码独立于绘图设备。
窗口-视口转换仅仅是线性变换,不会执行裁减操作。
这就意味着如果绘制范围超出了当前设置的窗口,那么仍然会使用相同的线性代数方法将绘制变换到视口上。
绘制过程中先使用坐标矩阵进行变换,再使用窗口-视口转换。

  1. 代码演示

前面讲到的知识可能不是很容易理解,下面通过实际的程序来进一步讲解这些知识点。

新建QWidget类型项目,mytransformation,重写paintEvent函数


void Widget::paintEvent(QPaintEvent* event)
{
    QPainter painter(this);
    //填充界面背景为白色
    painter.fillRect(rect(),Qt::white);
    painter.setPen(QPen(Qt::red,10));
    //绘制一条线段
    painter.drawLine(QPoint(0,0),QPoint(100,100));
    //将坐标系统进行平移,使(200,150)点作为原点
    painter.translate(200,150);
    //开启抗锯齿
    painter.setRenderHint(QPainter::Antialiasing);
    //重新绘制相同的线段
    painter.drawLine(QPoint(0,0),QPoint(100,100));
}

这里先绘制了一条线段,然后使用translate()函数改变了坐标原点,并重新绘制了前面的线段,该函数的两个参数分别为水平方向和垂直方向的偏移值。
因为现在的坐标原点已经改变,也就是说会以(200,150)作为新的原点(0,0),所以两条线段并不会重合。
而且在绘制第二条线段时使用了抗锯齿,所以可以看出它比第一条线段要平滑许多。
在程序中,要想将坐标原点再还原回去,可以进行反向平移,即使用translate
(-200,-150)。

下面继续在paintEvent()函数中添加如下代码:


 //保存painter的状态
    painter.save();
    //将坐标系统旋转90度
    painter.rotate(90);
    painter.setPen(QPen(Qt::cyan,11));
    //重新绘制相同的线段
    painter.drawLine(QPoint(5,6),QPoint(100,99));
    //恢复painter的状态
    painter.restore();

这里先使用save()函数保存了painter的当前状态,然后将坐标系统进行旋转并绘制了同以前一样的线段;

不过,因为坐标系统已经旋转了,所以这条线段也不会和前面的线段重合。
这里的rotate()函数会以原点为中心进行旋转,其参数为旋转的角度,正数为顺时针旋转,负数为逆时针旋转。
最后使用restore()函数恢复了painter以前的状态,就是恢复到了旋转以前的坐标系统和画笔颜色。
可以运行程序查看效果。

下面继续在paintEvent()函数中添加代码:

    painter.setBrush(Qt::darkGreen); //绘制一个矩形
    painter.drawRect(-50,-50,100,100);
    painter.save();
    //将坐标系统进行缩放
    painter.scale(0.5,0.2);
    painter.setBrush(Qt::yellow); //重新绘制相同的矩形
    painter.drawRect(-50,-50,100,100);
    painter.restore();

这里先绘制了一个矩形,然后将坐标系统进行缩放并绘制了相同的矩形,
因为坐标系统已经改变,所以两个矩形不会重合。
这里scale()函数的两个参数分别为水平方向和垂直方向缩放的倍数。

继续在paintEvent()函数中添加代码:


    painter.setPen(Qt::blue);
    painter.setBrush(Qt::darkYellow); //绘制一个椭圆
    painter.drawEllipse(QRect(60,-100,50,50)); //将坐标系统进行扭曲
    painter.shear(1.5,-0.7);
    painter.setBrush(Qt::darkGray); //重新绘制相同的椭圆
    painter.drawEllipse(QRect(60,-100,50,50));

这里先绘制了一个椭圆,然后将坐标系统进行扭曲并绘制了相同的椭圆,
因为坐标系统已经改变,所以两个椭圆不会重合。
这里shear()函数的两个参数分别为水平方向和垂直方向的扭曲值,其值为0时表示不进行扭曲。
运行程序,效果如图:

通过上面的例子,我们基本能了解这四个函数的用法,前3个在以后的绘图程序中比较常用的。
QPainter::scale()函数缩放坐标系统,
QPainter::rotate()函数顺时针旋转坐标系统,
QPainter::translate()函数平移坐标系统,
QPainter::shear()围绕原点来扭曲坐标系统。

下面来看一下窗口-视口转换的内容,先将前面paintEvent()函数中的所有内容都删除或注释掉,然后更改如下:

    QPainter painter(this);
    painter.setWindow(-50,-50,100,100);
    painter.setBrush(Qt::green);
    painter.drawRect(0,0,20,20);

这里先使用setWindow()函数将逻辑坐标矩形设置为以(-50,-50)为起点,宽100,高100。
这样逻辑坐标的(-50,-50)点就会对应物理坐标的(0,0)点,因为这里是在this(即Widget部件上)进行绘图,所以Widget就是绘图设备。
也就是说,现在逻辑坐标的(-50,-50)点对应界面左上角的(0,0)点。
而且,因为逻辑坐标矩形宽为100、高为100,所以界面的宽度和高度都会被100等分。
下面在界面上显示出物理坐标,从而帮助我们理解。
在widget.h文件的protected域中声明鼠标移动事件处理函数:
void mouseMoveEvent(QMouseEvent*event);

widget.cpp文件中,实现:

void Widget::mouseMoveEvent(QMouseEvent*event)
{
    QString pos =QString("%1,%2").arg(event->pos().x()).arg(event->pos().y());
    QToolTip::showText(event->globalPos(),pos,this);
}

这里先获取了鼠标指针在Widget上的坐标(即物理坐标),然后在工具提示中进行显示。
现在运行程序可以看到,在(0,0)点绘制的矩形实际在(400,300)点,而矩形的宽和高也不再是20,而变为了160和120。
为什么会出现这样的问题呢?前面已经讲过,更改逻辑坐标或者物理坐标的矩形就是进行坐标的一个线性变换,逻辑坐标矩形的4个角会映射到对应物理坐标矩形的4个角。而现在Widget部件的大小为宽800、高600,所以物理坐标对应的矩形就是(0,0,800,600)。

这样按比例对应,就是在水平方向,逻辑坐标的一个单位对应物理坐标的8个单位;在垂直方向,逻辑坐标的一个单位对应物理坐标的6个单位。
所以,逻辑坐标中的宽20、高20的矩形在物理坐标中就是宽160、高120的矩形。
可以看到,设置的矩形已经发生了变形,由设置的正方形变成了一个长方形。为了防止变形,需要将视口的宽和高的对应比例设置为相同值,因为逻辑坐标的矩形设置为了一个正方形,所以视口(即物理坐标矩形)也应该设置为一个正方形,更改paintEvent()函数如下:

    QPainter painter(this);
    int side =qMin(width(),height());
    int x =(width()/2);
    int y =(height()/2);
    //设置视口
    painter.setViewport(x,y,side,side);
    painter.setWindow(0,0,100,100);
    painter.setBrush(Qt::green);
    painter.drawRect(0,0,20,20);

这样绘制出来的矩形就是正方形了。
可以根据自己的想法继续更改代码,深入研究一下逻辑坐标矩形、物理坐标矩形和绘图设备矩形之间的关系。

inter painter(this);
    int side =qMin(width(),height());
    int x =(width()/2);
    int y =(height()/2);
    //设置视口
    painter.setViewport(x,y,side,side);
    painter.setWindow(0,0,100,100);
    painter.setBrush(Qt::green);
    painter.drawRect(0,0,20,20);

这样绘制出来的矩形就是正方形了。
可以根据自己的想法继续更改代码,深入研究一下逻辑坐标矩形、物理坐标矩形和绘图设备矩形之间的关系。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐