首页 > 解决方案 > 在 QtreeWidget 中拖动 Qframe

问题描述

嗨,我正在阅读这篇关于如何拖放小部件项目的文章,这很有意义,例如

Qt 的项目视图使用内部 application/x-qabstractitemmodeldatalist MIME 类型传递项目

我还阅读了这个问题,主要关注来自 Qlinewidget 和 QTreewidget 的字符串模型。

但是我怎样才能得到一个QAbstractItemView.model()包含多个 QtWidgets 的 QFrame。

底线问题是:如何在 QTreeWidget 中移动包含多个 QtWidgets 的 QFrame。请看下面的示例代码:按下按钮添加子节点并尝试将它们拖到其他子节点或第一级树层次结构的父节点之间

from PyQt5.QtWidgets import (QTreeWidget, QTreeWidgetItem, QPushButton, QLabel, QDialog, QVBoxLayout, QApplication, QLineEdit)
from PyQt5.QtWidgets import (QPushButton, QDialog, QTreeWidget,
                             QTreeWidgetItem, QVBoxLayout,
                             QHBoxLayout, QFrame, QLabel, QComboBox,
                             QApplication)

class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        self.index=0
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(800, 600)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
        self.gridLayout.setObjectName("gridLayout")
        self.treeWidget = QtWidgets.QTreeWidget(self.centralwidget)
        self.treeWidget.setObjectName("treeWidget")
        self.treeWidget.setFrameShape(QtWidgets.QFrame.StyledPanel)
        self.treeWidget.setFrameShadow(QtWidgets.QFrame.Sunken)
        self.treeWidget.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
        self.treeWidget.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
        self.treeWidget.setAutoScrollMargin(10)
        
        self.treeWidget.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        
        self.treeWidget.setDragEnabled(1)
        self.treeWidget.setDefaultDropAction(QtCore.Qt.MoveAction)
        self.treeWidget.setAnimated(True)
        self.treeWidget.setWordWrap(False)
        self.treeWidget.setExpandsOnDoubleClick(True)
        self.treeWidget.setObjectName("treeWidget")
        self.treeWidget.header().setVisible(False)
        self.treeWidget.header().setHighlightSections(False)
        self.treeWidget.header().setSortIndicatorShown(False)
        self.treeWidget.header().setStretchLastSection(True)

        self.gridLayout.addWidget(self.treeWidget, 0, 0, 1, 1)
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 21))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        #initialize top level items
        self.topLevelItem1 = QTreeWidgetItem(self.treeWidget)
        
        
        #add those top level in treewidget
        self.treeWidget.addTopLevelItem(self.topLevelItem1)

        #create button
        self.ButtonWidget=QtWidgets.QPushButton("Press")
        self.ButtonWidget.clicked.connect(self.AddQFrame)
        #add button to tree widget
        self.treeWidget.setItemWidget(self.topLevelItem1, 0, self.ButtonWidget)
        
        
        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)


    def AddQFrame(self):
        #add combo box on button press
        self.index=self.index+1

        #create child item
        self.ChildItem1 = QTreeWidgetItem()
        self.topLevelItem1.addChild(self.ChildItem1)

        #create frame & horizontal layout for that child item
        self.ChildWidgetFrame=QFrame(self.treeWidget)
        self.layoutChild=QHBoxLayout(self.ChildWidgetFrame)
        
        #layout in qframe
        self.QcomboWidget=QtWidgets.QComboBox()
        self.QcomboWidget.addItem("item "+str(self.index))
        self.QcomboWidget.addItem("CC")
        self.QcomboWidget.addItem("CV")
        self.spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
        #widgets in layout
        self.layoutChild.addWidget(QLabel("#"+str(self.index)))
        self.layoutChild.addWidget(self.QcomboWidget)
        self.layoutChild.addItem(self.spacerItem3)
        #display widget
        self.treeWidget.setItemWidget(self.ChildItem1, 0, self.ChildWidgetFrame)
        

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
        self.treeWidget.setSortingEnabled(False)
        self.treeWidget.headerItem().setText(0, _translate("MainWindow", "1"))
        __sortingEnabled = self.treeWidget.isSortingEnabled()
        self.treeWidget.setSortingEnabled(False)
        self.treeWidget.setSortingEnabled(__sortingEnabled)
        
    

if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())

编辑

这个问题在拖动哪个树级别方面令人困惑。所以我编辑了这个问题:我只是在第一层级中重新排列/移动孩子/父母。感谢您的时间和努力!

编辑2

另一个编辑以阐明 treewidget 层次结构的级别。

这就是我想要实现的目标:Dnd Parent Qframe 及其子级达到树的第一级。

示例图像

标签: pythonpyqt5qtreewidget

解决方案


前提

OP试图实现的目标并不容易。索引小部件与底层模型无关(它们不应该!),因为它们只与项目视图相关。由于拖放操作作用于 QMimeData 对象及其内容(序列化为字节数据),因此没有直接的方法可以从拖放事件中访问索引小部件。这意味着 d&d 动作只作用于项目模型,而索引小部件被完全忽略。

但是,这还不够。
即使您可以获得对索引小部件的字节引用,一旦索引小部件被替换或删除,这些小部件总是会被删除:主要问题与setItemWidget()相同setIndexWidget()

如果索引小部件 A 被索引小部件 B 替换,则索引小部件 A 将被删除。

源代码执行以下操作:

void QAbstractItemView::setIndexWidget(const QModelIndex &index, QWidget *widget)
{
    # ...
    if (QWidget *oldWidget = indexWidget(index)) {
        d->persistent.remove(oldWidget);
        d->removeEditor(oldWidget);
        oldWidget->removeEventFilter(this);
        oldWidget->deleteLater();
    }
    # ...

}

结果是,每当设置索引小部件(或删除索引)时,相关的索引小部件就会被删除。从PyQt的角度来看,我们无法控制这一点,除非我们彻底实现相关的项目视图类(并且......祝你好运)。

树模型的注意事项

Qt 有自己的方式来支持InternalMove树模型的标志。在下面的解释中,我假设拖放操作总是在为属性SingleSelection设置的模式下发生,并且设置为 default 。如果您想为高级拖放模式提供扩展选择的实现,您必须自己找到(可能通过研究 QAbstractItemView 和 QTreeView 的源代码)。selectionMode()dragDropMode()InternalMove

[解决方法] 解决方法

不过,有一个技巧。
唯一deleteLater()被调用的小部件是用 设置的实际小部件setIndexWidget(),而不是它的子部件。
因此,在这些情况下,要使用索引小部件添加对拖放的支持,唯一简单的解决方案是始终添加带有容器父小部件的索引小部件,并在替换/删除索引小部件之前setIndexWidget()从容器中删除实际小部件,然后创建在新索引/项目上使用(或)之前实际小部件的新容器setItemWidget(),可能带有递归函数以确保保留子引用。

这确保了实际显示的(以前的)索引小部件不会被删除,因为只有它的容器会被删除,从而允许我们将该小部件设置为另一个索引。

幸运的是,QTreeWidget 可以更轻松地访问这些项目,因为这些项目是实际的和持久的对象,即使在它们被移动后也可以进行跟踪(与 QTreeView 中的 QModelIndex 发生的情况不同)。

在下面的示例中(使用评论中提供的信息进行了更新)我正在创建顶级项目并只允许在第一级放置。这是一个基本示例,您可能想要添加一些功能:例如,如果项目组合未设置为“重复”,则防止丢弃,甚至创建一个已设置为“重复”的新父项并手动添加子项。

class WidgetDragTree(QtWidgets.QTreeWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.header().hide()
        self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        self.setDragEnabled(True)
        self.setDefaultDropAction(QtCore.Qt.MoveAction)

    def addFrame(self):
        item = QtWidgets.QTreeWidgetItem()
        self.addTopLevelItem(item)
        item.setExpanded(True)

        # create the "virtual" container; use 0 contents margins for the layout 
        # to avoid unnecessary padding around the widget
        container = QtWidgets.QWidget(self)
        layout = QtWidgets.QHBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)

        # the *actual* widget that we want to add
        widget = QtWidgets.QFrame()
        layout.addWidget(widget)
        frameLayout = QtWidgets.QHBoxLayout(widget)

        widget.label = QtWidgets.QLabel('#{}'.format(self.topLevelItemCount()))
        frameLayout.addWidget(widget.label)
        combo = QtWidgets.QComboBox()
        frameLayout.addWidget(combo)
        combo.addItems(['Select process', 'CC', 'VV', 'Repeat'])

        # add a spacer at the end to keep widgets at their minimum required size
        frameLayout.addStretch()

        # the widget has to be added AT THE END, otherwise its sizeHint won't be 
        # correctly considered for the index
        self.setItemWidget(item, 0, container)

    def delFrame(self):
        for index in self.selectedIndexes():
            item = self.itemFromIndex(index)
            if item.parent():
                item.parent().takeChild(item)
            else:
                self.takeTopLevelItem(index.row())

    def updateLabels(self, parent=None):
        if parent is None:
            parent = self.rootIndex()
        for row in range(self.model().rowCount(parent)):
            index = self.model().index(row, 0, parent)
            container = self.indexWidget(index)
            if container and container.layout():
                widget = container.layout().itemAt(0).widget()
                try:
                    widget.label.setText('#{}'.format(row + 1))
                except Exception as e:
                    print(e)
            # if the index has children, call updateLabels recursively
            if self.model().rowCount(index):
                self.updateLabels(index)

    def dragMoveEvent(self, event):
        super().dragMoveEvent(event)
        if self.dropIndicatorPosition() == self.OnViewport:
            # do not accept drop on the viewport
            event.ignore()
        elif self.dropIndicatorPosition() == self.OnItem:
            # do not accept drop beyond the first level
            target = self.indexAt(event.pos())
            if target.parent().isValid():
                event.ignore()

    def getIndexes(self, indexList):
        # get indexes recursively using a set (to get unique indexes only)
        indexes = set(indexList)
        for index in indexList:
            childIndexes = []
            for row in range(self.model().rowCount(index)):
                childIndexes.append(self.model().index(row, 0, index))
            if childIndexes:
                indexes |= self.getIndexes(childIndexes)
        return indexes

    def dropEvent(self, event):
        widgets = []
        # remove the actual widget from the container layout and store it along 
        # with the tree item
        for index in self.getIndexes(self.selectedIndexes()):
            item = self.itemFromIndex(index)
            container = self.indexWidget(index)
            if container and container.layout():
                widget = container.layout().itemAt(0).widget()
                if widget:
                    container.layout().removeWidget(widget)
                    widgets.append((item, widget))

        super().dropEvent(event)

        # restore the widgets in a new container
        for item, widget in widgets:
            container = QtWidgets.QWidget(self)
            layout = QtWidgets.QHBoxLayout(container)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(widget)
            self.setItemWidget(item, 0, container)
            index = self.indexFromItem(item)
            if index.parent().isValid():
                self.expand(index.parent())

        # force the update of the item layouts
        self.updateGeometries()

        # update the widget labels
        self.updateLabels()


class Test(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QtWidgets.QVBoxLayout(self)
        btnLayout = QtWidgets.QHBoxLayout()
        layout.addLayout(btnLayout)
        self.addBtn = QtWidgets.QPushButton('+')
        btnLayout.addWidget(self.addBtn)
        self.delBtn = QtWidgets.QPushButton('-')
        btnLayout.addWidget(self.delBtn)
        self.tree = WidgetDragTree()
        layout.addWidget(self.tree)

        self.addBtn.clicked.connect(self.tree.addFrame)
        self.delBtn.clicked.connect(self.tree.delFrame)

更新(Windows 修复)

似乎存在一个可能的错误,这发生在 Windows(至少在 Qt 5.13 和 Windows 10 中):单击一个项目,然后单击一个组合框后,树小部件会收到一堆mouseMoveEvent触发拖动的信息。不幸的是,我无法进行进一步的测试,但这是一种可能的解决方法:

class WidgetDragTree(QtWidgets.QTreeWidget):
    # ...
    def mousePressEvent(self, event):
        # fix for [unknown] bug on windows where clicking on a combo child of an 
        # item widget also sends back some mouseMoveEvents
        item = self.itemAt(event.pos())
        if item and self.itemWidget(item, 0):
            # if the item has a widget, make a list of child combo boxes
            combos = self.itemWidget(item, 0).findChildren(QtWidgets.QComboBox)
            underMouseWidget = QtWidgets.QApplication.widgetAt(event.globalPos())
            if underMouseWidget in combos:
                return
        super().mousePressEvent(event)

推荐阅读