在 QtreeWidget 中拖动 Qframe

Drag Qframe within QtreeWidget

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

Qt's item views pass around items using the internal application/x-qabstractitemmodeldatalist MIME type

我还阅读了,主要关注Qlinewidget和QTreewidget中的字符串模型。

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

底线问题是:如何在QTreeWidget 中移动包含多个QtWidget 的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_())

编辑

这个问题在拖动哪个树级别方面令人困惑。所以我编辑了问题:I am only rearranging/moving childs/parents in first hierarchy。感谢您的时间和努力!

EDIT2

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

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

前提

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

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

If index widget A is replaced with index widget B, index widget A will be deleted.

source code 执行以下操作:

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

}

结果是,无论何时设置索引小部件(或删除索引),都会删除相关的索引小部件。从 PyQt 方面来看,我们 NO 对此没有控制权,除非我们对相关项目视图 class 进行彻底的实施(并且......祝你好运) .

树模型注意事项

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

[解决方法] 解决方案

虽然有一个黑客。
调用 deleteLater() 的唯一小部件是使用 setIndexWidget() 设置的实际小部件,而不是其子部件。
因此,在这些情况下,要使用索引小部件添加对拖放的支持,唯一简单的解决方案是 always 添加一个带有 container 父级的索引小部件小部件,并在索引小部件 replacing/removing 之前从容器中删除实际小部件,然后在使用 setIndexWidget()(或 setItemWidget()) 在新 index/item 上,可能具有递归函数以确保保留子引用。

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

幸运的是,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 中):点击一个项目后 then 单击组合框,树形小部件收到一堆 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)