在QTableWidget中拖放行

14

目标

我的目标是拥有一个QTableWidget,其中用户可以在内部拖放行。也就是说,用户可以拖动并放下一整行,将其向上或向下移动到表格中不同的位置,在两个其他行之间。该目标在以下图示中说明:

the challenge

我尝试的事情以及发生的情况
一旦我使用数据填充了QTableWidget,我会按照以下方式设置其属性:
table.setDragDropMode(QtGui.QAbstractItemView.InternalMove)   
#select one row at a time
table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) 
table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)

类似的代码可以让 QListWidget 表现得很好:当您在内部移动一个项目时,它会被放置在列表的两个元素之间,并且其余的项会以合理的方式进行排序,而不会覆盖任何数据(换句话说,视图的行为类似于上面的图形,但它是一个列表)。
相比之下,使用上述代码修改表格时,事情并没有按计划进行。下图显示了实际发生的情况:

crud

用文字来描述:当删除第i行时,该行在表格中变为空白。此外,如果我不小心将第i行放在第j行上(而不是两行之间的空白处),则第i行的数据将替换j行的数据。也就是说,在这种不幸的情况下,除了第i行变为空白外,第j行也被覆盖。

请注意,我还尝试添加了table.setDragDropOverwriteMode(False),但它并没有改变行为。

如何解决?

这个错误报告可能包含C++的一个可能解决方案:他们重新实现了QTableWidgetdropEvent,但我不确定如何清晰地移植到Python。

相关内容:


2
qt-project.org已经无法使用,现在请前往https://bugreports.qt.io/browse/QTBUG-13873报告问题。 - handle
6个回答

15

这似乎是非常奇怪的默认行为。无论如何,按照你提供的错误报告中的代码,我已经成功地将一些东西移植到了 PyQt。它可能不像那个代码那样健壮,但至少在你提供的简单测试用例中似乎能够工作!

下面实现可能存在的问题:

  • 当前选定的行不遵循拖放(因此如果您移动第三行,则第三行在移动后仍被选定)。这可能不太难解决!

  • 它可能不适用于具有子行的行。我甚至不确定一个QTableWidgetItem是否可以有子项,所以也许没问题。

  • 我没有测试选择多行,但我认为它应该可以工作

  • 出于某种原因,尽管在表格中插入了一行,我并没有删除正在移动的行。对我来说,这似乎非常奇怪。它几乎看起来像在除末尾之外的任何地方插入行不会增加表格的rowCount()

  • 我对GetSelectedRowsFast的实现与他们有点不同。它可能不快,并且可能存在一些错误(我不检查项目是否可用或可选择),就像他们做的那样。我认为这也很容易解决,但只有在禁用选定行时,某人执行拖放操作时才会出现问题。在这种情况下,我认为更好的解决方案可能是取消选择已禁用的行,但这取决于您对其的使用方式!

如果您将此代码用于生产环境,则可能需要仔细检查并确保所有内容都很合理。我的 PyQt 移植很可能存在问题,原始的基于 c++ 的算法也可能存在问题。但是,它可以作为使用QTableWidget实现所需功能的证明。

更新:注意,下面有一个PyQt5的额外答案,它也解决了我上面提到的一些问题。您可能想要查看一下!

代码:

import sys, os
from PyQt4.QtCore import *
from PyQt4.QtGui import *

class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        QTableWidget.__init__(self, *args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.SingleSelection) 
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setDragDropMode(QAbstractItemView.InternalMove)   

    def dropEvent(self, event):
        if event.source() == self and (event.dropAction() == Qt.MoveAction or self.dragDropMode() == QAbstractItemView.InternalMove):
            success, row, col, topIndex = self.dropOn(event)
            if success:             
                selRows = self.getSelectedRowsFast()                        

                top = selRows[0]
                # print 'top is %d'%top
                dropRow = row
                if dropRow == -1:
                    dropRow = self.rowCount()
                # print 'dropRow is %d'%dropRow
                offset = dropRow - top
                # print 'offset is %d'%offset

                for i, row in enumerate(selRows):
                    r = row + offset
                    if r > self.rowCount() or r < 0:
                        r = 0
                    self.insertRow(r)
                    # print 'inserting row at %d'%r


                selRows = self.getSelectedRowsFast()
                # print 'selected rows: %s'%selRows

                top = selRows[0]
                # print 'top is %d'%top
                offset = dropRow - top                
                # print 'offset is %d'%offset
                for i, row in enumerate(selRows):
                    r = row + offset
                    if r > self.rowCount() or r < 0:
                        r = 0

                    for j in range(self.columnCount()):
                        # print 'source is (%d, %d)'%(row, j)
                        # print 'item text: %s'%self.item(row,j).text()
                        source = QTableWidgetItem(self.item(row, j))
                        # print 'dest is (%d, %d)'%(r,j)
                        self.setItem(r, j, source)

                # Why does this NOT need to be here?
                # for row in reversed(selRows):
                    # self.removeRow(row)

                event.accept()

        else:
            QTableView.dropEvent(event)                

    def getSelectedRowsFast(self):
        selRows = []
        for item in self.selectedItems():
            if item.row() not in selRows:
                selRows.append(item.row())
        return selRows

    def droppingOnItself(self, event, index):
        dropAction = event.dropAction()

        if self.dragDropMode() == QAbstractItemView.InternalMove:
            dropAction = Qt.MoveAction

        if event.source() == self and event.possibleActions() & Qt.MoveAction and dropAction == Qt.MoveAction:
            selectedIndexes = self.selectedIndexes()
            child = index
            while child.isValid() and child != self.rootIndex():
                if child in selectedIndexes:
                    return True
                child = child.parent()

        return False

    def dropOn(self, event):
        if event.isAccepted():
            return False, None, None, None

        index = QModelIndex()
        row = -1
        col = -1

        if self.viewport().rect().contains(event.pos()):
            index = self.indexAt(event.pos())
            if not index.isValid() or not self.visualRect(index).contains(event.pos()):
                index = self.rootIndex()

        if self.model().supportedDropActions() & event.dropAction():
            if index != self.rootIndex():
                dropIndicatorPosition = self.position(event.pos(), self.visualRect(index), index)

                if dropIndicatorPosition == QAbstractItemView.AboveItem:
                    row = index.row()
                    col = index.column()
                    # index = index.parent()
                elif dropIndicatorPosition == QAbstractItemView.BelowItem:
                    row = index.row() + 1
                    col = index.column()
                    # index = index.parent()
                else:
                    row = index.row()
                    col = index.column()

            if not self.droppingOnItself(event, index):
                # print 'row is %d'%row
                # print 'col is %d'%col
                return True, row, col, index

        return False, None, None, None

    def position(self, pos, rect, index):
        r = QAbstractItemView.OnViewport
        margin = 2
        if pos.y() - rect.top() < margin:
            r = QAbstractItemView.AboveItem
        elif rect.bottom() - pos.y() < margin:
            r = QAbstractItemView.BelowItem 
        elif rect.contains(pos, True):
            r = QAbstractItemView.OnItem

        if r == QAbstractItemView.OnItem and not (self.model().flags(index) & Qt.ItemIsDropEnabled):
            r = QAbstractItemView.AboveItem if pos.y() < rect.center().y() else QAbstractItemView.BelowItem

        return r


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout) 

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget) 

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(['Colour', 'Model'])

        items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle')]
        for i, (colour, model) in enumerate(items):
            c = QTableWidgetItem(colour)
            m = QTableWidgetItem(model)

            self.table_widget.insertRow(self.table_widget.rowCount())
            self.table_widget.setItem(i, 0, c)
            self.table_widget.setItem(i, 1, m)

        self.show()


app = QApplication(sys.argv)
window = Window()
sys.exit(app.exec_())

非常棒的东西,从这个不错的例子中肯定学到了很多!我写了一个后续来帮助我尝试理解supportedDropActions:https://dev59.com/questions/YITba4cB1Zd3GeqP2jFX。 - eric
非常感谢。您提供的Qt错误报告网站链接正是我所需要的。我需要用于C++,所以很遗憾,您的代码并不完全符合我的需求 :) - MadddinTribleD

12

这是一个经过修订的版本,适用于PyQt5和Python 3的three-pineapples答案。 它还修复了多选拖放,并在移动后重新选择行。

import sys

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QDropEvent
from PyQt5.QtWidgets import QTableWidget, QAbstractItemView, QTableWidgetItem, QWidget, QHBoxLayout, \
    QApplication


class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setDragDropMode(QAbstractItemView.InternalMove)

    def dropEvent(self, event: QDropEvent):
        if not event.isAccepted() and event.source() == self:
            drop_row = self.drop_on(event)

            rows = sorted(set(item.row() for item in self.selectedItems()))
            rows_to_move = [[QTableWidgetItem(self.item(row_index, column_index)) for column_index in range(self.columnCount())]
                            for row_index in rows]
            for row_index in reversed(rows):
                self.removeRow(row_index)
                if row_index < drop_row:
                    drop_row -= 1

            for row_index, data in enumerate(rows_to_move):
                row_index += drop_row
                self.insertRow(row_index)
                for column_index, column_data in enumerate(data):
                    self.setItem(row_index, column_index, column_data)
            event.accept()
            for row_index in range(len(rows_to_move)):
                self.item(drop_row + row_index, 0).setSelected(True)
                self.item(drop_row + row_index, 1).setSelected(True)
        super().dropEvent(event)

    def drop_on(self, event):
        index = self.indexAt(event.pos())
        if not index.isValid():
            return self.rowCount()

        return index.row() + 1 if self.is_below(event.pos(), index) else index.row()

    def is_below(self, pos, index):
        rect = self.visualRect(index)
        margin = 2
        if pos.y() - rect.top() < margin:
            return False
        elif rect.bottom() - pos.y() < margin:
            return True
        # noinspection PyTypeChecker
        return rect.contains(pos, True) and not (int(self.model().flags(index)) & Qt.ItemIsDropEnabled) and pos.y() >= rect.center().y()


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout)

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget) 

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(['Type', 'Name'])

        items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle'), ('Silver', 'Chevy'), ('Black', 'BMW')]
        self.table_widget.setRowCount(len(items))
        for i, (color, model) in enumerate(items):
            self.table_widget.setItem(i, 0, QTableWidgetItem(color))
            self.table_widget.setItem(i, 1, QTableWidgetItem(model))

        self.resize(400, 400)
        self.show()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec_())

有没有关于QTreeWidget的解决方案,可以实现多列、多项和多子项,拖放一个行子项从一个项目到另一个项目或位置的更改?@scott-maxwell - bygreencn
为什么当单元格包含图像或小部件时,移动任何行后它们就会消失? - user13522091

5

最近我遇到了同样的问题,我将上面的代码块精简成了一些东西,我认为它具有相同的行为,但更加简洁。

def dropEvent(self, event):
  if event.source() == self:
      rows = set([mi.row() for mi in self.selectedIndexes()])
      targetRow = self.indexAt(event.pos()).row()
      rows.discard(targetRow)
      rows = sorted(rows)
      if not rows:
          return
      if targetRow == -1:
          targetRow = self.rowCount()
      for _ in range(len(rows)):
          self.insertRow(targetRow)
      rowMapping = dict() # Src row to target row.
      for idx, row in enumerate(rows):
          if row < targetRow:
              rowMapping[row] = targetRow + idx
          else:
              rowMapping[row + len(rows)] = targetRow + idx
      colCount = self.columnCount()
      for srcRow, tgtRow in sorted(rowMapping.iteritems()):
          for col in range(0, colCount):
              self.setItem(tgtRow, col, self.takeItem(srcRow, col))
      for row in reversed(sorted(rowMapping.iterkeys())):
          self.removeRow(row)
      event.accept()
      return

不在电脑旁边,无法检查,但一定会去看看,并告诉你它的工作情况。 - eric

2

由于我在谷歌上没有找到使用C++的合适解决方案,因此我想添加我的解决方案:

#include "mytablewidget.h"

MyTableWidget::MyTableWidget(QWidget *parent) : QTableWidget(parent)
{

}

void MyTableWidget::dropEvent(QDropEvent *event)
{
    if(event->source() == this)
    {
        int newRow = this->indexAt(event->pos()).row();
        QTableWidgetItem *selectedItem;
        QList<QTableWidgetItem*> selectedItems = this->selectedItems();
        if(newRow == -1)
            newRow = this->rowCount();
        int i;
        for(i = 0; i < selectedItems.length()/this->columnCount(); i++)
        {
            this->insertRow(newRow);
        }
        int currentOldRow = -1;
        int currentNewRow = newRow-1;
        QList<int> deleteRows;
        foreach(selectedItem, selectedItems)
        {
            int column = selectedItem->column();
            if(selectedItem->row() != currentOldRow)
            {
                currentOldRow = selectedItem->row();
                deleteRows.append(currentOldRow);
                currentNewRow++;
            }
            this->takeItem(currentOldRow, column);
            this->setItem(currentNewRow, column, selectedItem);
        }

        for(i = deleteRows.count()-1; i>=0; i--)
        {
            this->removeRow(deleteRows.at(i));
        }
    }
}

我会在 this->setItem(currentNewRow, column, selectedItem); 后面添加 this->setCellWidget(currentNewRow, column, this->cellWidget(currentOldRow, column));,以便正确处理带有小部件的单元格。 - MateoConLechuga

1
即使是旧任务,因为我花了一段时间才找到解决方案,以防行包含QTableWidgetItem和通过setCellWidget设置的小部件......也许当其他人搜索此问题时,这可以帮助他们。
问题是,使用上述解决方案,提供在QTableWidgetItem中的文本会移动得很好,但是像图标或按钮之类的小部件在移动后消失了。
第一个想法可能是通过cellWidget()方法内部捕获小部件,然后通过setCellWidget()将其设置回来,但是由于QTableWidget允许通过cellWidget()方法访问小部件,但不返回小部件对象本身,因此这种方法失败了。无论如何,这样做会导致Python应用程序崩溃......
唯一的可能性是(据我所知)创建一个回调到您的父对象并再次创建小部件。
因此,我的MyTableWidget类中的dropEvent方法如下:
    def dropEvent(self, event):

    if not event.isAccepted() and event.source() == self:
        drop_row = self.drop_on(event)

        rows = sorted(set(item.row() for item in self.selectedItems()))

        rows_to_move = []
        for row_index in rows:
            items = dict()
            for column_index in range(self.columnCount()):
                # get the widget or item of current cell
                widget = self.cellWidget(row_index, column_index)
                if isinstance(widget, type(None)):
                    # if widget is NoneType, it is a QTableWidgetItem
                    items[column_index] = {"kind": "QTableWidgetItem",
                                           "item": QTableWidgetItem(self.item(row_index, column_index))}
                else:
                    # otherwise it is any other kind of widget. So we catch the widgets unique (hopefully) objectname
                    items[column_index] = {"kind": "QWidget",
                                           "item": widget.objectName()}

            rows_to_move.append(items)

        for row_index in reversed(rows):
            self.removeRow(row_index)
            if row_index < drop_row:
                drop_row -= 1

        for row_index, data in enumerate(rows_to_move):
            row_index += drop_row
            self.insertRow(row_index)

            for column_index, column_data in data.items():
                if column_data["kind"] == "QTableWidgetItem":
                    # for QTableWidgetItem we can re-create the item directly
                    self.setItem(row_index, column_index, column_data["item"])
                else:
                    # for others we call the parents callback function to get the widget
                    _widget = self._parent.get_table_widget(column_data["item"])
                    if _widget is not None:
                        self.setCellWidget(row_index, column_index, _widget)

        event.accept()

    super().dropEvent(event)

为了实现这一点,您需要将调用父类传递到MyTableWidget类中,并且需要一个回调函数(在上面的代码中为“get_table_widget”),该函数提供与给定输入相关的小部件对象(在我的情况下,仅对象名称就足够了)。请注意保留HTML标签。

1

根据之前的答案,这是一个更新后的Qt6代码:

import sys

from PyQt6.QtWidgets import (
    QTableWidget,
    QAbstractItemView,
    QTableWidgetItem,
    QWidget,
    QHBoxLayout,
    QApplication,
)


class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)

    def dropEvent(self, event):
        if event.source() == self:
            rows = set([mi.row() for mi in self.selectedIndexes()])
            targetRow = self.indexAt(event.position().toPoint()).row()
            rows.discard(targetRow)
            rows = sorted(rows)
            if not rows:
                return
            if targetRow == -1:
                targetRow = self.rowCount()
            for _ in range(len(rows)):
                self.insertRow(targetRow)
            rowMapping = dict()  # Src row to target row.
            for idx, row in enumerate(rows):
                if row < targetRow:
                    rowMapping[row] = targetRow + idx
                else:
                    rowMapping[row + len(rows)] = targetRow + idx
            colCount = self.columnCount()
            for srcRow, tgtRow in sorted(rowMapping.items()):
                for col in range(0, colCount):
                    self.setItem(tgtRow, col, self.takeItem(srcRow, col))
            for row in reversed(sorted(rowMapping.keys())):
                self.removeRow(row)
            event.accept()
            return


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout)

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget)

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(["Type", "Name"])

        items = [
            ("Red", "Toyota"),
            ("Blue", "RV"),
            ("Green", "Beetle"),
            ("Silver", "Chevy"),
            ("Black", "BMW"),
        ]
        self.table_widget.setRowCount(len(items))
        for i, (color, model) in enumerate(items):
            self.table_widget.setItem(i, 0, QTableWidgetItem(color))
            self.table_widget.setItem(i, 1, QTableWidgetItem(model))

        self.resize(400, 400)
        self.show()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec())

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接