图元的拖拽及连线这类操作在一些桌面软件中很是常见。比如在学校用的eNSP(如下图),很直观,图元代表模型,连线代表连接(或关系)。本文就从头开始,实现这些功能,效果也如下图。
建立图元首先要有一个“画板”。
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()
此时运行,效果如下。背景是白色的,蓝色的区域是我用鼠标拖拽时产生的。多少有些不美观,这样,我们换个背景。换一个稍微看起来高大上一些的背景。
这样一来,就需要重写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)
。运行,效果如下:
这样看起来就美观了许多。
QGraphicsItem
及其子类。图元在是画在scene中的,而且可以由QGraphicsScene
的addItem()
和removeItem()
添加或删除。但这些图元目前只在视图上显示,然后这样操作的意义并不大。或许你可以考虑再将它存储起来,方便功能拓展。QGraphicsItem
的子类QGraphicsPixmapItem
。原因很简单,我的图元是以图片的形式出现的,而且这个类提供了针对图片图元很亲和的方法。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)
现在将图元从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) # 返回一个所有选中图元的列表,对此操作即可
图元的意义多半是展示图元间的关系,而这个关系,最直接的就是使用连线来表示。开始前不仅要把图元显示在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
,shape
和boundingRect
方法。
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)
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()
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)
到此处,所实现的图元及连接都是无向图。线条端点处没有指示,很难阐述一些意义。比如本例最开始的线条,两端都有一个红点,代表设备未启动,而当红色点变为绿点时,表明设备已经启动,这就是指示的意义。下面实现一个简易的指示,效果如下:
就是在GraphicEdge
上做文章。在paint
方法最后,使用painter.drawEllipse(x, y, radius, radius)
画出一个圆点即可。圆点的半径可以规定,但这圆点的x,y
坐标怎么获得?可以利用简单的三角函数关系推导,过程如下:
对应写成一下代码:
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)
至此,关于图元的一些基本操作已经实现,剩余的更多操作也不难实现。希望此文对在看的人有所帮助。
完整代码已打包,点击此处下载,提取码:3rxa