(Last updated: September 19, 2017)
Reordering rows in a QTableView

Setting up a Qt5 QTableView widget to allow row reordering, of the sort pictured above, isn't as entirely straightforward as you'd possibly think -- Google search results for that kind of thing will reveal quite a few people trying to get it right.

The simplest case is if you don't mind leaving the horizontal header shown, and letting the users drag-and-drop using the horizontal header numbers instead of selecting the row itself. That can be enabled using the header's setSectionsMovable function (in Qt5, at least - it's something else in Qt4 I think). Personally that's not what I was looking for, and wanted the whole row selectable and draggable.

The Short Version

Here's a short PyQt script which handles row reordering exactly the way I want to do it: qtableview-reorder-working.py

Feel free to just grab that and have a look at it; it's probably easier to just glance through the code than it'll be to read through all my explanations below. I will continue with my long-winded explanations regardless, though!

The Longer Version

Nearly everything required is supported by QTableView, QStandardItem, and QStandardItemModel, though to get everything absolutely right you'll have to do a small subclass of QStandardItemModel. The first set of properties on the QTableView object itself, are pretty straightforward (note that all my code here is PyQt):

tableview.setSelectionBehavior(tableview.SelectRows)
tableview.setSelectionMode(tableview.SingleSelection)
tableview.setDragDropMode(tableview.InternalMove)
tableview.setDragDropOverwriteMode(False)
model = QStandardItemModel()
tableview.setModel(model)

The setDragDropOverwriteMode call isn't strictly speaking related, but I think you'd probably want it in this case, regardless.

Once you've got those set on the view, you'll need to make sure to call setDropEnabled(False) on each of the QStandardItem objects that you add in to the model. If you leave out this step, you'll notice as you drag over the view that there'll be a black box drawn around the cells you're hovering over, and if you release at that point, strange things will start happening. Setting that parameter to False ensures that the only droppable areas are between the rows, which is what I was looking for.

Now, at this point it mostly works. You'll select whole rows, and be able to drag them into new positions. (And then later read from the model to find out what the new ordering is.) There's two problems remaining, though, and these are trickier:

Problem 1: "Shifting" of columns during dragging

At this point, if you drag a row to anything other than the first column, you'll see that the row gets copied instead of moved, and that the new row is shifted to the right by however many columns you'd moved over. For example, using the screenshot above as a starting point:

After shifing columns by accident

Definitely not what I was looking for. As far as I can tell, there is no builtin way to prevent this from happening - no handy boolean or anything to set. However, the solution actually isn't too bad. Basically you just need to override QStandardItemModel.dropMimeData to enforce the column variable to always be zero. The following class will do the trick:

class MyModel(QtGui.QStandardItemModel):

    def dropMimeData(self, data, action, row, col, parent):
        """
        Always move the entire row, and don't allow column "shifting"
        """
        return super().dropMimeData(data, action, row, 0, parent)

Note that the only thing it does is ignore the col variable and pass through zero instead. That's it, then! Instead of using QStandardItemModel for your items, use this new class instead.

Problem 2: Drop indicator only draws underneath the current cell

The remaining problem is cosmetic, though I feel it's good UI to fix it anyway. Basically, as you hover over the cells while dragging, the between-row indicator for where you're dropping only gets drawn under/above the current cell you're hovering over, even though it should really be drawn across the entire row.

The solution to this one is definitely a bit voodoo; basically you have to set up your own custom QProxyStyle and apply it to the QTableView, and use that QProxyStyle's overridden drawPrimitive function to draw the line. It's worth noting that this may not actually work on some platforms like OSX, but it works well enough on my systems. I should also mention that I stumbled across the solution to this one over at this stackoverflow question, where you can find a C++ implementation of it. The PyQt implementation follows:

class MyStyle(QtWidgets.QProxyStyle):

    def drawPrimitive(self, element, option, painter, widget=None):
        """
        Draw a line across the entire row rather than just the column
        we're hovering over.  This may not always work depending on global
        style - for instance I think it won't work on OSX.
        """
        if element == self.PE_IndicatorItemViewItemDrop and not option.rect.isNull():
            option_new = QtWidgets.QStyleOption(option)
            option_new.rect.setLeft(0)
            if widget:
                option_new.rect.setRight(widget.width())
            option = option_new
        super().drawPrimitive(element, option, painter, widget)

Then just assign an instance of MyStyle to your view:

tableview.setStyle(MyStyle())

The Result

This is actually just the same file I linked to way up at the top, but here it is again regardless: qtableview-reorder-working.py

Changelog

September 19, 2017
  • Initial post