1. Qt 绘图系统
Qt 中提供了强大的 2D 绘图系统,可以使用相同的 API 在屏幕和绘图设备上进行绘制,主要基于 QPainter、QPaintDevice 和 QPaintEngine 这 3 个类。
- QPainter
QPainter用来执行绘图操作。
- QPaintDevice
QPaintDevice提供绘图设备,是一个二维空间的抽象,可以使用 QPainter 在其上进行绘制;是所有可以进行绘制的对象的基类,它的子类主要有 QWidget、QPixmap、QPicture、QImage、QPrinter 和 QOpenGLPaintDevice 等。
- 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>
- 绘制图形
先在 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() | 绘制图片 |
- 使用画笔
在 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. 坐标变换
- 基本变换
默认的,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()
函数将其弹出。
- 窗口-视口转换
使用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()函数设置视口为正方形,
例如,这里将视口设置为适合绘图设备矩形的最大矩形。在设置窗口或视口时考虑到绘图设备的大小,就可以使绘图代码独立于绘图设备。
窗口-视口转换仅仅是线性变换,不会执行裁减操作。
这就意味着如果绘制范围超出了当前设置的窗口,那么仍然会使用相同的线性代数方法将绘制变换到视口上。
绘制过程中先使用坐标矩阵进行变换,再使用窗口-视口转换。
- 代码演示
前面讲到的知识可能不是很容易理解,下面通过实际的程序来进一步讲解这些知识点。
新建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);
这样绘制出来的矩形就是正方形了。
可以根据自己的想法继续更改代码,深入研究一下逻辑坐标矩形、物理坐标矩形和绘图设备矩形之间的关系。