上一篇提到了表格的顯示,這一篇來實作看看樹形圖的顯示。
Custom Tree Model Class
樹形圖(下圖中的樹狀模型)和一般的列表、表格不同的地方在於,有階層關係:

因此需要一個結構體去記錄從屬關係。底下實作一個萬用基礎節點 TreeItem
來包裝樹形圖上的每一點資料,其中:
node
用來擺放真正的資料,可以為任何類型的物件。_parent
用來記錄目前TreeItem
的上層TreeItem
節點_childs
用來記錄目前TreeItem
的子TreeItem
節點
from dataclasses import dataclass, field
from typing import List
@dataclass
class TreeItem:
node: Any = None
_parent: TreeItem = field(default=None, repr=False)
_childs: List[TreeItem] = field(default_factory=list, repr=False)
def child(self, row):
return self._childs[row]
def childCount(self):
return len(self._childs)
def parent(self):
return self._parent
def row(self):
if self._parent:
self_id = id(self)
child_ids = [id(x) for x in self._parent._childs]
if self_id in child_ids:
return child_ids.index(self_id)
return 0
def addChild(self, node: Any):
child = self.__class__(node, _parent=self)
self._childs.append(child)
return child
以及一個萬用基礎視圖模型 AbstractTreeModel
提供 View 來綁定,其中:
rootItem
為整個樹形圖看不到的頂點。你可以透過呼叫QtCore.QModelIndex()
,來取得一個不合法的 Index(通常代表此頂點)。
from PyQt5 import QtCore
class AbstractTreeModel(QtCore.QAbstractItemModel):
def __init__(self, *args, parent=None, **kwargs):
super().__init__(parent)
self.rootItem = TreeItem()
if post_init := getattr(self, "__post_init__", None):
# pass all initialization arguments to __post_init__
post_init(*args, **kwargs)
def index(self, row, column, parent=QtCore.QModelIndex()) -> QtCore.QModelIndex:
"""為視圖中所有能歷遍到的項目,給定一個Unique Index"""
if not self.hasIndex(row, column, parent):
return QtCore.QModelIndex()
if not parent.isValid():
parent_item = self.rootItem
else:
parent_item = self.itemFromIndex(parent)
if row >= parent_item.childCount():
return QtCore.QModelIndex()
child_item = parent_item.child(row)
assert type(child_item) == type(self.rootItem), "%r, %r" % (
type(child_item),
type(self.rootItem),
)
# 雖然這裡使用 createIndex,但 Qt 有一套機制,在可能的情況下將會返回快取值
# 每當視圖結構有改變時,例如載入新節點或移除現有節點,才會重新產生一個新的 Index
index = self.createIndex(row, column, child_item)
return index
def itemFromIndex(self, index) -> TreeItem:
"""從Index獲取原始項目"""
return index.internalPointer()
def indexFromItem(self, item, column=0):
"""從項目獲取其Index"""
if item is None:
return QtCore.QModelIndex()
assert type(item) == type(self.rootItem), "%r, %r" % (
type(item),
type(self.rootItem),
)
return self.createIndex(item.row(), column, item)
def columnCount(self, index) -> int:
"""總欄數 You can overwrite this method for multi-column data model"""
return 1
def parent(self, index) -> QtCore.QModelIndex:
"""從當前 Index 取得父層 Index"""
if not index.isValid():
return QtCore.QModelIndex()
child_item = self.itemFromIndex(index)
parent_item = child_item.parent()
if parent_item == self.rootItem:
return QtCore.QModelIndex()
elif parent_item is None:
return QtCore.QModelIndex()
else:
assert type(parent_item) == type(self.rootItem), "%r, %r" % (
type(parent_item),
type(self.rootItem),
)
# 無論Column為何,均返回 Column 為 0 的父項目
# 這裡弄錯欄的話,子項目會顯示不出來
return self.createIndex(parent_item.row(), 0, parent_item)
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole) -> Any:
"""
取得當前 Index 的 role 資料(文字顯示使用 DisplayRole,其他請參考 Document)
you need to reimplement this method for correct data output
"""
if not index.isValid():
return None
return None
# You should implant/modify the following method for lazylod model
def rowCount(self, index=QtCore.QModelIndex()) -> int:
"""
取得當前 Index 的子項目數量
LazyLoad 情況下也要計算出子項目數量,視圖中才會顯示展開圖示
"""
if index.column() > 0:
return 0
if not index.isValid():
item = self.rootItem
else:
item = self.itemFromIndex(index)
return item.childCount()
def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
"""視圖用來判斷當前 Index 是否可展開"""
return False
def fetchMore(self, parent: QtCore.QModelIndex) -> None:
"""展開節點時將會呼叫此 Method,請在此實作插入新資料/節點的程式碼"""
...
到這邊就打完樹形圖的基礎了,心裡 OS:程式碼也太長。這也是爲什麽要寫成 Abstract Class 的原因,之後要創建新 Tree Model 只要 Subclass 這些即可。
以顯示 JSON 資料結構為例
SimpleDictModel
這裡示範如何透過前述的 AbstractTreeModel
來實作一個視圖模型,以顯示多層結構的 JSON 資料。注意此模型會在初始化時載入所有資料,所以遇到肥胖的 JSON 資料 GUI 介面會卡頓一下。下一段將介紹如何實現異步載入。
class SimpleDictModel(AbstractTreeModel):
def __post_init__(self, data):
self.rootItem = TreeItem(("", {})) # key value pair
self._insert_items(data)
def _insert_items(self, data: dict, parent=None):
"""custom private method to insert all children item in dict"""
parent_item = parent or self.rootItem
for key, value in iter_children(data):
item = parent_item.addChild((key, value))
if len(list(iter_children(value))):
self._insert_items(value, item)
def columnCount(self, index) -> int:
return 3
def rowCount(self, index=QtCore.QModelIndex()) -> int:
if not index.isValid():
return self.rootItem.childCount()
item = self.itemFromIndex(index)
return item.childCount()
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
item = self.itemFromIndex(index)
if role == QtCore.Qt.ItemDataRole.DisplayRole:
key, val = item.node
if index.column() == 0:
return key
elif index.column() == 1:
if len(list(iter_children(val))) == 0:
return val
else:
return ""
else:
return str(type(val))
def iter_children(data: Any):
"""function to iterate data children"""
if isinstance(data, list):
iter_func = lambda x: enumerate(x)
elif isinstance(data, dict):
iter_func = lambda x: x.items()
else:
iter_func = lambda x: []
for x in iter_func(data):
yield x
另外寫了一個 iter_children
以處理不同型別的歷遍。現在讓我們在 GUI 中使用吧,底下直接把欲顯示的資料 data
寫在程式碼裡面,實務上通常會從 API 擷取或是讀取外部檔案:
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setup_ui()
self.setup_model()
def setup_ui(self):
self.tree_view = QtWidgets.QTreeView()
self.layout = QtWidgets.QVBoxLayout(self)
self.layout.addWidget(self.tree_view)
def setup_model(self):
data = {
"TrainNo": "TR0334",
"Direction": 1,
"Stops": [
{
"Name": {
"En": "Tainan",
"Zh_tw": "台南",
},
"Name": {
"En": "Kaohsiung",
"Zh_tw": "高雄",
}
}
],
"TrainType": "Local",
}
tree_model = SimpleDictModel(data)
self.tree_view.setModel(tree_model)
if __name__ == "__main__":
app = QtWidgets.QApplication([])
widget = MyWidget()
widget.resize(800, 600)
widget.show()
sys.exit(app.exec())
開啟終端機輸入:
$ python3 SimpleDictModel.py
成功!

LazyLoadDictModel
現在來示範如何實作一個異步載入的視圖模型,使得資料只有在需要的時候才會載入視圖。針對一個有子項目的父層實作心法:
- 在未載入資料時仍去計算子項目數量,這樣 View 上才會顯示 ▶
- 在尚未完全載入的時候
canFetchMore
都要回True
- View 將呼叫
fetchMore
來載入子項目
class LazyDictModel(AbstractTreeModel):
def __post_init__(self, data):
self.rootItem = TreeItem(("", {})) # key value pair
self._insert_items(data)
def _insert_items(self, data: dict, parent=None):
"""custom private method to insert all children item in dict"""
parent_item = parent or self.rootItem
for key, value in iter_children(data):
parent_item.addChild((key, value))
def columnCount(self, index) -> int:
return 3
def rowCount(self, index=QtCore.QModelIndex()) -> int:
if not index.isValid():
return self.rootItem.childCount()
item = self.itemFromIndex(index)
_, val = item.node
return item.childCount() or len(list(iter_children(val)))
def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
if not parent.isValid():
return False
item = self.itemFromIndex(parent)
_, val = item.node
return item.childCount() == 0 and len(list(iter_children(val)))
def fetchMore(self, parent: QtCore.QModelIndex) -> None:
item = self.itemFromIndex(parent)
key, val = item.node
print("FetchMore", key) # print for debug
self._insert_items(val, item)
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
item = self.itemFromIndex(index)
if role == QtCore.Qt.ItemDataRole.DisplayRole:
key, val = item.node
if index.column() == 0:
if len(list(iter_children(val))) == 0:
return val
else:
return ""
elif index.column() == 1:
return key
elif index.column() == 2:
return str(type(val))
可以在每次打開節點時,查看終端機的輸出,是否印出 FetchMore XXX
,來確認 Lazy Load 的效果。
標題列顯示
標題列現在顯示1、2、3很醜,想要顯示文字?試著加入 headerData
來顯示標題列資料吧:
class LazyDictModel(AbstractTreeModel):
HEADERS = ["Name", "Value", "Type"]
# ...
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
) -> Any:
assert orientation == QtCore.Qt.Orientation.Horizontal
if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.HEADERS[section]
return super().headerData(section, orientation, role)
def columnCount(self, index) -> int:
return len(self.HEADERS)
現在重新執行一次GUI,應該可以看到以下畫面,就大功告成啦!

以 File Explorer 爲例
雖然 PyQt 已經有內建 QFileSystemModel,這邊還是讓我們重新打造一次輪胎,練習實作 Model。
顯示資料夾内的檔案:
我們 Subclass 前述的兩個基礎 Class 分別實作
from __future__ import annotations
import contextlib
from pathlib import Path
from PyQt6 import QtCore, QtWidgets
@contextlib.contextmanager
def layoutChange(model: AbstractTreeModel):
try:
model.layoutAboutToBeChanged.emit()
yield
finally:
model.layoutChanged.emit()
class FSModel(AbstractTreeModel):
def __post_init__(self):
self.rootItem = TreeItem(Path())
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
) -> Any:
assert orientation == QtCore.Qt.Orientation.Horizontal
if role == QtCore.Qt.ItemDataRole.DisplayRole:
if section == 0:
return "Name"
return super().headerData(section, orientation, role)
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
item = self.itemFromIndex(index)
if role == QtCore.Qt.ItemDataRole.DisplayRole:
return item.node.name
def setRootPath(self, path: str):
"""useful method to set the root path of this model"""
with layoutChange(self):
self.beginRemoveRows(QtCore.QModelIndex(), 0, self.rootItem.childCount())
self.rootItem = TreeItem(Path(path))
self.endRemoveRows()
self.fetchMore(self.indexFromItem(self.rootItem))
def rowCount(self, index=QtCore.QModelIndex()):
if not self.canFetchMore(index):
return super().rowCount(index)
item = self.itemFromIndex(index)
if item.node.is_dir():
try:
return len(list(item.node.iterdir()))
except PermissionError:
return 0
else:
return 0
def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
if not parent.isValid():
return False
item = self.itemFromIndex(parent)
if item is None:
return False
return item.childCount() == 0 and item.node.is_dir()
def fetchMore(self, parent: QtCore.QModelIndex) -> None:
parent_item = self.itemFromIndex(parent)
files = list(parent_item.node.iterdir())
with layoutChange(self):
self.beginInsertRows(parent, 0, len(files))
for f in files:
parent_item.addChild(f)
self.endInsertRows()
然後在主程式裡面使用此模型:
import os
import sys
from PyQt5 import QtCore, QtWidgets
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.btn_openfolder = QtWidgets.QPushButton()
self.btn_openfolder.setText("&Open Folder")
self.tree_view = QtWidgets.QTreeView()
""" add a button to open other folder """
self.layout = QtWidgets.QVBoxLayout(self)
self.layout.addWidget(self.btn_openfolder)
self.layout.addWidget(self.tree_view)
""" use our model """
self.tree_model = FSModel()
self.tree_model.setRootPath(os.path.join(os.path.dirname(__file__), ".."))
self.tree_view.setModel(self.tree_model)
""" init event binding"""
self.btn_openfolder.clicked.connect(self._select_folder)
def _select_folder(self):
folderpath = QtWidgets.QFileDialog.getExistingDirectory(self, 'Select Folder')
if folderpath:
self.tree_model.setRootPath(folderpath)
if __name__ == "__main__":
app = QtWidgets.QApplication([])
widget = MyWidget()
widget.resize(800, 600)
widget.show()
sys.exit(app.exec())
就大功告成啦~來看效果:
