【发布时间】:2021-01-23 11:31:18
【问题描述】:
我在场景中有一些自定义项目。我想允许用户使用鼠标连接这两个项目。我检查了this 问题中的答案,但没有规定让用户连接这两点。 (另外,请注意项目必须是可移动的)
这是我想要的演示:
我想要两个椭圆之间的连接如上图
我能知道这是怎么做到的吗?
【问题讨论】:
我在场景中有一些自定义项目。我想允许用户使用鼠标连接这两个项目。我检查了this 问题中的答案,但没有规定让用户连接这两点。 (另外,请注意项目必须是可移动的)
这是我想要的演示:
我想要两个椭圆之间的连接如上图
我能知道这是怎么做到的吗?
【问题讨论】:
虽然proposed by JacksonPro 的解决方案很好,但我想提供一个稍微不同的概念,增加一些好处:
我们的想法是拥有 实际 QGraphicsItem 对象 (QGraphicsEllipseItem) 和 CustomItem 的 子项 的控制点。
这不仅简化了绘制,还改进了对象碰撞检测和管理:不需要复杂的函数来绘制新线,并且创建一个围绕其pos 绘制的椭圆围绕确保我们已经通过获取他们的scenePos() 知道了该行的目标;这也使得检测鼠标光标是否真的在控制点内变得更加容易。
请注意,为了简单起见,我将一些属性设置为类成员。如果您想为更高级或自定义控件创建项目的子类,则应将这些参数创建为实例属性;在这种情况下,您可能更喜欢从 QGraphicsRectItem 继承:即使您仍然需要覆盖绘画以绘制圆角矩形,它也会更容易设置其属性(笔、画笔和矩形)甚至更改它们在运行时,因此您只需要在 paint() 中访问这些属性,同时还确保在 Qt 需要时正确调用更新。
from PyQt5 import QtCore, QtGui, QtWidgets
class Connection(QtWidgets.QGraphicsLineItem):
def __init__(self, start, p2):
super().__init__()
self.start = start
self.end = None
self._line = QtCore.QLineF(start.scenePos(), p2)
self.setLine(self._line)
def controlPoints(self):
return self.start, self.end
def setP2(self, p2):
self._line.setP2(p2)
self.setLine(self._line)
def setStart(self, start):
self.start = start
self.updateLine()
def setEnd(self, end):
self.end = end
self.updateLine(end)
def updateLine(self, source):
if source == self.start:
self._line.setP1(source.scenePos())
else:
self._line.setP2(source.scenePos())
self.setLine(self._line)
class ControlPoint(QtWidgets.QGraphicsEllipseItem):
def __init__(self, parent, onLeft):
super().__init__(-5, -5, 10, 10, parent)
self.onLeft = onLeft
self.lines = []
# this flag **must** be set after creating self.lines!
self.setFlags(self.ItemSendsScenePositionChanges)
def addLine(self, lineItem):
for existing in self.lines:
if existing.controlPoints() == lineItem.controlPoints():
# another line with the same control points already exists
return False
self.lines.append(lineItem)
return True
def removeLine(self, lineItem):
for existing in self.lines:
if existing.controlPoints() == lineItem.controlPoints():
self.scene().removeItem(existing)
self.lines.remove(existing)
return True
return False
def itemChange(self, change, value):
for line in self.lines:
line.updateLine(self)
return super().itemChange(change, value)
class CustomItem(QtWidgets.QGraphicsItem):
pen = QtGui.QPen(QtCore.Qt.red, 2)
brush = QtGui.QBrush(QtGui.QColor(31, 176, 224))
controlBrush = QtGui.QBrush(QtGui.QColor(214, 13, 36))
rect = QtCore.QRectF(0, 0, 100, 100)
def __init__(self, left=False, right=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlags(self.ItemIsMovable)
self.controls = []
for onLeft, create in enumerate((right, left)):
if create:
control = ControlPoint(self, onLeft)
self.controls.append(control)
control.setPen(self.pen)
control.setBrush(self.controlBrush)
if onLeft:
control.setX(100)
control.setY(35)
def boundingRect(self):
adjust = self.pen.width() / 2
return self.rect.adjusted(-adjust, -adjust, adjust, adjust)
def paint(self, painter, option, widget=None):
painter.save()
painter.setPen(self.pen)
painter.setBrush(self.brush)
painter.drawRoundedRect(self.rect, 4, 4)
painter.restore()
class Scene(QtWidgets.QGraphicsScene):
startItem = newConnection = None
def controlPointAt(self, pos):
mask = QtGui.QPainterPath()
mask.setFillRule(QtCore.Qt.WindingFill)
for item in self.items(pos):
if mask.contains(pos):
# ignore objects hidden by others
return
if isinstance(item, ControlPoint):
return item
if not isinstance(item, Connection):
mask.addPath(item.shape().translated(item.scenePos()))
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
item = self.controlPointAt(event.scenePos())
if item:
self.startItem = item
self.newConnection = Connection(item, event.scenePos())
self.addItem(self.newConnection)
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.newConnection:
item = self.controlPointAt(event.scenePos())
if (item and item != self.startItem and
self.startItem.onLeft != item.onLeft):
p2 = item.scenePos()
else:
p2 = event.scenePos()
self.newConnection.setP2(p2)
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.newConnection:
item = self.controlPointAt(event.scenePos())
if item and item != self.startItem:
self.newConnection.setEnd(item)
if self.startItem.addLine(self.newConnection):
item.addLine(self.newConnection)
else:
# delete the connection if it exists; remove the following
# line if this feature is not required
self.startItem.removeLine(self.newConnection)
self.removeItem(self.newConnection)
else:
self.removeItem(self.newConnection)
self.startItem = self.newConnection = None
super().mouseReleaseEvent(event)
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
scene = Scene()
scene.addItem(CustomItem(left=True))
scene.addItem(CustomItem(left=True))
scene.addItem(CustomItem(right=True))
scene.addItem(CustomItem(right=True))
view = QtWidgets.QGraphicsView(scene)
view.setRenderHints(QtGui.QPainter.Antialiasing)
view.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
一个小建议:我看到你有总是在paint方法中创建对象的习惯,即使这些值通常是“硬编码”的; Graphics View 框架最重要的方面之一是它的性能,这显然会被 python 部分降低,所以如果你有在运行时保持不变的属性(矩形、钢笔、画笔),通常最好让它们更“静态” ",至少作为实例属性,为了尽可能的简化绘画。
【讨论】:
def controlPointAt(self, pos): 你能解释一下吗?我还启用了橡皮筋选择,我想在用户画线时禁用它。目前我在if item: 下设置self.views()[0].setDragMode(QtWidgets.QGraphicsView.NoDrag) 并在mousePressEvent 中添加else:self.views()[0].setDragMode(QtWidgets.QGraphicsView.RubberBandDrag)。我想知道这是最好的方法还是有其他方法?
controlPointAt 是必需的,因为itemAt() 总是返回给定坐标处的top item(基于shape() 实现),这是一个问题它可能会返回一个现有的“连接”(在我的例子中是 QGraphicsLineItem):如果一个“连接”(通常堆叠在 上方 共享zValues 的所有其他元素)与控制点发生冲突,您除非其 CustomItem 被移动(或连接到该连接的任何项目被移走),否则将无法使用该点。该函数确保始终只返回最顶部的可见控制点(如果有的话)。
mask = QtGui.QPainterPath() mask.setFillRule(QtCore.Qt.WindingFill) 我确实检查了我仍然没有得到的文档。还请就橡皮筋的选择提出建议。
viewportEvent 并返回@ 987654338@ 用于MouseButtonPress 事件,只要鼠标下的项目是 控制点(使用上面的controlPointAt())和进一步的MouseMove 事件,否则总是返回基本实现(return super().viewportEvent(event)) .
setFillRule 可能没有必要,因为我相信contains()的碰撞检测没有考虑到这一点,但比抱歉:在某些情况下,根据构建时使用的顺序和坐标,以不同的方式考虑复杂路径。无论如何,我建议阅读its documentation,因为它对其他情况也很有用。
为此,您可能必须通过继承 QGraphicsScene 并覆盖鼠标事件来实现自己的场景类。
这是你可以改进的代码:
import sys
from PyQt5 import QtWidgets, QtCore, QtGui
class CustomItem(QtWidgets.QGraphicsItem):
def __init__(self, pointONLeft=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ellipseOnLeft = pointONLeft
self.point = None
self.endPoint =None
self.isStart = None
self.line = None
self.setAcceptHoverEvents(True)
self.setFlag(self.ItemIsMovable)
self.setFlag(self.ItemSendsGeometryChanges)
def addLine(self, line, ispoint):
if not self.line:
self.line = line
self.isStart = ispoint
def itemChange(self, change, value):
if change == self.ItemPositionChange and self.scene():
self.moveLineToCenter(value)
return super(CustomItem, self).itemChange(change, value)
def moveLineToCenter(self, newPos): # moves line to center of the ellipse
if self.line:
if self.ellipseOnLeft:
xOffset = QtCore.QRectF(-5, 30, 10, 10).x() + 5
yOffset = QtCore.QRectF(-5, 30, 10, 10).y() + 5
else:
xOffset = QtCore.QRectF(95, 30, 10, 10).x() + 5
yOffset = QtCore.QRectF(95, 30, 10, 10).y() + 5
newCenterPos = QtCore.QPointF(newPos.x() + xOffset, newPos.y() + yOffset)
p1 = newCenterPos if self.isStart else self.line.line().p1()
p2 = self.line.line().p2() if self.isStart else newCenterPos
self.line.setLine(QtCore.QLineF(p1, p2))
def containsPoint(self, pos): # checks whether the mouse is inside the ellipse
x = self.mapToScene(QtCore.QRectF(-5, 30, 10, 10).adjusted(-0.5, 0.5, 0.5, 0.5)).containsPoint(pos, QtCore.Qt.OddEvenFill) or \
self.mapToScene(QtCore.QRectF(95, 30, 10, 10).adjusted(0.5, 0.5, 0.5, 0.5)).containsPoint(pos,
QtCore.Qt.OddEvenFill)
return x
def boundingRect(self):
return QtCore.QRectF(-5, 0, 110, 110)
def paint(self, painter, option, widget):
pen = QtGui.QPen(QtCore.Qt.red)
pen.setWidth(2)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush(QtGui.QColor(31, 176, 224)))
painter.drawRoundedRect(QtCore.QRectF(0, 0, 100, 100), 4, 4)
painter.setBrush(QtGui.QBrush(QtGui.QColor(214, 13, 36)))
if self.ellipseOnLeft: # draws ellipse on left
painter.drawEllipse(QtCore.QRectF(-5, 30, 10, 10))
else: # draws ellipse on right
painter.drawEllipse(QtCore.QRectF(95, 30, 10, 10))
# ------------------------Scene Class ----------------------------------- #
class Scene(QtWidgets.QGraphicsScene):
def __init__(self):
super(Scene, self).__init__()
self.startPoint = None
self.endPoint = None
self.line = None
self.graphics_line = None
self.item1 = None
self.item2 = None
def mousePressEvent(self, event):
self.line = None
self.graphics_line = None
self.item1 = None
self.item2 = None
self.startPoint = None
self.endPoint = None
if self.itemAt(event.scenePos(), QtGui.QTransform()) and isinstance(self.itemAt(event.scenePos(),
QtGui.QTransform()), CustomItem):
self.item1 = self.itemAt(event.scenePos(), QtGui.QTransform())
self.checkPoint1(event.scenePos())
if self.startPoint:
self.line = QtCore.QLineF(self.startPoint, self.endPoint)
self.graphics_line = self.addLine(self.line)
self.update_path()
super(Scene, self).mousePressEvent(event)
def mouseMoveEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton and self.startPoint:
self.endPoint = event.scenePos()
self.update_path()
super(Scene, self).mouseMoveEvent(event)
def filterCollidingItems(self, items): # filters out all the colliding items and returns only instances of CustomItem
return [x for x in items if isinstance(x, CustomItem) and x != self.item1]
def mouseReleaseEvent(self, event):
if self.graphics_line:
self.checkPoint2(event.scenePos())
self.update_path()
if self.item2 and not self.item1.line and not self.item2.line:
self.item1.addLine(self.graphics_line, True)
self.item2.addLine(self.graphics_line, False)
else:
if self.graphics_line:
self.removeItem(self.graphics_line)
super(Scene, self).mouseReleaseEvent(event)
def checkPoint1(self, pos):
if self.item1.containsPoint(pos):
self.item1.setFlag(self.item1.ItemIsMovable, False)
self.startPoint = self.endPoint = pos
else:
self.item1.setFlag(self.item1.ItemIsMovable, True)
def checkPoint2(self, pos):
item_lst = self.filterCollidingItems(self.graphics_line.collidingItems())
contains = False
if not item_lst: # checks if there are any items in the list
return
for self.item2 in item_lst:
if self.item2.containsPoint(pos):
contains = True
self.endPoint = pos
break
if not contains:
self.item2 = None
def update_path(self):
if self.startPoint and self.endPoint:
self.line.setP2(self.endPoint)
self.graphics_line.setLine(self.line)
def main():
app = QtWidgets.QApplication(sys.argv)
scene = Scene()
item1 = CustomItem(True)
scene.addItem(item1)
item2 = CustomItem()
scene.addItem(item2)
view = QtWidgets.QGraphicsView(scene)
view.setViewportUpdateMode(view.FullViewportUpdate)
view.setMouseTracking(True)
view.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
上面代码的解释:
我通过继承 QGraphicsItem 来制作自己的自定义项目。 pointONLeft=False 是检查要绘制椭圆的哪一侧。如果pointONLeft=True,那么您在问题图像中看到的红色圆圈将绘制在左侧。
addLine、itemChange 和 moveLineToCenter 方法取自 here。我建议您在继续之前完成该答案。
CustomItem 内部的containsPoint 方法检查鼠标是否在圆圈内。此方法将从自定义Scene 访问,如果鼠标在圆圈内,它将使用CustomiItem.setFlag(CustomItem.ItemIsMovable, False) 禁用移动。
为了画线,我使用了 PyQt 提供的QLineF。如果您想知道如何通过拖动来绘制直线,我建议您参考this。而对于qpainterpath 的解释也可以在这里应用。
collidingItems() 是QGraphicsItem 提供的方法。它返回所有发生碰撞的项目,包括线本身。因此,我创建了 filterCollidingItems 以仅过滤出属于 CustomItem 实例的项目。
(另外,请注意collidingItems() 以相反的插入顺序返回碰撞项目,即如果首先插入 CustomItem1,然后插入 CustomItem,那么如果行碰撞,则将首先返回第二个项目。所以如果两个项目彼此在并且线发生碰撞然后最后插入的项目将变为item2您可以通过更改z value来更改它)
读者可以在 cmets 中添加建议或查询。如果您有更好的答案,请随时写。
【讨论】: