在之前的章节中,笔者一般使用QLabel控件来显示图片。但是,如果要使用很多图片怎么办?难道要实例化很多个QLabel控件来一一显示?那如何管理呢?当然,我们不可能会用QLabel控件来做这样的事,否则会非常麻烦和混乱。PyQt5中的图形视图可以让我们管理大量的自定义2D图元并与之交互。该框架使用BSP(Binary Space Partitioning - 二叉空间分割)树,以快速查找图形元素。所以就算一个视图场景中包含数百万的图元,它也可以实时进行显示。如果要用PyQt5来制作稍微复杂点的游戏的话,图形视图是必定要用到的。
图形视图框架主要包含三个类:QGraphicsItem图元类、QGraphicsScene场景类和QGraphicsView视图类。简单一句话来概括下三者的关系就是:图元放在场景上,场景内容通过视图来显示。下面我们来一一进行讲解。
34.1 QGraphicsItem图元类
图元可以是文本、图片,规则几何图形或者任意自定义图形。该类已经提供了一些标准的图元,比如:
- 直线图元QGraphicsLineItem
- 矩形图元QGraphicsRectItem
- 椭圆图元QGraphicsEllipseItem
- 图片图元QGraphicsPixmapItem
- 文本图元QGraphicsTextItem
- 路径图元QGraphicsPathItem
想必通过名称也可以知道这些图元是用来干嘛的,我们通过以下代码来演示如何使用:
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap, QColor, QPainterPath
from PyQt5.QtWidgets import QApplication, QGraphicsItem, QGraphicsLineItem, QGraphicsRectItem, QGraphicsEllipseItem, \
QGraphicsPixmapItem, QGraphicsTextItem, QGraphicsPathItem, QGraphicsScene, QGraphicsView
class Demo(QGraphicsView):
def __init__(self):
super(Demo, self).__init__()
# 1
self.resize(300, 300)
# 2
self.scene = QGraphicsScene()
self.scene.setSceneRect(0, 0, 300, 300)
# 3
self.line = QGraphicsLineItem()
self.line.setLine(100, 10, 200, 10)
# self.line.setLine(QLineF(100, 10, 200, 10))
# 4
self.rect = QGraphicsRectItem()
self.rect.setRect(100, 30, 100, 30)
# self.rect.setRect(QRectF(100, 30, 100, 30))
# 5
self.ellipse = QGraphicsEllipseItem()
self.ellipse.setRect(100, 80, 100, 20)
# self.ellipse.setRect(QRectF(100, 80, 100, 20))
# 6
self.pic = QGraphicsPixmapItem()
self.pic.setPixmap(QPixmap('pic.png').scaled(60, 60))
self.pic.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.pic.setOffset(100, 120)
# self.pic.setOffset(QPointF(100, 120))
# 7
self.text1 = QGraphicsTextItem()
self.text1.setPlainText('Hello PyQt5')
self.text1.setDefaultTextColor(QColor(66, 222, 88))
self.text1.setPos(100, 180)
self.text2 = QGraphicsTextItem()
self.text2.setPlainText('Hello World')
self.text2.setTextInteractionFlags(Qt.TextEditorInteraction)
self.text2.setPos(100, 200)
self.text3 = QGraphicsTextItem()
self.text3.setHtml('<a href="https://baidu.com">百度</a>')
self.text3.setOpenExternalLinks(True)
self.text3.setTextInteractionFlags(Qt.TextBrowserInteraction)
self.text3.setPos(100, 220)
# 8
self.path = QGraphicsPathItem()
self.tri_path = QPainterPath()
self.tri_path.moveTo(100, 250)
self.tri_path.lineTo(130, 290)
self.tri_path.lineTo(100, 290)
self.tri_path.lineTo(100, 250)
self.tri_path.closeSubpath()
self.path.setPath(self.tri_path)
# 9
self.scene.addItem(self.line)
self.scene.addItem(self.rect)
self.scene.addItem(self.ellipse)
self.scene.addItem(self.pic)
self.scene.addItem(self.text1)
self.scene.addItem(self.text2)
self.scene.addItem(self.text3)
self.scene.addItem(self.path)
# 10
self.setScene(self.scene)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
-
该类直接继承QGraphicsView,那么窗口就是视图,且大小为300x300;
-
实例化一个QGraphicsScene场景,并调用setSceneRect(x, y, w, h)方法来设置场景坐标原点和大小。从代码中我们得知坐标原点为(0, 0),之后往场景中添加的图元就会都根据该坐标来设置位置(关于坐标的更多内容,笔者会在34.4小节中进行讲解)。场景的大小为300x300,跟视图大小一样;
-
实例化一个QGraphicsLineItem直线图元,并调用setLine()方法设置直线两端的坐标。该方法既可以直接传入四个数值,也可以传入一个QLineF对象。文档里写的非常清楚:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mh5Ronym-1641378986725)(data:image/svg+xml;utf8, )]
图片可以被选中和移动:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7WmiGKWi-1641378986727)(data:image/svg+xml;utf8, )]
QGraphicsItem还支持以下特性:
- 鼠标按下、移动、释放和双击事件,以及鼠标悬浮事件、滚轮事件和右键菜单事件
- 键盘输入事件
- 拖放事件
- 分组
- 碰撞检测
实现事件函数非常简单,这里就不细讲,我们重点要来了解下它在图形视图框架中的是如何传递的。请看下面的代码:
import sys
from PyQt5.QtWidgets import QApplication, QGraphicsRectItem, QGraphicsScene, QGraphicsView
class CustomItem(QGraphicsRectItem):
def __init__(self):
super(CustomItem, self).__init__()
self.setRect(100, 30, 100, 30)
def mousePressEvent(self, event):
print('event from QGraphicsItem')
super().mousePressEvent(event)
class CustomScene(QGraphicsScene):
def __init__(self):
super(CustomScene, self).__init__()
self.setSceneRect(0, 0, 300, 300)
def mousePressEvent(self, event):
print('event from QGraphicsScene')
super().mousePressEvent(event)
class CustomView(QGraphicsView):
def __init__(self):
super(CustomView, self).__init__()
self.resize(300, 300)
def mousePressEvent(self, event):
print('event from QGraphicsView')
super().mousePressEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
view = CustomView()
scene = CustomScene()
item = CustomItem()
scene.addItem(item)
view.setScene(scene)
view.show()
sys.exit(app.exec_())
图元,场景和视图其实都有各自的事件函数,我们在上面分别继承了QGraphicsRectItem, QGraphicsScene以及QGraphicsView并重新实现了各自的mousePressEvent()事件函数,在其中我们都打印一句话来让用户知道是哪个函数被执行了。
运行截图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jsFpK9xf-1641378986728)(data:image/svg+xml;utf8, )]
由此可见,事件的传递顺序为视图->场景->图元。有一点大家需要注意,重新实现事件函数的话我们必须要调用相应的父类事件函数,否则事件无法顺利传递下去。假如我把CustomView类中事件函数下的super().mousePressEvent(event)这行代码删除掉,那么控制台只会输出"event from QGraphicsView":
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZBVj0O0H-1641378986730)(data:image/svg+xml;utf8, )]
在矩形框中点击,控制台打印如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Aq8dF4Cj-1641378986731)(data:image/svg+xml;utf8, )]
请大家一定要搞清楚事件的传递顺序,这样才能更好地使用图形视图框架。
所谓分组也就是将各个图元进行分类,分到一起的图元就会共同行动(选中、移动以及复制等)。我们通过下面的代码来演示下:
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPen, QBrush
from PyQt5.QtWidgets import QApplication, QGraphicsItem, QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsScene, \
QGraphicsView, QGraphicsItemGroup
class Demo(QGraphicsView):
def __init__(self):
super(Demo, self).__init__()
self.resize(300, 300)
self.scene = QGraphicsScene()
self.scene.setSceneRect(0, 0, 300, 300)
# 1
self.rect1 = QGraphicsRectItem()
self.rect2 = QGraphicsRectItem()
self.ellipse1 = QGraphicsEllipseItem()
self.ellipse2 = QGraphicsEllipseItem()
self.rect1.setRect(100, 30, 100, 30)
self.rect2.setRect(100, 80, 100, 30)
self.ellipse1.setRect(100, 140, 100, 20)
self.ellipse2.setRect(100, 180, 100, 50)
# 2
pen1 = QPen(Qt.SolidLine)
pen1.setColor(Qt.blue)
pen1.setWidth(3)
pen2 = QPen(Qt.DashLine)
pen2.setColor(Qt.red)
pen2.setWidth(2)
brush1 = QBrush(Qt.SolidPattern)
brush1.setColor(Qt.blue)
brush2 = QBrush(Qt.SolidPattern)
brush2.setColor(Qt.red)
self.rect1.setPen(pen1)
self.rect1.setBrush(brush1)
self.rect2.setPen(pen2)
self.rect2.setBrush(brush2)
self.ellipse1.setPen(pen1)
self.ellipse1.setBrush(brush1)
self.ellipse2.setPen(pen2)
self.ellipse2.setBrush(brush2)
# 3
self.group1 = QGraphicsItemGroup()
self.group2 = QGraphicsItemGroup()
self.group1.addToGroup(self.rect1)
self.group1.addToGroup(self.ellipse1)
self.group2.addToGroup(self.rect2)
self.group2.addToGroup(self.ellipse2)
self.group1.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.group2.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
print(self.group1.boundingRect())
print(self.group2.boundingRect())
# 4
self.scene.addItem(self.group1)
self.scene.addItem(self.group2)
self.setScene(self.scene)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
-
实例化四个图元,两个为矩形,两个为椭圆,并调用setRect()方法设置坐标和大小;
-
实例化两种画笔和两种画刷,用于图元的样式设置;
-
实例化两个QGraphicsGroup分组对象,并将矩形和椭圆都添加进来。rect1和ellipse1在group1里,而rect2和ellipse2在group2里。接着调用setFlags()方法设置属性,让分组可以选中和移动。boundRect()方法放回一个QRectF值,该值可以显示出分组的边界位置和大小;
-
将分组添加到场景当中。
运行截图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lz5avygY-1641378986733)(data:image/svg+xml;utf8, )]
碰撞检测在游戏中的用处非常大,比如在飞机大战游戏中,如果子弹没有和敌机做碰撞检测处理的话,那敌机就不会被消灭,奖励也不会增加,游戏也就没有什么意思。我们通过下面这个例子来带大家了解如何对图元进行碰撞检测:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7c7bbJBr-1641378986736)(data:image/svg+xml;utf8, )]
当选中这个图元时,虚线部分显示的就是该图元的边界,而形状就指的是图元本身,也就是黑色实线部分。碰撞检测可以以边界为范围或者以形状为范围。假如我们在代码中以边界为范围,那椭圆的虚线跟矩形图元一碰到,就会触发碰撞检测;如果以形状为范围,那只有在椭圆的黑色实线跟矩形碰到的情况下,碰撞检测才会触发。
下面是几种具体的检测方式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d0yO3p7x-1641378986737)(data:image/svg+xml;utf8, )]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3MvHHLOL-1641378986738)(data:image/svg+xml;utf8, )]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BES40G4G-1641378986740)(data:image/svg+xml;utf8, )]
34.2 QGraphicsScene场景类
在之前的小节中,我们要往场景中添加图元的话都是先把图元实例化好,再调用场景的addItem()方法进行添加。不过场景其实还提供了以下方法让我们可以快速添加图元:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZNI5eSid-1641378986741)(data:image/svg+xml;utf8, )]
控制台打印内容:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J8OpZupT-1641378986742)(data:image/svg+xml;utf8, )]
我们还可以向场景中添加QLabel, QLineEdit, QPushButton, QTableWidget等简单或者复杂的控件,甚至可以直接添加一个主窗口。接下来通过完成以下界面来带大家进一步了解(就是第三章布局管理中的界面例子):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L3aQ9D9D-1641378986743)(data:image/svg+xml;utf8, )]
Linux(Ubuntu)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7MwxUbL9-1641378986745)(data:image/svg+xml;utf8, )]
既然图元已经添加好,场景也已经设置好,那我们通常就可以调用视图的一些方法来对图元做一些变换,比如放大、缩小和旋转等。请看下方代码:
import sys
from PyQt5.QtCore import Qt, QRectF
from PyQt5.QtGui import QColor, QBrush
from PyQt5.QtWidgets import QApplication, QGraphicsItem, QGraphicsScene, QGraphicsView
class Demo(QGraphicsView):
def __init__(self):
super(Demo, self).__init__()
self.resize(300, 300)
self.scene = QGraphicsScene()
self.scene.setSceneRect(0, 0, 500, 500)
self.ellipse = self.scene.addEllipse(QRectF(200, 200, 50, 50), brush=QBrush(QColor(Qt.blue)))
self.rect = self.scene.addRect(QRectF(300, 300, 50, 50), brush=QBrush(QColor(Qt.red)))
self.ellipse.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.rect.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.setScene(self.scene)
self.press_x = None
# 1
def wheelEvent(self, event):
if event.angleDelta().y() < 0:
self.scale(0.9, 0.9)
else:
self.scale(1.1, 1.1)
# super().wheelEvent(event)
# 2
def mousePressEvent(self, event):
self.press_x = event.x()
# super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if event.x() > self.press_x:
self.rotate(10)
else:
self.rotate(-10)
# super().mouseMoveEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
-
在鼠标滚轮事件中,调用scale()方法来来放大和缩小视图。这里并没有必要调用父类的事件函数,因为我们不需要将事件传递给场景以及图元;
-
重新实现鼠标按下和移动事件函数,首先获取鼠标按下时的坐标,然后判断鼠标是向左移动还是向右。如果向右的话,则视图顺时针旋转10度,否则逆时针旋转10度。
运行截图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K5JvJzak-1641378986746)(data:image/svg+xml;utf8, )]
放大和缩小
旋转
当然视图还提供了很多方法,比如同样可以用items()和itemAt()来获取图元,也可以设置视图背景、视图图缓存模式和鼠标拖曳模式等等。大家可按需查阅(这里讲多了怕混乱(ー`´ー))。
34.4 图形视图的坐标体系
图形视图基于笛卡尔坐标系,视图,场景和图元都有各自的坐标。
视图坐标以左上角为原点,向右为x正轴,向下为y正轴(所有的鼠标事件最开始用的都是视图坐标):
场景坐标以中心为原点,向右为x正轴,向下为y正轴(场景坐标描述的是最顶层图元的位置):
图元坐标跟场景坐标一样(描述子图元的位置):
图形视图提供了三种坐标系之间相互转换的函数,以及图元与图元之间的转换函数:
好,我们现在来讲解下34.2小节中的那个问题,代码如下:
import sys
from PyQt5.QtGui import QPixmap, QTransform
from PyQt5.QtWidgets import QApplication, QGraphicsItem, QGraphicsScene, QGraphicsView
class Demo(QGraphicsView):
def __init__(self):
super(Demo, self).__init__()
self.resize(600, 600)
self.scene = QGraphicsScene()
self.scene.setSceneRect(0, 0, 300, 300)
self.rect = self.scene.addRect(100, 30, 100, 30)
self.ellipse = self.scene.addEllipse(100, 80, 50, 40)
self.pic = self.scene.addPixmap(QPixmap('pic.png').scaled(60, 60))
self.pic.setOffset(100, 130)
self.rect.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.ellipse.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.pic.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable)
self.setScene(self.scene)
def mouseDoubleClickEvent(self, event):
item = self.scene.itemAt(event.pos(), QTransform())
self.scene.removeItem(item)
super().mouseDoubleClickEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
在上面这个程序中,视图大小为600x600,而场景大小只有300x300。此时运行程序,我们双击的话是删除不了图元的,原因就是我们所获取的event.pos()是视图上的坐标,但是self.scene.itemAt()需要的是场景坐标。把视图坐标传给场景的itemAt()方法是获取不到任何图元的,所以我们应该要进行转换!
把mouseDoubleClickEvent()事件函数修改如下即可:
def mouseDoubleClickEvent(self, event):
point = self.mapToScene(event.pos())
item = self.scene.itemAt(point, QTransform())
self.scene.removeItem(item)
super().mouseDoubleClickEvent(event)
调用视图的mapToScene()方法将视图坐标转换为场景坐标,这样图元就可以找到,也就自然而然可以删除掉了。
运行截图如下,椭圆被删除了:
34.5 小结
-
事件的传递顺序为视图->场景->图元,如果是在图元父子类之间传递的话,那传递顺序是从子类到父类;
-
碰撞检测的范围分为边界和形状两种,需要明白两者的不同;
-
要给QGraphicsItem加上信号和槽机制以及动画的话,就自定义一个继承于QGraphicsObject的类;
-
往场景中添加QLabel, QLineEdit, QPushButton等控件,我们需要用到QGraphicsProxyWidget;
-
视图,场景和图元都有自己的坐标系,注意使用坐标转换函数进行转换;
-
图形视图框架知识点太多,笔者写本章的目的只是尽量带大家入门,个别地方可能会没有解释详细,请各位谅解。关于更多细节,大家可以在Qt Assistant中搜索“Graphics View Framework”来进一步了解。