基于PANDAS的QAbstractTableModel实现高级TableView详细解析(八、在TableView实现冻结窗口)

一、原理

与EXCEL的冻结窗口原理一致,在主窗口上面创建一个遮罩层,遮罩层的尺寸恰好为冻结范围的尺寸;

二、需求分析

1.基础

冻结窗口至少要满足以下几点:

  • 共享模型
  • 可以设置冻结区域
  • 调整列宽时冻结区同步变化
  • 窗口大小变化时遮罩层自动调整
from PySide6 import QtCore, QtWidgets class FreezeTableWidget(QtWidgets.QWidget): """ 遮罩式冻结列表格控件 """ def __init__(self, parent=None): super().__init__(parent) self._freeze_cols = 0 self.mainView = QtWidgets.QTableView(self) self.frozenView = QtWidgets.QTableView(self) self._init_views() self._layout_main() self._connect_headers() # ------------------------------------------------------------------ # 初始化 # ------------------------------------------------------------------ def _init_views(self): for view in (self.mainView, self.frozenView): view.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) view.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) header = view.horizontalHeader() header.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) header.setStretchLastSection(False) # frozenView 专属配置 self.frozenView.verticalHeader().hide() self.frozenView.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOff ) self.frozenView.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOff ) def _layout_main(self): layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.mainView) # frozenView 作为覆盖层 self.frozenView.setParent(self) self.frozenView.raise_() self.frozenView.hide() def _connect_headers(self): self.mainView.horizontalHeader().sectionResized.connect( self._on_main_column_resized ) # ------------------------------------------------------------------ # model # ------------------------------------------------------------------ def setModel(self, model: QtCore.QAbstractItemModel): self.mainView.setModel(model) self.frozenView.setModel(model) def model(self): return self.mainView.model() def setItemDelegate(self, delegate): self.mainView.setItemDelegate(delegate) self.frozenView.setItemDelegate(delegate) # ------------------------------------------------------------------ # 冻结列 # ------------------------------------------------------------------ def set_frozen(self, count: int): self._freeze_cols = max(0, count) self._apply_frozen() def _apply_frozen(self): model = self.mainView.model() if not model or self._freeze_cols <= 0: self.frozenView.hide() return self.frozenView.show() self._sync_all_column_widths() self._update_frozen_geometry() def _sync_all_column_widths(self): for col in range(self._freeze_cols): self.frozenView.setColumnWidth( col, self.mainView.columnWidth(col) ) def _on_main_column_resized(self, logical, old, new): if logical < self._freeze_cols: self.frozenView.setColumnWidth(logical, new) self._update_frozen_geometry() # ------------------------------------------------------------------ # 遮罩层几何 # ------------------------------------------------------------------ def _update_frozen_geometry(self): if self._freeze_cols <= 0: self.frozenView.hide() return width = sum( self.mainView.columnWidth(i) for i in range(self._freeze_cols) ) header = self.mainView.horizontalHeader() header_height = header.height() self.frozenView.setGeometry( self.mainView.viewport().x() - 2, header.geometry().bottom() - header_height, width, self.mainView.viewport().height() + header_height + 2 ) self.frozenView.raise_() def resizeEvent(self, event): super().resizeEvent(event) self._update_frozen_geometry()

2.滚动跟随 + 行高同步

1阶段完成后,我们的代码就实现基础的遮罩功能了,但是当我们使用滚动加载时会发现冻结的部分不会跟着移动,第二阶段我们要实现

  • mainView ↔ frozenView 垂直滚动同步
  • 行高同步(保证行完全对齐)

a.在__init__里新增调用

位置:self._connect_headers()后面

self._connect_scroll()

def _connect_scroll(self): self.mainView.verticalScrollBar().valueChanged.connect( self.frozenView.verticalScrollBar().setValue ) self.frozenView.verticalScrollBar().valueChanged.connect( self.mainView.verticalScrollBar().setValue )

b.同步行高

_connect_headers()中追加:

self.mainView.verticalHeader().sectionResized.connect(self._on_row_resized)

def _on_row_resized(self, row, old, new): self.frozenView.setRowHeight(row, new)

3.同步选中

完成2阶段后作为展示模型就算是合格了,但你会发现冻结区选中会断开,3阶段要完成下面的目标

两个 view 使用同一个 selectionModel

setModel替换

def setModel(self, model): self.mainView.setModel(model) self.frozenView.setModel(model) self.frozenView.setSelectionModel( self.mainView.selectionModel() )

重写函数

def selectionModel(self): return self.mainView.selectionModel() def selectedIndexes(self): return self.mainView.selectedIndexes() def setSelectionMode(self, mode): self.mainView.setSelectionMode(mode) self.frozenView.setSelectionMode(mode) def setSelectionBehavior(self, behavior): self.mainView.setSelectionBehavior(behavior) self.frozenView.setSelectionBehavior(behavior)

4.事件穿透

完成3阶段后冻结区域依旧是没法复制的,现在我们就需要将冻结的事件和主视图绑定

a.安装事件管理器

在init的最后添加

self.frozenView.viewport().installEventFilter(self)

b.逻辑实现

def eventFilter(self, obj, event): if obj is self.frozenView.viewport(): if event.type() in ( QtCore.QEvent.MouseButtonPress, QtCore.QEvent.MouseButtonRelease, QtCore.QEvent.MouseMove, ): # 坐标映射到 mainView pos = self.mainView.viewport().mapFromGlobal( self.frozenView.viewport().mapToGlobal(event.pos()) ) proxy = QtGui.QMouseEvent( event.type(), pos, event.globalPosition(), event.button(), event.buttons(), event.modifiers(), ) QtWidgets.QApplication.sendEvent( self.mainView.viewport(), proxy ) return True return super().eventFilter(obj, event)

5.增强支持

a,setColumnHidden

def setColumnHidden(self, col, hide): if col < self._freeze_cols: self.frozenView.setColumnHidden(col, hide) else: self.mainView.setColumnHidden(col, hide)

b,model刷新保护

def _bind_model_signals(self, model): model.modelReset.connect(self._schedule_apply) model.layoutChanged.connect(self._schedule_apply) def _schedule_apply(self): QtCore.QTimer.singleShot(0, self._apply_frozen)

二.关联自定义模型的整行选择

这个是给看前面章节的同学准备的,需要在事件管理器的最前面添加下面的代码即可

if event.type() == QtCore.QEvent.Type.MouseButtonPress: # 判断是不是点击在任意 view 上 if isinstance(obj, QtWidgets.QWidget) and obj is self.mainView.viewport(): pos = event.pos() index = self.mainView.indexAt(pos) if not index.isValid(): return False model = self.mainView.model() source_model = model source_index = index if isinstance(model, QtCore.QSortFilterProxyModel): source_model = model.sourceModel() source_index = model.mapToSource(index) if getattr(source_model, "checkbox_status", False) and getattr(source_model, "row_select_enable", False): source_model.toggle_row_check(source_index) return True # 拦截,不走默认

三、下期

我在资源上传了这部分代码可以自行下载,下期介绍多重表头支持