用PyQt拖放小部件--在容器中用拖放方式对小部件进行可视化分类

406 阅读8分钟

本周,我的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_()

如果你运行这个程序,你应该看到像这样的东西:

Widgets in a layout 一系列的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)

如果你现在运行代码,你可以拖动按钮,但你会发现拖动是被禁止的。

Drag forbidden 拖动小部件开始了,但被禁止了。

发生了什么?鼠标移动被我们的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_() 时传递的动作决定的。

Drag accepted 拖动小部件开始并被接受,显示移动图标。

在拖放操作中释放鼠标按钮,会在你当前悬停的小部件上触发一个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 对象上设置生成的像素图。

如果你运行这个修改后的代码,你会看到类似以下的东西 --

Drag visual 拖动小部件显示被拖动的小部件。

通用的拖放容器

我们可以更进一步,实现一个通用的拖放小部件,允许我们对任意对象进行排序。在下面的代码中,我们创建了一个新的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_()

Generic drag drop horizontal 在水平方向的通用拖放排序。

你会注意到,在创建项目时,你可以将标签作为一个参数传入来设置(就像我们从一个普通的QLabel )。但是你也可以设置一个数据值,这是这个项目的内部值--当订单改变时,或者当你自己调用get_item_data ,这将会被发出来。这就把视觉表现和实际被排序的东西分开了,这意味着你可以用它来排序任何东西,而不仅仅是字符串。

在上面的例子中,我们将枚举的索引作为数据传入,所以拖动将输出(通过连接到orderChangedprint )类似的东西。

蟒蛇

[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.VerticalQt.Orientation.HorizontalDragWidget 上实现了方向性。这种设置允许你对项目进行垂直或水平排序 -- 两个方向的计算都被处理。

Generic drag drop vertical 垂直方向上的通用拖放排序。