為 PyQt5 中的資料綁定 Model 與 View — QTableView 表格篇

前言

想利用 PyQt 顯示表格的資料最簡單的用法就是使用 QTableWidget,只要往裡面填入 QStandardItem 就可以了。例如:

from PyQt5.QtWidgets, QtCore, QtGui

import table_widget as ui

class Main(QtWidgets.MainWindow, ui.Ui_MainWindow):
    def __init__(self):
        super().__init__()
        self.setupUi(self)
        data = [
            ['Brand new planet', 'SOUNDTRACKS', 2020],
        ]
        self.insert_data(self.table_widget, data)

    def insert_data(self, table_widget, data):
        row0 = data[0] if len(data) else []
        table_widget.setRowCount(len(data))
        table_widget.setColumnCount(len(row0))
        for r, row in enumerate(data):
            for c, item in enumerate(row):
                table_widget.setItem(r, c, QtWidgets.QTableWidgetItem(str(item)))

if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    window = Main()
    window.show()
    sys.exit(app.exec_())

就可以顯示:

Basic PyQt5 table sample

但每當資料有變動都要一個一個 Loop 去放資料項目很沒效率,有沒有更方便的作法呢?那就來利用資料綁定「視圖(Model)與模型(View)」吧~

Data Model-View

Model-View 就是為了拯救剪不斷理還亂的後端資料與前端呈現,將兩者分別使用各自的 Class 來管理,也就是 Model Class 與 View Class。

本篇利用相對單純的表格做為範例,

  • View Class:GUI 呈現的部分使用 QTableView
  • Model Class:Data 處理的邏輯將繼承自 QAbstractTableModel 並定義自己的 Class 來客製化欲顯示的資料。

View Class 的部分很簡單,就是在 Qt Designer 裡面拖曳一個 QTableView 進去,然後調整自己想要的樣式即可。如果想要顯示標題欄位記得勾選 horizontalHeaderVisible(上方的標題列)或 verticalHeaderVisible(左方的標題列)。

還不會用 Qt Designer 的人建議先看完 PyQt 入門文章再回來~

以下將著重講解 Model Class 的寫法。

Custom Table Model Class

一個簡單的 Read-Only Model 需要實作以下幾個 Abstract Method:

  • data():根據 role 的不同(參考 ItemDataRole 列表)將所需資料回傳給 View,其中的 Qt.DisplayRole 就是在需要顯示該 index 資料時觸發,從 index 可以拿出 View 中的定位資訊(即行數與欄數)。
  • rowCount():回傳目前需要顯示的橫列個數。
  • columnCount():回傳目前需要顯示的直欄個數。

後兩個方法返回的個數值不一定要和 Data 的實際數量相同,例如想隱藏某些資料項目時。接著為了把資料傳入 Model 另外在 __init__() 中帶入。

class SimpleTableModel(QtCore.QAbstractTableModel):
    def __init__(self, data, parent=None):
        super().__init__(parent)
        self._data = data

    def data(self, index, role):
        row = index.row()
        col = index.column()
        if role in {QtCore.Qt.DisplayRole}:
            return self._data[row][col]

    def rowCount(self, parent=QtCore.QModelIndex()):
        return len(self._data)

    def columnCount(self, parent=QtCore.QModelIndex()):
        return len(self._data[0]) if self.rowCount() else 0

回到 GUI 主程式使用前面寫好的 SimpleTableModel

class Main(QtWidgets.QMainWindow, ui.Ui_MainWindow):
    def __init__(self):
         super().__init__()
         self.setupUi(self)
         data = [
             ['Brand new planet', 'SOUNDTRACKS', 2020],
         ]
         model = SimpleTableModel(data)
         self.table_view.setModel(model)

到這邊內容就搞定!現在重啟應用程式就可以看到資料秀出來了。

自定標題列

如果想要更改標題,需要為 SimpleTableModel 實作 headerData()

class SimpleTableModel(QtCore.QAbstractTableModel):
    # ...
    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        titles = ['Song', 'Album', 'Year'] # should not be here for production code
        if role == QtCore.Qt.DisplayRole: # only change what DisplayRole returns
            if orientation == QtCore.Qt.Horizontal:
                return titles[section]
            elif orientation == QtCore.Qt.Vertical:
                return f'#{section + 1}'
        return super().headerData(section, orientation, role) # must have this line

只需要調整 Qt.DisplayRole 輸出的資料即可,其他 Role 請記得呼叫 superheaderData() 否則標題欄會不見,其中的 section 參數就是 View 中的行 Index 或欄 Index。範例 Code 中我直接寫死欲顯示的標題欄位 titles,真正實作建議從 __init__() 帶入、或使用其他更好的方式。

如果 role 不限制 DisplayRole 標題欄不見是因為 SizeHintRole 返回不正確的值。

總之就可以顯示出標題啦~

PyQt5 table view sample with header data

使項目可編輯

目前爲止我們實作了一個只可讀的 TableView,如果想讓某些格子可以編輯的話也很簡單,只要在 flags() 方法中吐出正確的標記 Flag 即可(參考可用 ItemFlag 列表)。

class SimpleTableModel(QtCore.QAbstractTableModel):
    # ...
    def flags(self, index):
        return super().flags(index) | QtCore.Qt.ItemIsEditable

現在在表格格子裡點兩下,可以編輯了!但是原本的文字竟然不見!?這是因為我們還沒有給 Qt.EditRole 返回值,修改一下前面範例的 data() 方法。

class SimpleTableModel(QtCore.QAbstractTableModel):
    # ...
    def data(self, index, role):
        row = index.row()
        col = index.column()
        if role in {QtCore.Qt.DisplayRole, QtCore.Qt.EditRole}: # add QtCore.Qt.EditRole
            return self._data[row][col]

預設的編輯器會根據返回的型態而改變,例如字串就會用文字框、數值就會用數字框⋯,如果想自訂編輯器可以進一步使用 QItemDelegate(例如想使用 QComboBox),這屬於進階用法有興趣的同學可以自行去探索

將資料同步到視圖

當資料發生增減需要同步視圖的顯示時,需要實作以下幾個方法:

class SimpleTableModel(QtCore.QAbstractTableModel):
    # ...
    def insertRows(self, row, count, parent=QtCore.QModelIndex()):
        self.beginInsertRows()
        # do actual data insert
        self.endInsertRows()

    def removeRows(self, row, count, parent=QtCore.QModelIndex()):
        self.beginRemoveRows()
        # do actual data remove
        self.endRemoveRows()

    def insertColumns(self, column, count, parent=QtCore.QModelIndex()):
        self.beginInsertColumns()
        # do actual data insert
        self.endInsertColumns()

    def removeColumns(self, column, count, parent=QtCore.QModelIndex()):
        self.beginRemoveColumns()
        # do actual data remove
        self.endRemoveColumns()

嗯,好像有點囉嗦?這是因為 Model 需要通知 View 資料即將改變(View 將暫停更新畫面)、以及資料已改變完成(View 將重新繪製畫面),以確保資料正確顯示在視圖中。


好了,到這邊相信大家對 Model-View 有一定的概念啦~和使用 QTableWidget 利用迴圈硬塞 Item 進去比起來,最大的不同就是一次只處理一個 Item!在 Model 的程式碼中你會發現所有方法的第一個參數一定是各種形式的 Index,視圖在顯示到該項目時就會觸發各種我們有寫到的方法並且帶入對應的座標。如此便可脫離迴圈的泥沼中,漂亮的根據項目定位來處理資料顯示。

這樣,你懂了嗎?

References

  1. Model/View Programming – Qt for Python
  2. Model/View Tutorial | Qt Widgets 5