[PyQt5] 实现图元(QGraphicsItem)的建立、操作和连接

图元的拖拽及连线这类操作在一些桌面软件中很是常见。比如在学校用的eNSP(如下图),很直观,图元代表模型,连线代表连接(或关系)。本文就从头开始,实现这些功能,效果也如下图。
ensp的图元

1. 背景搭建

纯色背景

建立图元首先要有一个“画板”。

import sys
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsView
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.scene = QGraphicsScene(self)
        self.view = QGraphicsView(self)
        # 有view就要有scene
        self.view.setScene(self.scene)
        # 设置view可以进行鼠标的拖拽选择
        self.view.setDragMode(self.view.RubberBandDrag)

        self.setMinimumHeight(500)
        self.setMinimumWidth(500)
        self.setCentralWidget(self.view)
        self.setWindowTitle("Graphics Demo")

def demo_run():
    app = QApplication(sys.argv)
    demo = MainWindow()
    # 适配 Retina 显示屏(选写).
    app.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
    app.setAttribute(Qt.AA_EnableHighDpiScaling, True)
    # ----------------------------------
    demo.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    demo_run()

此时运行,效果如下。背景是白色的,蓝色的区域是我用鼠标拖拽时产生的。多少有些不美观,这样,我们换个背景。换一个稍微看起来高大上一些的背景。
效果1

网格背景

这样一来,就需要重写QGraphicsScene类中一个名为drawBackground的方法。顾名思义,该方法是画背景用的。此时我们单独建立一个类,继承它并重写方法。

import math

from PyQt5.QtWidgets import QGraphicsScene
from PyQt5.QtGui import QColor, QPen
from PyQt5.QtCore import QLine


class GraphicScene(QGraphicsScene):

    def __init__(self, parent=None):
        super().__init__(parent)

        # 一些关于网格背景的设置
        self.grid_size = 20  # 一块网格的大小 (正方形的)
        self.grid_squares = 5  # 网格中正方形的区域个数
		
		# 一些颜色
        self._color_background = QColor('#393939')
        self._color_light = QColor('#2f2f2f')
        self._color_dark = QColor('#292929')
		# 一些画笔
        self._pen_light = QPen(self._color_light)
        self._pen_light.setWidth(1)
        self._pen_dark = QPen(self._color_dark)
        self._pen_dark.setWidth(2)
		
		# 设置画背景的画笔
        self.setBackgroundBrush(self._color_background)
        self.setSceneRect(0, 0, 500, 500)
	
	# override
    def drawBackground(self, painter, rect):
        super().drawBackground(painter, rect)
		
		# 获取背景矩形的上下左右的长度,分别向上或向下取整数
        left = int(math.floor(rect.left()))
        right = int(math.ceil(rect.right()))
        top = int(math.floor(rect.top()))
        bottom = int(math.ceil(rect.bottom()))
		
		# 从左边和上边开始
        first_left = left - (left % self.grid_size)  # 减去余数,保证可以被网格大小整除
        first_top = top - (top % self.grid_size)
		
		# 分别收集明、暗线
        lines_light, lines_dark = [], []
        for x in range(first_left, right, self.grid_size):
            if x % (self.grid_size * self.grid_squares) != 0:
                lines_light.append(QLine(x, top, x, bottom))
            else:
                lines_dark.append(QLine(x, top, x, bottom))
		
        for y in range(first_top, bottom, self.grid_size):
            if y % (self.grid_size * self.grid_squares) != 0:
                lines_light.append(QLine(left, y, right, y))
            else:
                lines_dark.append(QLine(left, y, right, y))
		
		# 最后把收集的明、暗线分别画出来
        painter.setPen(self._pen_light)
        if lines_light:
            painter.drawLines(*lines_light)

        painter.setPen(self._pen_dark)
        if lines_dark:
            painter.drawLines(*lines_dark)

最后修改MainWindow,self.scene = QGraphicsScene(self) GraphicScene(self)。运行,效果如下:
效果2
这样看起来就美观了许多。

2. 添加图元

准备工作
  1. 图元是什么?图元画在哪?怎么画上去?
    首先,图元在PyQt中也是由类表示的,是QGraphicsItem及其子类。图元在是画在scene中的,而且可以由QGraphicsSceneaddItem()removeItem()添加或删除。但这些图元目前只在视图上显示,然后这样操作的意义并不大。或许你可以考虑再将它存储起来,方便功能拓展。
    这是官网文档对于QGraphicsItem的描述, 只有它或继承它的子类才可以添加进scene。
  2. 本示例选用什么图元?
    此次演示选用的是QGraphicsItem的子类QGraphicsPixmapItem。原因很简单,我的图元是以图片的形式出现的,而且这个类提供了针对图片图元很亲和的方法。
  3. 如何更好的管理这些图元?
    scene是画图元的地方,自然view就是管理scene的地方。开始前,要针对view再进行一些配置。这里仍继承并重写自己的view类。
class GraphicView(QGraphicsView):

    def __init__(self, graphic_scene, parent=None):
        super().__init__(parent)

        self.gr_scene = graphic_scene  # 将scene传入此处托管,方便在view中维护
        self.parent = parent

        self.init_ui()

    def init_ui(self):
        self.setScene(self.gr_scene)
        # 设置渲染属性
        self.setRenderHints(QPainter.Antialiasing |                    # 抗锯齿
                            QPainter.HighQualityAntialiasing |         # 高品质抗锯齿
                            QPainter.TextAntialiasing |                # 文字抗锯齿
                            QPainter.SmoothPixmapTransform |           # 使图元变换更加平滑
                            QPainter.LosslessImageRendering)           # 不失真的图片渲染
        # 视窗更新模式
        self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
        # 设置水平和竖直方向的滚动条不显示
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setTransformationAnchor(self.AnchorUnderMouse)
        # 设置拖拽模式
        self.setDragMode(self.RubberBandDrag)

此时MainWindow中,更新self.view = QGraphicsView(self) GraphicView(self.scene, self).

创建图元

此处创建了一个简单的图元。

from PyQt5.QtWidgets import QGraphicsItem, QGraphicsPixmapItem
from PyQt5.QtGui import QPixmap

class GraphicItem(QGraphicsPixmapItem):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.pix = QPixmap("此处为你的图元的图片路径")
        self.width = 50    # 图元宽
        self.height = 50   # 图元高
        self.setPixmap(self.pix)  # 设置图元
        self.setFlag(QGraphicsItem.ItemIsSelectable)  # ***设置图元是可以被选择的
        self.setFlag(QGraphicsItem.ItemIsMovable)     # ***设置图元是可以被移动的

现在将图元添加进scene中,继续编写view类。本例触发添加图元的条件是:按下键盘的N键。

class GraphicView(QGraphicsView):
	    ......
        
        #override
	    def keyPressEvent(self, event):
        if event.key() == Qt.Key_N:
        # 当按下N键时,会在scene的(0,0)位置出现此图元
            item = GraphicItem()
            item.setPos(0, 0)
            self.gr_scene.addItem(gr_item)

效果如下:
效果3

删除图元

现在将图元从scene中移除,并且移除的是已选中的图元。此处触发删除图元的条件是右键点击图元

class GraphicView(QGraphicsView):
    .....
    
    # override
    def mousePressEvent(self, event):
        super().mousePressEvent(event)
        if event.button() == Qt.RightButton:   # 判断鼠标右键点击
            item = self.get_item_at_click(event)
            if isinstance(item, GraphicItem):  # 判断点击对象是否为图元的实例
                self.gr_scene.removeItem(item)

    def get_item_at_click(self, event):
        """ 获取点击位置的图元,无则返回None. """
        pos = event.pos()
        item = self.itemAt(pos)
        return item

本例仅删除一个选中的图元。若想删除选中矩形框中的所有图元,可以在view类中加入下面这个方法:

  def get_items_at_rubber_select(self):
      area = self.rubberBandRect()
      return self.items(area)   # 返回一个所有选中图元的列表,对此操作即可

运行效果如下:
效果4

3. 添加连线

准备工作

图元的意义多半是展示图元间的关系,而这个关系,最直接的就是使用连线来表示。开始前不仅要把图元显示在scene上,也要把图元存储起来。这部分操作放在scene类中更方便实现, 添加以下代码:

class GraphicScene(QGraphicsScene):
    def __init__(self, parent=None):
    	....
    	self.nodes = []  # 存储图元
        self.edges = []  # 存储连线

    def add_node(self, node):
        self.nodes.append(node)
        self.addItem(node)

    def remove_node(self, node):
        self.nodes.remove(node)
        self.removeItem(node)

    def add_edge(self, edge):
        self.edges.append(edge)
        self.addItem(edge)

    def remove_edge(self, edge):
        self.edges.remove(edge)
        self.removeItem(edge)
    ....

则在view中出现的self.gr_scene.addItem(item)self.gr_scene.removeItem(item)都替换为self.gr_scene.add_node(item)self.gr_scene.remove_item(item)

创建线条

线条的实现是继承QGraphicsPathItem,重写paint,shapeboundingRect方法。

class GraphicEdge(QGraphicsPathItem):
    def __init__(self, edge_wrap, parent=None):
        super().__init__(parent)
        # 这个参数是GraphicEdge的包装类,见下文
        self.edge_wrap = edge_wrap
        self.width = 3.0  # 线条的宽度
        self.pos_src = [0, 0]  # 线条起始位置 x,y坐标
        self.pos_dst = [0, 0]  # 线条结束位置

        self._pen = QPen(QColor("#000"))  # 画线条的
        self._pen.setWidthF(self.width)

        self._pen_dragging = QPen(QColor("#000"))  # 画拖拽线条时线条的
        self._pen_dragging.setStyle(Qt.DashDotLine)
        self._pen_dragging.setWidthF(self.width)

        self.setFlag(QGraphicsItem.ItemIsSelectable)  # 线条可选
        self.setZValue(-1)  # 让线条出现在所有图元的最下层

    def set_src(self, x, y):
        self.pos_src = [x, y]

    def set_dst(self, x, y):
        self.pos_dst = [x, y]
	
	# 计算线条的路径
    def calc_path(self):
        path = QPainterPath(QPointF(self.pos_src[0], self.pos_src[1]))  # 起点
        path.lineTo(self.pos_dst[0], self.pos_dst[1])  # 终点
        return path
	
	# override
    def boundingRect(self):
        return self.shape().boundingRect()
	
	# override
    def shape(self):
        return self.calc_path()
	
	# override
    def paint(self, painter, graphics_item, widget=None):
        self.setPath(self.calc_path()) # 设置路径
        path = self.path()
        if self.edge.end_item is None: 
        	# 包装类中存储了线条开始和结束位置的图元
        	# 刚开始拖拽线条时,并没有结束位置的图元,所以是None
        	# 这个线条画的是拖拽路径,点线
            painter.setPen(self._pen_dragging)
            painter.drawPath(path)
        else:
        	# 这画的才是连接后的线
            painter.setPen(self._pen)
            painter.drawPath(path)

下面来看看线条的包装类:

class Edge:
    def __init__(self, scene, start_item, end_item):
    	# 参数分别为场景、开始图元、结束图元
        super().__init__()
        self.scene = scene
        self.start_item = start_item
        self.end_item = end_item
		
		# 线条图形在此处创建
        self.gr_edge = GraphicEdge(self)
        # 此类一旦被初始化就在添加进scene
        self.scene.add_edge(self.gr_edge)
		
		# 开始更新
        if self.start_item is not None:
            self.update_positions()
	
	# 最终保存进scene
    def store(self):
        self.scene.add_edge(self.gr_edge)
	
	# 更新位置
    def update_positions(self):
    	# src_pos 记录的是开始图元的位置,此位置为图元的左上角
        src_pos = self.start_item.pos()
        # 想让线条从图元的中心位置开始,让他们都加上偏移
        patch = self.start_item.width / 2
        self.gr_edge.set_src(src_pos.x()+patch, src_pos.y()+patch)
        # 如果结束位置图元也存在,则做同样操作
        if self.end_item is not None:
            end_pos = self.end_item.pos()
            self.gr_edge.set_dst(end_pos.x()+patch, end_pos.y()+patch)
        else:
            self.gr_edge.set_dst(src_pos.x()+patch, src_pos.y()+patch)
        self.gr_edge.update()

    def remove_from_current_items(self):
        self.end_item = None
        self.start_item = None
	
	# 移除线条
    def remove(self):
        self.remove_from_current_items()
        self.scene.remove_edge(self.gr_edge)
        self.gr_edge = None
连接图元

现在线条已经存在了,如何使用它连接图元呢?在view中添加以下代码:

class GraphicView(QGraphicsView):
	......
	def edge_drag_start(self, item):
        self.drag_start_item = item  # 拖拽开始时的图元,此属性可以不在__init__中声明
        self.drag_edge = Edge(self.gr_scene, self.drag_start_item, None)  # 开始拖拽线条,注意到拖拽终点为None

    def edge_drag_end(self, item):
        new_edge = Edge(self.gr_scene, self.drag_start_item, item)  # 拖拽结束
        self.drag_edge.remove()  # 删除拖拽时画的线
        self.drag_edge = None
        new_edge.store()  # 保存最终产生的连接线

接下来就是如何使用这两个方法,(此例触发连线的条件是按下E键)继续在view类中:

	def __init__(...):
		...
		self.edge_enable = False  # 用来记录目前是否可以画线条
        self.drag_edge = None  # 记录拖拽时的线
    
    # override
    def keyPressEvent(self, event):
        ....
        # 当按下键盘E键时,启动线条功能,再次按下则是关闭
        if event.key() == Qt.Key_E:
            self.edge_enable = ~self.edge_enable
    
    # 此时重构这个方法
    # override
    def mousePressEvent(self, event):
        item = self.get_item_at_click(event)
        if event.button() == Qt.RightButton:
            if isinstance(item, GraphicItem):
                self.gr_scene.remove_node(item)
        elif self.edge_enable:
            if isinstance(item, GraphicItem):
            	# 确认起点是图元后,开始拖拽
                self.edge_drag_start(item)
        else:
        	# 如果写到最开头,则线条拖拽功能会不起作用
            super().mousePressEvent(event)
    
    # override
    def mouseReleaseEvent(self, event):
        if self.edge_enable:
        	# 拖拽结束后,关闭此功能
            self.edge_enable = False
            item = self.get_item_at_click(event)
            # 终点图元不能是起点图元,即无环图
            if isinstance(item, GraphicItem) and item is not self.drag_start_item:
                self.edge_drag_end(item)
            else:
                self.drag_edge.remove()
                self.drag_edge = None
        else:
            super().mouseReleaseEvent(event)
	
	# override
    def mouseMoveEvent(self, event):
    	# 实时更新线条
        pos = event.pos()
        if self.edge_enable and self.drag_edge is not None:
            sc_pos = self.mapToScene(pos)
            self.drag_edge.gr_edge.set_dst(sc_pos.x(), sc_pos.y())
            self.drag_edge.gr_edge.update()
        super().mouseMoveEvent(event)

此时运行,基本效果实现:
效果5
但依旧存在两个BUG:

  1. 再次移动图元,线条的位置不会随着改变。解决方法:只需在图元的类中添加以下代码
class GraphicItem(QGraphicsPixmapItem):
	......
    def mouseMoveEvent(self, event):
        super().mouseMoveEvent(event)
        # 如果图元被选中,就更新连线,这里更新的是所有。可以优化,只更新连接在图元上的。
        if self.isSelected():
            for gr_edge in self.scene().edges:
                gr_edge.edge_wrap.update_positions()
  1. 删除图元后,与图元所连接的线并没有删除。解决方法:
class GraphicScene(QGraphicsScene):
	......
    def remove_node(self, node):
        self.nodes.remove(node)
        # 删除图元时,遍历与其连接的线,并移除
        for edge in self.edges:
            if edge.edge_wrap.start_item is node or edge.edge_wrap.end_item is node:
                self.remove_edge(edge)
        self.removeItem(node)

最终效果如下:
效果6

线条指示

到此处,所实现的图元及连接都是无向图。线条端点处没有指示,很难阐述一些意义。比如本例最开始的线条,两端都有一个红点,代表设备未启动,而当红色点变为绿点时,表明设备已经启动,这就是指示的意义。下面实现一个简易的指示,效果如下:
效果7
就是在GraphicEdge上做文章。在paint方法最后,使用painter.drawEllipse(x, y, radius, radius)画出一个圆点即可。圆点的半径可以规定,但这圆点的x,y坐标怎么获得?可以利用简单的三角函数关系推导,过程如下:
过程1
对应写成一下代码:

import math

class GraphicEdge(QGraphicsPathItem):
    def __init__(self, edge_wrap, parent=None):
    	......
    	# 设置画圆点的画笔
    	self._mark_pen = QPen(Qt.green)
        self._mark_pen.setWidthF(self.width)
        self._mark_brush = QBrush()
        self._mark_brush.setColor(Qt.green)
        self._mark_brush.setStyle(Qt.SolidPattern)
        
    def paint(self, painter, graphics_item, widget=None):
        ......
        else:
            x1, y1 = self.pos_src
            x2, y2 = self.pos_dst
            radius = 5    # 圆点半径
            length = 70   # 圆点距离终点图元的距离,同推导中的 L
            k = math.atan2(y2 - y1, x2 - x1)    # 同推导中的 theta
            new_x = x2 - length * math.cos(k) - self.width  # 减去线条自身的宽度
            new_y = y2 - length * math.sin(k) - self.width
			# 先画最终路径
            painter.setPen(self._pen)
            painter.drawPath(path)
			# 再画圆点
            painter.setPen(self._mark_pen)
            painter.setBrush(self._mark_brush)
            painter.drawEllipse(new_x, new_y, radius, radius)

4. 小结

至此,关于图元的一些基本操作已经实现,剩余的更多操作也不难实现。希望此文对在看的人有所帮助。

完整代码已打包,点击此处下载,提取码:3rxa

  • 62
    点赞
  • 147
    收藏
    觉得还不错? 一键收藏
  • 36
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 36
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值