PyQt5中实现QTableView行拖拽重排序完整指南

在桌面应用开发中,表格控件是最常用的数据展示组件之一。而在现代UI设计中,用户期望能够通过拖拽操作来重新排列表格行的顺序。本文将详细介绍如何在PyQt5中实现QTableView的行拖拽重排序功能,包含完整的代码实现和技术要点分析。

功能概述

我们要实现的功能包括:

  • 支持通过鼠标拖拽重新排列表格行
  • 自定义拖拽时的视觉反馈
  • 集成复选框和下拉框等复杂控件
  • 完整的Model-View架构设计

核心实现:自定义QTableView

1. ReorderTableView类设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ReorderTableView(QtWidgets.QTableView):
"""QTableView with the ability to make the model move a row with drag & drop"""

class DropmarkerStyle(QtWidgets.QProxyStyle):
def drawPrimitive(self, element, option, painter, widget=None):
"""绘制跨整行的拖拽指示器,而不仅仅是单个列"""
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)

def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide() # 隐藏行号
self.setSelectionBehavior(self.SelectRows) # 选择整行
self.setSelectionMode(self.SingleSelection) # 单选模式
self.setDragDropMode(self.InternalMove) # 内部移动模式
self.setDragDropOverwriteMode(False) # 不覆盖现有项
self.setStyle(self.DropmarkerStyle()) # 设置自定义样式

2. 拖拽事件处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def dropEvent(self, event):
"""处理拖拽放置事件"""
# 检查事件来源和操作类型
if (event.source() is not self or
(event.dropAction() != QtCore.Qt.MoveAction and
self.dragDropMode() != QtWidgets.QAbstractItemView.InternalMove)):
super().dropEvent(event)
return

# 获取源行和目标行索引
selection = self.selectedIndexes()
from_index = selection[0].row() if selection else -1
to_index = self.indexAt(event.pos()).row()

# 验证索引有效性并执行移动
if (0 <= from_index < self.model().rowCount() and
0 <= to_index < self.model().rowCount() and
from_index != to_index):
self.model().relocateRow(from_index, to_index)
event.accept()

super().dropEvent(event)

数据模型实现

1. 自定义QAbstractTableModel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class AssetModel(QAbstractTableModel):
attr = ["Name", "Options", "Extra", 'Index']
ItemsRole = Qt.UserRole + 1 # 自定义角色:下拉选项
ActiveRole = Qt.UserRole + 2 # 自定义角色:当前选中项
IndexRole = Qt.UserRole + 3 # 自定义角色:复选框状态

def __init__(self, *args, **kwargs):
QAbstractTableModel.__init__(self, *args, **kwargs)
self._items = []

def flags(self, index):
"""设置项目标志,支持拖拽和编辑"""
if not index.isValid():
return QtCore.Qt.ItemIsDropEnabled
if index.row() < len(self._items):
return (QtCore.Qt.ItemIsEnabled |
QtCore.Qt.ItemIsEditable |
QtCore.Qt.ItemIsSelectable |
QtCore.Qt.ItemIsDragEnabled)
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable

def supportedDropActions(self):
"""支持的拖拽操作类型"""
return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction

2. 核心:行重定位功能

1
2
3
4
5
6
7
8
9
10
11
12
13
def relocateRow(self, row_source, row_target):
"""重新定位行:这是拖拽功能的核心"""
row_a, row_b = max(row_source, row_target), min(row_source, row_target)

# 通知视图开始移动行
self.beginMoveRows(QtCore.QModelIndex(), row_a, row_a,
QtCore.QModelIndex(), row_b)

# 执行实际的数据移动
self._items.insert(row_target, self._items.pop(row_source))

# 通知视图移动完成
self.endMoveRows()

自定义委托实现

1. 下拉框委托

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AssetDelegate(QtWidgets.QStyledItemDelegate):
"""为Options列提供下拉框编辑器"""

def createEditor(self, parent, option, index):
combobox = QtWidgets.QComboBox(parent)
combobox.addItems(index.data(AssetModel.ItemsRole))
combobox.currentIndexChanged.connect(self.onCurrentIndexChanged)
return combobox

def setEditorData(self, editor, index):
ix = index.data(AssetModel.ActiveRole)
editor.setCurrentIndex(ix)

def setModelData(self, editor, model, index):
ix = editor.currentIndex()
model.setData(index, ix, AssetModel.ActiveRole)

2. 复选框委托

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class IndexDelegate(QtWidgets.QStyledItemDelegate):
"""为Index列提供复选框编辑器"""

def createEditor(self, parent, option, index):
checkbox = QCheckBox(parent)
return checkbox

def setEditorData(self, editor, index):
bool_value = index.data(AssetModel.IndexRole)
editor.setChecked(bool_value)

def updateEditorGeometry(self, editor, option, index):
"""居中对齐复选框"""
rect = option.rect
w, h = 13, 13
x = rect.left() + (rect.width() - w) / 2
y = rect.top() + (rect.height() - h) / 2
editor.setGeometry(int(x), int(y), w, h)

数据类设计

1
2
3
4
5
6
7
8
9
10
11
12
class Asset:
"""资产数据类"""
def __init__(self, name, items=[], active=0, index=False):
self.active = active # 当前选中的选项索引
self.name = name # 资产名称
self.items = items # 可选项列表
self.index = index # 复选框状态

@property
def status(self):
"""状态属性:用于图标显示"""
return self.active == len(self.items) - 1

主窗口集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Example(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.resize(400, 300)

# 创建可重排序的表格视图
self.ui_assets = ReorderTableView(self)
self.ui_assets.setModel(AssetModel())

# 为不同列设置自定义委托
self.ui_assets.setItemDelegateForColumn(1, AssetDelegate(self.ui_assets))
self.ui_assets.setItemDelegateForColumn(3, IndexDelegate(self.ui_assets))

# 布局设置
main_layout = QtWidgets.QVBoxLayout()
main_layout.addWidget(self.ui_assets)
self.setLayout(main_layout)

# 初始化测试数据
self.init_test_data()

def init_test_data(self):
"""初始化测试数据"""
assets = [
Asset('Dev1', ['v01', 'v02', 'v03'], 0),
Asset('Dev2', ['v10', 'v11', 'v13'], 1),
Asset('Dev3', ['v11', 'v22', 'v53'], 2),
Asset('Dev4', ['v13', 'v21', 'v23'], 0)
]

self.ui_assets.model().clear()
for asset in assets:
self.ui_assets.model().addItem(asset)

# 设置列宽自适应
self.ui_assets.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)

技术要点分析

1. 拖拽机制原理

  • dragDropMode设置InternalMove模式确保只在视图内部移动
  • flags方法:必须返回ItemIsDragEnabledItemIsDropEnabled标志
  • dropEvent重写:自定义拖拽逻辑,调用模型的relocateRow方法

2. 视觉反馈优化

  • DropmarkerStyle:自定义样式类,确保拖拽指示器跨越整行
  • 选择行为:设置为SelectRows确保选中整行而非单个单元格

3. Model-View分离

  • 数据操作:所有数据修改都通过模型方法进行
  • 视图更新:使用beginMoveRows/endMoveRows确保视图正确更新
  • 自定义角色:使用UserRole扩展数据传递

运行效果

完整的实现提供了以下功能:

  1. 行拖拽:用户可以拖拽任意行到新位置
  2. 视觉反馈:拖拽时显示插入位置指示器
  3. 复杂控件:支持下拉框和复选框等嵌入式编辑器
  4. 状态图标:根据数据状态显示不同颜色的圆形图标

扩展应用

这个实现可以轻松扩展到其他场景:

1. 多选拖拽

1
2
3
4
5
6
7
# 修改选择模式为多选
self.setSelectionMode(self.ExtendedSelection)

# 在dropEvent中处理多行移动
def dropEvent(self, event):
selected_rows = [index.row() for index in self.selectedIndexes()]
# 批量移动逻辑

2. 拖拽到其他视图

1
2
3
4
5
6
7
# 设置为外部拖拽模式
self.setDragDropMode(self.DragDrop)

# 实现startDrag方法自定义拖拽数据
def startDrag(self, supportedActions):
# 自定义拖拽数据
pass

3. 撤销/重做功能

1
2
3
4
5
6
7
8
9
class UndoableModel(AssetModel):
def __init__(self):
super().__init__()
self.history = []

def relocateRow(self, from_row, to_row):
# 保存操作到历史记录
self.history.append(('move', from_row, to_row))
super().relocateRow(from_row, to_row)

性能优化建议

  1. 大数据量处理:对于包含大量行的表格,考虑使用虚拟化技术
  2. 拖拽响应性:在拖拽过程中减少不必要的重绘操作
  3. 内存管理:及时清理临时创建的编辑器和委托对象

总结

本文详细介绍了在PyQt5中实现QTableView行拖拽重排序的完整方案。通过自定义QTableView、QAbstractTableModel和QStyledItemDelegate,我们实现了一个功能丰富且用户友好的表格控件。

这个实现方案的核心优势在于:

  • 完整的MVC架构:数据、视图、控制逻辑分离清晰
  • 高度可定制:支持各种复杂的单元格编辑器
  • 良好的用户体验:流畅的拖拽操作和直观的视觉反馈
  • 易于扩展:可以轻松添加新功能和自定义行为

对于需要实现类似功能的开发者,这个方案提供了一个坚实的基础,可以根据具体需求进行定制和扩展。

完整代码

以下是完整的可运行代码,保存为Python文件即可直接运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import sys
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtCore import QRect, Qt, QAbstractTableModel, QSortFilterProxyModel, QModelIndex
from PyQt5.QtWidgets import QCheckBox, QWidget, QStyleOptionViewItem, QHeaderView


class ReorderTableView(QtWidgets.QTableView):
"""QTableView with the ability to make the model move a row with drag & drop"""

class DropmarkerStyle(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)

def __init__(self, parent):
super().__init__(parent)
self.verticalHeader().hide()
self.setSelectionBehavior(self.SelectRows)
self.setSelectionMode(self.SingleSelection)
self.setDragDropMode(self.InternalMove)
self.setDragDropOverwriteMode(False)
self.setStyle(self.DropmarkerStyle())

def dropEvent(self, event):
if (event.source() is not self or
(event.dropAction() != QtCore.Qt.MoveAction and
self.dragDropMode() != QtWidgets.QAbstractItemView.InternalMove)):
super().dropEvent(event)

selection = self.selectedIndexes()
from_index = selection[0].row() if selection else -1
to_index = self.indexAt(event.pos()).row()
if (0 <= from_index < self.model().rowCount() and
0 <= to_index < self.model().rowCount() and
from_index != to_index):
self.model().relocateRow(from_index, to_index)
event.accept()
super().dropEvent(event)


class AssetDelegate(QtWidgets.QStyledItemDelegate):
def paint(self, painter, option, index):
if isinstance(self.parent(), QtWidgets.QAbstractItemView):
self.parent().openPersistentEditor(index)
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)

def createEditor(self, parent, option, index):
combobox = QtWidgets.QComboBox(parent)
combobox.addItems(index.data(AssetModel.ItemsRole))
combobox.currentIndexChanged.connect(self.onCurrentIndexChanged)
return combobox

def onCurrentIndexChanged(self, ix):
editor = self.sender()
self.commitData.emit(editor)
self.closeEditor.emit(editor, QtWidgets.QAbstractItemDelegate.NoHint)

def setEditorData(self, editor, index):
ix = index.data(AssetModel.ActiveRole)
editor.setCurrentIndex(ix)

def setModelData(self, editor, model, index):
ix = editor.currentIndex()
model.setData(index, ix, AssetModel.ActiveRole)


class IndexDelegate(QtWidgets.QStyledItemDelegate):
def paint(self, painter, option, index):
if isinstance(self.parent(), QtWidgets.QAbstractItemView):
self.parent().openPersistentEditor(index)
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)

def createEditor(self, parent, option, index):
checkbox = QCheckBox(parent)
return checkbox

def setEditorData(self, editor: QCheckBox, index):
bool_ = index.data(AssetModel.IndexRole)
editor.setChecked(bool_)

def setModelData(self, editor: QCheckBox, model, index):
bool_ = editor.isChecked()
model.setData(index, bool_, AssetModel.IndexRole)

def updateEditorGeometry(self, editor: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> None:
rect: QRect = option.rect
w = 13
h = 13
x = rect.left() + (rect.width() - w) / 2
y = rect.top() + (rect.height() - h) / 2
editor.setGeometry(int(x), int(y), w, h)


class Asset(object):
def __init__(self, name, items=[], active=0, index=False):
self.active = active
self.name = name
self.items = items
self.index = index

@property
def status(self):
return self.active == len(self.items) - 1


class AssetModel(QAbstractTableModel):
attr = ["Name", "Options", "Extra", 'Index']
ItemsRole = Qt.UserRole + 1
ActiveRole = Qt.UserRole + 2
IndexRole = Qt.UserRole + 3

def __init__(self, *args, **kwargs):
QAbstractTableModel.__init__(self, *args, **kwargs)
self._items = []

def flags(self, index):
if not index.isValid():
return QtCore.Qt.ItemIsDropEnabled
if index.row() < len(self._items):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsDragEnabled
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable

def supportedDropActions(self) -> bool:
return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction

def relocateRow(self, row_source, row_target) -> None:
row_a, row_b = max(row_source, row_target), min(row_source, row_target)
self.beginMoveRows(QtCore.QModelIndex(), row_a, row_a, QtCore.QModelIndex(), row_b)
self._items.insert(row_target, self._items.pop(row_source))
self.endMoveRows()

def clear(self):
self.beginResetModel()
self._items = []
self.endResetModel()

def rowCount(self, index=QModelIndex()):
return len(self._items)

def columnCount(self, index=QModelIndex()):
return len(self.attr)

def addItem(self, sbsFileObject):
self.beginInsertRows(QModelIndex(),
self.rowCount(), self.rowCount())
self._items.append(sbsFileObject)
self.endInsertRows()

def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return AssetModel.attr[section]
return QAbstractTableModel.headerData(self, section, orientation, role)

def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
if 0 <= index.row() < self.rowCount():
item = self._items[index.row()]
col = index.column()
if role == AssetModel.ItemsRole:
return getattr(item, 'items')

if role == AssetModel.ActiveRole:
return getattr(item, 'active')

if role == AssetModel.IndexRole:
return getattr(item, 'index')

if 0 <= col < self.columnCount():
if role == Qt.DisplayRole:
if col == 0:
return getattr(item, 'name', '')
if col == 1:
return getattr(item, 'items')[getattr(item, 'active')]
elif role == Qt.DecorationRole:
if col == 0:
status = getattr(item, 'status')
col = QtGui.QColor(Qt.red) if status else QtGui.QColor(
Qt.green)
px = QtGui.QPixmap(120, 120)
px.fill(Qt.transparent)
painter = QtGui.QPainter(px)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
px_size = px.rect().adjusted(12, 12, -12, -12)
painter.setBrush(col)
painter.setPen(QtGui.QPen(Qt.black, 4,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin))
painter.drawEllipse(px_size)
painter.end()

return QtGui.QIcon(px)

def setData(self, index, value, role=Qt.EditRole):
if 0 <= index.row() < self.rowCount():
item = self._items[index.row()]
if role == AssetModel.ActiveRole:
setattr(item, 'active', value)
return True
elif role == AssetModel.IndexRole:
setattr(item, 'index', value)
return True
return QAbstractTableModel.setData(self, index, value, role)


class Example(QtWidgets.QWidget):

def __init__(self):
super(Example, self).__init__()
self.resize(400, 300)
self.setWindowTitle('PyQt5 QTableView 拖拽重排序示例')

self.ui_assets = ReorderTableView(self)
self.ui_assets.setModel(AssetModel())
self.ui_assets.setItemDelegateForColumn(1, AssetDelegate(self.ui_assets))
self.ui_assets.setItemDelegateForColumn(3, IndexDelegate(self.ui_assets))

main_layout = QtWidgets.QVBoxLayout()
main_layout.addWidget(self.ui_assets)
self.setLayout(main_layout)

self.unit_test()

def unit_test(self):
assets = [
Asset('Dev1', ['v01', 'v02', 'v03'], 0),
Asset('Dev2', ['v10', 'v11', 'v13'], 1),
Asset('Dev3', ['v11', 'v22', 'v53'], 2),
Asset('Dev4', ['v13', 'v21', 'v23'], 0)
]

self.ui_assets.model().clear()
for i, obj in enumerate(assets):
self.ui_assets.model().addItem(obj)
self.ui_assets.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)


def main():
app = QtWidgets.QApplication(sys.argv)
ex = Example()
ex.show()
sys.exit(app.exec_())


if __name__ == '__main__':
main()

运行说明

  1. 确保安装了PyQt5:pip install PyQt5
  2. 将代码保存为 tableview_drag_demo.py
  3. 运行:python tableview_drag_demo.py

使用方法

  1. 拖拽重排序:选中任意行,拖拽到新位置即可重新排列
  2. 下拉框操作:点击”Options”列的下拉框可以切换选项
  3. 复选框操作:点击”Index”列的复选框可以切换状态
  4. 状态指示器:第一列的圆形图标会根据状态显示不同颜色

这个完整的示例展示了PyQt5中实现复杂表格拖拽功能的所有核心技术,可以作为实际项目的参考模板。