pyside qtreewidget constrain drag and drop

2019-03-16 13:45发布

问题:

I'm trying to add a constraint to the QTreeWidget drag and drop function to prevent the branches from entering another branch in another root.

Here's an example to make things more clear:
I have 4 objects. Lets call them apple, banana, carrot, durian.

The tree looks like this:

isDelicious (Root)
|-- BackgroundObjects (Branch)
   |-- Durian
|-- ForgroundObjects (Branch)
   |-- Apple
   |-- Banana
   |-- Carrot
isSmelly (Root)
|-- BackgroundObjects (Branch)
   |-- Apple
   |-- Carrot
|-- ForgroundObjects (Branch)
   |-- Banana
   |-- Durian

So, the objects are allowed to be dragged and dropped from BackgroundObjects to ForgroundObjects, and visa versa on the same root, but they are not allowed to be dragged and dropped onto a branch on a different root.

I have tried reimplementing and subclassing dragMoveEvent, dragEnterEvent, and dropEvent, and if I call accept on the event in dragEnterEvent, it'll call dragMoveEvent after (which I expect). However, dropEvent is only called when I drop outside of the QTreeWidget.

What I want to do is check the grandparent of the selected objects before they are moved, and the proposed new grandparent to see if they are the same. If so, then accept the move. Otherwise ignore the move.

I have searched to see if there is any answers, and so far I haven't seen any for what I'm trying to do. Probably the closest would be these two questions from Stack Overflow:
https://stackoverflow.com/questions/17134289/managing-drag-and-drop-within-qtreewidgets-in-pyside
qt: QTreeView - limit drag and drop to only happen within a particlar grandparent (ancestor)

回答1:

Qt does not seem to make this sort of thing very easy.

The best I could come up with was to temporarily reset the item flags during the drag-enter and drag-move events. The example below calculates the current top-level item dynamically in order to contrain drag and drop. But it could also be done by using setData() to add an identifier to each item.

from PyQt4 import QtCore, QtGui

class TreeWidget(QtGui.QTreeWidget):
    def __init__(self, parent=None):
        QtGui.QTreeWidget.__init__(self, parent)
        self.setDragDropMode(self.InternalMove)
        self.setDragEnabled(True)
        self.setDropIndicatorShown(True)
        self._dragroot = self.itemRootIndex()

    def itemRootIndex(self, item=None):
        root = self.invisibleRootItem()
        while item is not None:
            item = item.parent()
            if item is not None:
                root = item
        return QtCore.QPersistentModelIndex(
            self.indexFromItem(root))

    def startDrag(self, actions):
        items = self.selectedItems()
        self._dragroot = self.itemRootIndex(items and items[0])
        QtGui.QTreeWidget.startDrag(self, actions)

    def dragEnterEvent(self, event):
        self._drag_event(event, True)

    def dragMoveEvent(self, event):
        self._drag_event(event, False)

    def _drag_event(self, event, enter=True):
        items = []
        disable = False
        item = self.itemAt(event.pos())
        if item is not None:
            disable = self._dragroot != self.itemRootIndex(item)
            if not disable:
                rect = self.visualItemRect(item)
                if event.pos().x() < rect.x():
                    disable = True
        if disable:
            for item in item, item.parent():
                if item is not None:
                    flags = item.flags()
                    item.setFlags(flags & ~QtCore.Qt.ItemIsDropEnabled)
                    items.append((item, flags))
        if enter:
            QtGui.QTreeWidget.dragEnterEvent(self, event)
        else:
            QtGui.QTreeWidget.dragMoveEvent(self, event)
        for item, flags in items:
            item.setFlags(flags)

class Window(QtGui.QWidget):
    def __init__(self):
        QtGui.QWidget.__init__(self)
        self.tree = TreeWidget(self)
        self.tree.header().hide()
        def add(root, *labels):
            item = QtGui.QTreeWidgetItem(self.tree, [root])
            item.setFlags(item.flags() &
                          ~(QtCore.Qt.ItemIsDragEnabled |
                            QtCore.Qt.ItemIsDropEnabled))
            for index, title in enumerate(
                ('BackgroundObjects', 'ForegroundObjects')):
                subitem = QtGui.QTreeWidgetItem(item, [title])
                subitem.setFlags(
                    subitem.flags() & ~QtCore.Qt.ItemIsDragEnabled)
                for text in labels[index].split():
                    child = QtGui.QTreeWidgetItem(subitem, [text])
                    child.setFlags(
                        child.flags() & ~QtCore.Qt.ItemIsDropEnabled)
        add('isDelicious', 'Durian', 'Apple Banana Carrot')
        add('isSmelly', 'Apple Carrot', 'Banana Durian')
        root = self.tree.invisibleRootItem()
        root.setFlags(root.flags() & ~QtCore.Qt.ItemIsDropEnabled)
        self.tree.expandAll()
        layout = QtGui.QVBoxLayout(self)
        layout.addWidget(self.tree)

if __name__ == '__main__':

    import sys
    app = QtGui.QApplication(sys.argv)
    window = Window()
    window.setGeometry(500, 300, 300, 300)
    window.show()
    sys.exit(app.exec_())


回答2:

Here's my solution (full code at the end), subclassing a QTreeWidget. I tried to have something very general that should work for a lot of cases. One issue remains with the visual cues when dragging. The previous version didn't work on windows, I hope this one will. It works absolutely fine on Linux.


Defining categories

Every item in the tree has a category (a string), that I stored in QtCore.Qt.ToolTipRole. You could also subclass QTreeWidgetItem to have a specific attribute category.

We define in a dictionary settings all the categories, with the list of the categories they can be drop into and the flag to set. For example:

default=QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled
drag=QtCore.Qt.ItemIsDragEnabled
drop=QtCore.Qt.ItemIsDropEnabled
settings={
    "family":(["root"],default|drag|drop),
    "children":(["family"],default|drag)
}

Every item of category "family" can receive drag, and can only be drop in "root" (the invisible root item). Every item of category "children" can only be drop into a "family".


Adding items to the tree

The method addItem(strings,category,parent=None) creates a QTreeWidgetItem(strings,parent) with a tool tip "category" and the matching flags in setting. It returns the item. Example:

dupont=ex.addItem(["Dupont"],"family")
robert=ex.addItem(["Robertsons"],"family")
ex.addItem(["Laura"],"children",dupont)
ex.addItem(["Matt"],"children",robert)
...


Reimplementation of Drag and Drop

The item being dragged is determined with self.currentItem() (multiple selection is not handled). The list of categories where this item can be dropped is okList=self.settings[itemBeingDragged.data(0,role)][0].

The item under the mouse, aka "drop target", is self.itemAt(event.pos()). If the mouse in on blank space, the drop target is set to the root item.

  • dragMoveEvent (visual cue for whether the drop will be accepted/ignored)
    If the drop target is in okList, we call the regular dragMoveEvent. If not, we have to check for "next to drop target". In the image bellow, the item under the mouse is Robertsons, but the real drop target is the root item (see the line bellow Robertsons ?). To fix this, we check it the item can be dragged on the parent of the drop target. If not, we call event.ignore().

    The only remaining issue is when the mouse is actually on "Robertsons": the drag event is accepted. The visual cue says the drop will be accepted when it's not.

  • dropEvent
    Instead of accepting or ignoring the drop, which is very tricky because of "next to drop target", we always accept the drop, and then fix mistakes.
    If the new parent is the same as the old parent, or if it is in okList, we do nothing. Otherwise, we put back the dragged item in the old parent.

    Sometimes the dropped item will be collapsed, but this could easily be fixed with itemBeingDragged.setExpanded()


Finally, the full code with two examples:

import sys
from PyQt4 import QtCore, QtGui

class CustomTreeWidget( QtGui.QTreeWidget ):
    def __init__(self,settings, parent=None):
        QtGui.QTreeWidget.__init__(self, parent)
        #self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
        self.setItemsExpandable(True)
        self.setAnimated(True)
        self.setDragEnabled(True)
        self.setDropIndicatorShown(True)
        self.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
        self.settings=settings

        root=self.invisibleRootItem()
        root.setData(0,QtCore.Qt.ToolTipRole,"root")

    def dragMoveEvent(self, event):
        role=QtCore.Qt.ToolTipRole
        itemToDropIn = self.itemAt(event.pos())
        itemBeingDragged=self.currentItem()
        okList=self.settings[itemBeingDragged.data(0,role)][0]

        if itemToDropIn is None:
            itemToDropIn=self.invisibleRootItem()

        if itemToDropIn.data(0,role) in okList:
            super(CustomTreeWidget, self).dragMoveEvent(event)
            return
        else:
            # possible "next to drop target" case
            parent=itemToDropIn.parent()
            if parent is None:
                parent=self.invisibleRootItem()
            if parent.data(0,role) in okList:
                super(CustomTreeWidget, self).dragMoveEvent(event)
                return
        event.ignore()

    def dropEvent(self, event):
        role=QtCore.Qt.ToolTipRole

        #item being dragged
        itemBeingDragged=self.currentItem()
        okList=self.settings[itemBeingDragged.data(0,role)][0]

        #parent before the drag
        oldParent=itemBeingDragged.parent()
        if oldParent is None:
            oldParent=self.invisibleRootItem()
        oldIndex=oldParent.indexOfChild(itemBeingDragged)

        #accept any drop
        super(CustomTreeWidget,self).dropEvent(event)

        #look at where itemBeingDragged end up
        newParent=itemBeingDragged.parent()
        if newParent is None:
            newParent=self.invisibleRootItem()

        if newParent.data(0,role) in okList:
            # drop was ok
            return
        else:
            # drop was not ok, put back the item
            newParent.removeChild(itemBeingDragged)
            oldParent.insertChild(oldIndex,itemBeingDragged)

    def addItem(self,strings,category,parent=None):
        if category not in self.settings:
            print("unknown categorie" +str(category))
            return False
        if parent is None:
            parent=self.invisibleRootItem()

        item=QtGui.QTreeWidgetItem(parent,strings)
        item.setData(0,QtCore.Qt.ToolTipRole,category)
        item.setExpanded(True)
        item.setFlags(self.settings[category][1])
        return item

if __name__ == '__main__':
    app = QtGui.QApplication(sys.argv)

    default=QtCore.Qt.ItemIsSelectable|QtCore.Qt.ItemIsEnabled|QtCore.Qt.ItemIsEditable
    drag=QtCore.Qt.ItemIsDragEnabled
    drop=QtCore.Qt.ItemIsDropEnabled

    #family example
    settings={
        "family":(["root"],default|drag|drop),
        "children":(["family"],default|drag)
    }
    ex = CustomTreeWidget(settings)
    dupont=ex.addItem(["Dupont"],"family")
    robert=ex.addItem(["Robertsons"],"family")
    smith=ex.addItem(["Smith"],"family")
    ex.addItem(["Laura"],"children",dupont)
    ex.addItem(["Matt"],"children",dupont)
    ex.addItem(["Kim"],"children",robert)
    ex.addItem(["Stephanie"],"children",robert)
    ex.addItem(["John"],"children",smith)

    ex.show()
    sys.exit(app.exec_())

    #food example: issue with "in between"
    settings={
        "food":([],default|drop),
        "allVegetable":(["food"],default|drag|drop),
        "allFruit":(["food"],default|drag|drop),
        "fruit":(["allFruit","fruit"],default|drag|drop),
        "veggie":(["allVegetable","veggie"],default|drag|drop),
    }
    ex = CustomTreeWidget(settings)
    top=ex.addItem(["Food"],"food")
    fruits=ex.addItem(["Fruits"],"allFruit",top)
    ex.addItem(["apple"],"fruit",fruits)
    ex.addItem(["orange"],"fruit",fruits)
    vegetable=ex.addItem(["Vegetables"],"allVegetable",top)
    ex.addItem(["carrots"],"veggie",vegetable)
    ex.addItem(["lettuce"],"veggie",vegetable)
    ex.addItem(["leek"],"veggie",vegetable)

    ex.show()
    sys.exit(app.exec_())