Draggable rows in QTableView
The example starts from the methodology used in this article from 2017. The article explains how to prevent QTableView column shifting when dropping the row and how to visually drag the whole row. Starting from that code, this article will expand on it to cover fixes when hidden or disabled columns are present.
The article covers all steps in building a simple program, such that's it is easy to follow along.
To begin, create a new .py file and paste in the code below.
from PyQt5.QtCore import (Qt) from PyQt5.QtGui import (QStandardItemModel, QStandardItem) from PyQt5.QtWidgets import (QProxyStyle,QStyleOption, QTableView, QHeaderView, QItemDelegate, QApplication) class customModel(QStandardItemModel): class customTableView(QTableView): class customStyle(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 = QStyleOption(option) option_new.rect.setLeft(0) if widget: option_new.rect.setRight(widget.width()) option = option_new super().drawPrimitive(element, option, painter, widget) if __name__ == "__main__": import sys app = QApplication(sys.argv) table = customTableView() table.resize(600,300) table.show() sys.exit(app.exec_())
At the top, I've already defined all the necessary imports for this sample code. The code uses PyQt, but should be also usable for PySide. At the very bottom I've provided some code to run the application and show our table. For this application, 3 classes are created. customModel
will contain our model's data and is a subclass of QStandardItemModel. For the visual representation, QTableView is subclassed in customTableView
. Finally, from the 2017 article, I've copied customStyle
, a subclass of QProxyStyle. This style will make sure that also visually whole rows are dragged.
If pass
statements are provided in the first two subclasses, this piece of code will already show the application. Let's continue by providing setting up our customTableView
.
Preparing QTableView and data
First off, let's initialize our customTableView and set it up to desired drag/drop behaviour. This means selecting rows instead of cells (setSelectionBehavior
), only selecting one row at once (setSelectionMode
), moving rows instead of copying them (setDragDropMode
) and after drop removing the original row instead of clearing it (setDragDropOverwriteMode
).
class customTableView(QTableView): def __init__(self, parent=None): super().__init__(parent) self.setSelectionBehavior(self.SelectRows) #Select whole rows self.setSelectionMode(self.SingleSelection) # Only select/drag one row each time self.setDragDropMode(self.InternalMove) # Objects can only be drag/dropped internally and are moved instead of copied self.setDragDropOverwriteMode(False) # Removes the original item after moving instead of clearing it # Set our custom style - this draws the drop indicator across the whole row self.setStyle(customStyle()) model = customModel() self.setModel(model) self.populate()
We also set the model to our customModel. As the last line, I call a function to populate the model with data. This function still has to be created inside of the customTableView
class:
class customTableView(QTableView): #... def populate(self): set_enabled = True # We'll change this value to show how to drag rows with disabled elements later model = self.model() for row in ['a','b','c','d','e','f','g']: data = [] for column in range(5): item = QStandardItem(f'{row}-{column}') item.setDropEnabled(False) if column == 3: item.setEnabled(set_enabled) data.append(item) model.appendRow(data)
Notice that for all items, setDropEnabled(False)
is called. This prevents dropped data from overwriting existing rows. I've also checked an if statement, which will allow me to set items in column 3 disabled/enabled, based on the variable set_enabled
. We'll use this later on to show and fix drag/dropping with disabled items.
Note that column 3 in our model is visually the fourth column. Like lists the model starts with index 0 for rows and columns.
We now have dummy data to work with. This is also a good moment to test the importance of the various settings in the __init__
function and the setDropEnabled(False)
call in the populate
function.
The table works fine when we drag a row and drop it in the leftmost column. However, when we drop it in any other column, the columns start shifting. To solve this, we must make changes in our customModel
.
Preventing Column Shifting
Let's start with our current model, which is the simplest case. I does not have hidden columns or disabled items. We have to alter the QStandardItemModel
, such that it always drops in the first column (column 0). This way we prevent column shifting. We can do this by redefining dropMimeData
and overwriting the column argument to 0:
def dropMimeData(self, data, action, row, col, parent): """ Always move the entire row, and don't allow column "shifting" """ response = super().dropMimeData(data, Qt.CopyAction, row, 0, parent) return response
Fix for hidden columns
Our simple model works fine now. However, let's set a column of our customTableView
hidden. We do this by adding a line of code to the very end of the populate
function:
def populate(self): #... self.setColumnHidden(2,True)
Everything seems to work fine. Now change the value 2 to 0 to hide the leftmost column. When dropping, the data starts shifting to the left. In fact, even when we hid column 2, things were actually going wrong. When we would make the columns visible again after dropping, the data in the hidden column disappeared.
To my current understanding, this is caused by the selection. This selection happens in the customTableView
and as the column is hidden, I can't be selected. One solution, which I haven't tested, might be to overwrite the QTableViews
selection function in customTableView
. The second solution, provided below, recalculates the data as it passes through the model. To do this, we overwrite the mimeData
function (not dropMimeData!) in our customModel
to always select the data of all columns. This is fairly simple:
class customModel(QStandardItemModel): def mimeData(self,indices): """ Move all data, including hidden/disabled columns """ index = indices[0] new_data = [] for col in range(self.columnCount()): new_data.append(index.sibling(index.row(),col)) return super().mimeData(new_data)
In this function, we take one index (QModelIndex
) of the iterable given as input. Next, we create a new data array by selecting siblings of this index. This new data contains the data of all the columns of the current row. This new data we pass the parent class function, which solves the issue.
Fix for disabled items
Let's make a whole column disabled by changing the set_enabled
variable to False
in the populate
function. When dropping the rows, the data is copied instead of moved. I've not yet found the cause, but I assume it is caused by the disabled cells not being able to be removed from their original position. Feel free to comment below.
I've come across this stackOverflow answer which is most likely the most robust solution by reimplementing the dropEvent. As we know which column is disabled (column 3), I provide a different solution below. This might also be extended to check for disabled cells in other columns. The hack temporary enables the cell such that the movement action succeeds. To do this, we extend our mimeData
and dropMimeData
function. For mimeData:
def mimeData(self,indices): """ Move all data, including hidden/disabled columns """ index = indices[0] new_data = [] for col in range(self.columnCount()): new_data.append(index.sibling(index.row(),col)) item = self.item(index.row(),3) self.was_enabled = item.isEnabled() item.setEnabled(True) # Hack// Fixes copying instead of moving when item is disabled return super().mimeData(new_data)
Because we know the item is at column 3, we can easily fetch is and store the enabled state to a variable. Then we enable it. In dropMimeAction we'll disable this again:
def dropMimeData(self, data, action, row, col, parent): """ Always move the entire row, and don't allow column "shifting" """ response = super().dropMimeData(data, Qt.CopyAction, row, 0, parent) if row == -1: #Drop after last row row = self.rowCount()-1 item = self.item(row,3) item.setEnabled(self.was_enabled) # Hack// Fixes copying instead of moving when style column is disabled return response
We catch the original response (with column shifting fix) and afterwards enable the item again. To get the item we must recalculate the row. As an item is dropped at the very bottom, row might be -1
, which can't be used to fetch the item.
NOTE:
I noticed that this fix for disabled items is not sufficient. It fails in the case a drag event is initiated, but the item is not dropped at a different location. In this case, the item is not set back to its original state. Please comment if you find a better implementation.