本周,我的PyQt6书的读者 ,提出了一个有趣的问题,关于如何处理在容器中拖放小部件,显示被拖放的小部件的移动情况。
我对管理容器中用鼠标移动的QWidget感兴趣。我已经用拖放实现了应用程序,交换了按钮的位置,但我想显示
QPushButton,就像你在Qt Designer中看到的那样。拖动一个小部件应该显示小部件本身,而不仅仅是鼠标指针。
首先,我们将实现一个简单的案例,即拖动部件而不显示任何额外的东西。然后我们可以扩展它来回答 这个问题。
拖放小部件
我们将从这个简单的应用程序开始,它使用QWidget 创建一个窗口,并将一系列的QPushButton 小部件放入其中。
你可以用QPushButton 代替你喜欢的任何其他部件,例如:QLabel
python
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton
class Window(QWidget):
def __init__(self):
super().__init__()
self.blayout = QHBoxLayout()
for l in ['A', 'B', 'C', 'D']:
btn = QPushButton(l)
self.blayout.addWidget(btn)
self.setLayout(self.blayout)
app = QApplication([])
w = Window()
w.show()
app.exec_()
如果你运行这个程序,你应该看到像这样的东西:
一系列的QPushButton widgets ,呈水平布局。
这里我们正在创建一个窗口,但Window widget是从QWidget 子类化的,这意味着你可以将这个widget添加到任何其他的布局中。请看后面一个通用对象排序部件的例子。
QPushButton 对象通常不是可拖动的,所以为了处理鼠标移动并启动拖动,我们需要实现一个子类。我们可以在文件的顶部添加以下内容。
python
from PyQt5.QtCore import Qt, QMimeData
from PyQt5.QtGui import QDrag
class DragButton(QPushButton):
def mouseMoveEvent(self, e):
if e.buttons() == Qt.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec_(Qt.MoveAction)
我们实现一个mouseMoveEvent ,它接受事件的单一e 参数。我们检查鼠标左键是否在此事件中被按下--就像拖动时那样--然后启动拖动。为了开始拖动,我们创建一个QDrag 对象,并传入self ,以便我们以后访问被拖动的部件。我们还必须传入mime数据。这用于包括有关被拖动内容的信息,特别是在应用程序之间传递数据。然而,就像这里一样,将其留空也是可以的。
最后,我们通过调用drag.exec_(Qt.MoveAction) 来启动一个拖动。与对话框一样,exec_() 启动一个新的事件循环,阻断主循环直到拖动完成。参数Qt.MoveAction 告诉拖动处理程序正在发生什么类型的操作,因此它可以向用户显示适当的图标提示。
你可以更新主窗口代码以使用我们新的DragButton 类,如下所示。
class Window(QWidget):
def __init__(self):
super().__init__()
self.blayout = QHBoxLayout()
for l in ['A', 'B', 'C', 'D']:
btn = DragButton(l)
self.blayout.addWidget(btn)
self.setLayout(self.blayout)
如果你现在运行代码,你可以拖动按钮,但你会发现拖动是被禁止的。
拖动小部件开始了,但被禁止了。
发生了什么?鼠标移动被我们的DragButton 对象检测到并开始拖动,但主窗口不接受拖放。
为了解决这个问题,我们需要在窗口上启用拖放功能,并实现dragEnterEvent ,以实际接受它们。
class Window(QWidget):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.blayout = QHBoxLayout()
for l in ['A', 'B', 'C', 'D']:
btn = DragButton(l)
self.blayout.addWidget(btn)
self.setLayout(self.blayout)
def dragEnterEvent(self, e):
e.accept()
如果你现在运行这个,你会看到拖动现在被接受了,你会看到移动图标。这表明拖动已经开始并被我们所拖动的窗口所接受。显示的图标是由我们在调用drag.exec_() 时传递的动作决定的。
拖动小部件开始并被接受,显示移动图标。
在拖放操作中释放鼠标按钮,会在你当前悬停的小部件上触发一个dropEvent (如果它被配置为接受拖放)。在我们的例子中,那就是窗口。为了处理移动,我们需要在我们的dropEvent 方法中实现这样的代码。
下拉事件包含鼠标在释放按钮和下拉触发时的位置。我们可以用它来决定将小部件移动到哪里。
def dropEvent(self, e):
pos = e.pos()
widget = e.source()
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if pos.x() < w.x():
# We didn't drag past this widget.
# insert to the left of it.
self.blayout.insertWidget(n-1, widget)
break
e.accept()
为了确定放置小部件的位置,我们遍历布局中的所有小部件,直到找到一个x ,其位置大于鼠标指针的位置。如果是这样的话,那么我们就在这个小部件的左边直接插入小部件,然后退出循环。
这样做的效果是,如果你拖动1个像素超过了另一个小部件的开始,这可能有点混乱。你调整这个切口,使用if pos.x() < w.x() + w.size().width() // 2: ,即x+宽度的一半,来使用小部件的中间位置。
def dropEvent(self, e):
pos = e.pos()
widget = e.source()
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if pos.x() < w.x() + w.size().width() // 2:
# We didn't drag past this widget.
# insert to the left of it.
self.blayout.insertWidget(n-1, widget)
break
e.accept()
完整的工作拖放代码如下所示:
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton
from PyQt5.QtCore import Qt, QMimeData
from PyQt5.QtGui import QDrag
class DragButton(QPushButton):
def mouseMoveEvent(self, e):
if e.buttons() == Qt.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec_(Qt.MoveAction)
class Window(QWidget):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.blayout = QHBoxLayout()
for l in ['A', 'B', 'C', 'D']:
btn = DragButton(l)
self.blayout.addWidget(btn)
self.setLayout(self.blayout)
def dragEnterEvent(self, e):
e.accept()
def dropEvent(self, e):
pos = e.pos()
widget = e.source()
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if pos.x() < w.x() + w.size().width() // 2:
# We didn't drag past this widget.
# insert to the left of it.
self.blayout.insertWidget(n-1, widget)
break
e.accept()
app = QApplication([])
w = Window()
w.show()
app.exec_()
视觉拖放
所以现在我们有了工作中的拖放实现,我们可以继续以视觉方式显示拖放。我们想要实现的是,在拖动时显示鼠标点旁边被拖动的按钮。
Qt的QDrag 处理器提供了一个显示拖动对象的机制,我们可以使用它。我们可以更新我们的DragButton 类,将一个像素图象传递给QDrag ,当拖动发生时,它将显示在鼠标指针下。为了显示小部件,我们只需要获得我们正在拖动的小部件的QPixmap 。
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QPushButton
from PyQt5.QtCore import Qt, QMimeData
from PyQt5.QtGui import QDrag, QPixmap
class DragButton(QPushButton):
def mouseMoveEvent(self, e):
if e.buttons() == Qt.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
pixmap = QPixmap(self.size())
self.render(pixmap)
drag.setPixmap(pixmap)
drag.exec_(Qt.MoveAction)
为了创建像素图,我们创建了一个QPixmap 对象,并通过self.size() 传入该事件被触发的小部件的大小。这将创建一个空的像素图,然后我们可以将其传入self.render ,以便在其上渲染--或绘制--当前的小部件。这就是了。然后我们在drag 对象上设置生成的像素图。
如果你运行这个修改后的代码,你会看到类似以下的东西 --
拖动小部件显示被拖动的小部件。
通用的拖放容器
我们可以更进一步,实现一个通用的拖放小部件,允许我们对任意对象进行排序。在下面的代码中,我们创建了一个新的widgetDragWidget ,它可以被添加到任何窗口。你可以添加你希望被排序的项目--DragItem 的实例,以及设置它们的数据。当项目被重新排序时,新的顺序会作为一个信号发射出去,orderChanged 。
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QLabel, QMainWindow, QVBoxLayout
from PyQt5.QtCore import Qt, QMimeData, pyqtSignal
from PyQt5.QtGui import QDrag, QPixmap
class DragItem(QLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setContentsMargins(25, 5, 25, 5)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet("border: 1px solid black;")
# Store data separately from display label, but use label for default.
self.data = self.text()
def set_data(self, data):
self.data = data
def mouseMoveEvent(self, e):
if e.buttons() == Qt.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
pixmap = QPixmap(self.size())
self.render(pixmap)
drag.setPixmap(pixmap)
drag.exec_(Qt.MoveAction)
class DragWidget(QWidget):
"""
Generic list sorting handler.
"""
orderChanged = pyqtSignal(list)
def __init__(self, *args, orientation=Qt.Orientation.Vertical, **kwargs):
super().__init__()
self.setAcceptDrops(True)
# Store the orientation for drag checks later.
self.orientation = orientation
if self.orientation == Qt.Orientation.Vertical:
self.blayout = QVBoxLayout()
else:
self.blayout = QHBoxLayout()
self.setLayout(self.blayout)
def dragEnterEvent(self, e):
e.accept()
def dropEvent(self, e):
pos = e.pos()
widget = e.source()
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if self.orientation == Qt.Orientation.Vertical:
# Drag drop vertically.
drop_here = pos.y() < w.y() + w.size().height() // 2
else:
# Drag drop horizontally.
drop_here = pos.x() < w.x() + w.size().width() // 2
if drop_here:
# We didn't drag past this widget.
# insert to the left of it.
self.blayout.insertWidget(n-1, widget)
self.orderChanged.emit(self.get_item_data())
break
e.accept()
def add_item(self, item):
self.blayout.addWidget(item)
def get_item_data(self):
data = []
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
data.append(w.data)
return data
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.drag = DragWidget(orientation=Qt.Orientation.Vertical)
for n, l in enumerate(['A', 'B', 'C', 'D']):
item = DragItem(l)
item.set_data(n) # Store the data.
self.drag.add_item(item)
# Print out the changed order.
self.drag.orderChanged.connect(print)
container = QWidget()
layout = QVBoxLayout()
layout.addStretch(1)
layout.addWidget(self.drag)
layout.addStretch(1)
container.setLayout(layout)
self.setCentralWidget(container)
app = QApplication([])
w = MainWindow()
w.show()
app.exec_()
在水平方向的通用拖放排序。
你会注意到,在创建项目时,你可以将标签作为一个参数传入来设置(就像我们从一个普通的QLabel )。但是你也可以设置一个数据值,这是这个项目的内部值--当订单改变时,或者当你自己调用get_item_data ,这将会被发出来。这就把视觉表现和实际被排序的东西分开了,这意味着你可以用它来排序任何东西,而不仅仅是字符串。
在上面的例子中,我们将枚举的索引作为数据传入,所以拖动将输出(通过连接到orderChanged 的print )类似的东西。
蟒蛇
[1, 0, 2, 3]
[1, 2, 0, 3]
[1, 0, 2, 3]
[1, 2, 0, 3]
如果你去掉item.set_data(n) ,你会看到变化时发出的标签。
['B', 'A', 'C', 'D']
['B', 'C', 'A', 'D']
我们还使用Qt内置的标志Qt.Orientation.Vertical 或Qt.Orientation.Horizontal 在DragWidget 上实现了方向性。这种设置允许你对项目进行垂直或水平排序 -- 两个方向的计算都被处理。
垂直方向上的通用拖放排序。