PySide6でデスクトップアプリを作る #5 モデルとビュー — リスト・テーブルにデータをつなぐ

読了 8分

ここまでToDoリストは、QListWidgetに文字列をaddItemで直接入れてきました。この方式は始めやすい半面、すぐに壁に当たります。ToDoに期限と完了状態を一緒に持たせようとした瞬間、データがウィジェットの中に閉じ込められていることが問題になります。同じデータを別の画面にも表示するにはコピーして2回入れる必要があり、並べ替えや検索をするにはウィジェットの項目を直接さらうことになります。Qtはこの問題をモデルとビューの分離で解決します。

全7回で構成します。

  • #1 PySide6とは — QtとPythonでデスクトップアプリ
  • #2 ウィジェットとレイアウト — 画面を組み立てる
  • #3 シグナルとスロット — イベント処理の核心
  • #4 Qt DesignerとUIファイル — 画面を描いて読み込む
  • #5 モデルとビュー — リスト・テーブルにデータをつなぐ ← この記事
  • #6 スレッドとタイマー — 止まらないUI
  • #7 パッケージングと配布 — PyInstallerで実行ファイルを作る

今回はモデル/ビュー構造の考え方を押さえ、QAbstractTableModelを継承してToDoテーブルモデルを自分で実装したうえで、プロキシモデルで並べ替えと検索フィルターまで付けます。

モデル/ビュー構造 — データと表示の分離 #

Qtのモデル/ビューは、広く知られたMVCパターンのQt流の変形です。役割は2つの軸に分かれます。

  • モデル — データを保管し、「何行何列か」「このセルの値は何か」という質問に答えます。画面については何も知りません。
  • ビュー — モデルに質問を投げ、返ってきた答えを描きます。データを所有しません。

この分離がもたらす利点は明確です。同じモデルをリストビューとテーブルビューに同時につないでもデータは1つで、モデルのデータが変わればつながっているすべてのビューが一緒に更新されます。並べ替えとフィルターも、データの原本に手を触れずに中間の層で処理できます。

QListWidgetのように名前がWidgetで終わる項目型ウィジェットは、実はモデルとビューをひとつにまとめた簡便型です。始めやすい代わりにデータがウィジェットに閉じ込められる構造なので、データに構造が生まれた時点でモデル/ビューへ移るのがQtの標準の道筋です。

いちばん小さいモデル/ビュー — QStringListModel #

継承なしでそのまま使える既製のモデルから確認します。文字列のリストにはQStringListModelがあります。

QListView + QStringListModel
from PySide6.QtCore import QStringListModel
from PySide6.QtWidgets import QListView

model = QStringListModel(["牛乳を買う", "PySide6の記事を読む"])
view = QListView()
view.setModel(model)

QListWidgetと画面は同じですが、構造が違います。データはモデルが持っていて、ビューはsetModelでつないだだけです。model.setStringListでデータを変えるとビューが即座に追従します。とはいえToDoにはタイトルのほかに期限と完了状態も必要なので、文字列のリストでは足りません。列が複数あるテーブルモデルを自分で作る番です。

QAbstractTableModelの継承 — ToDoテーブルモデル #

表形式のカスタムモデルは、QAbstractTableModelを継承して最低3つのメソッドに答えれば作れます。何行あるか(rowCount)、何列あるか(columnCount)、各セルの値は何か(data)です。

ToDoテーブルモデル
from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex


class TodoModel(QAbstractTableModel):
    HEADERS = ["タイトル", "期限", "状態"]

    def __init__(self, todos=None):
        super().__init__()
        self._todos = todos or []

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

    def columnCount(self, parent=QModelIndex()):
        return len(self.HEADERS)

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if not index.isValid():
            return None
        todo = self._todos[index.row()]
        if role == Qt.ItemDataRole.DisplayRole:
            if index.column() == 0:
                return todo["title"]
            if index.column() == 1:
                return todo["due"]
            if index.column() == 2:
                return "完了" if todo["done"] else "進行中"
        return None

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
            return self.HEADERS[section]
        return None

dataの2番目の引数であるroleが、モデル/ビュー理解の入口です。ビューはセルを1つ描くとき、何度も質問します。表示する文字は何か(DisplayRole)、背景色は何か(BackgroundRole)、文字寄せはどちらか(TextAlignmentRole)をそれぞれ聞いてきて、モデルは答えたいroleにだけ値を返せばよい仕組みです。上の実装は表示文字にだけ答え、残りはNoneを返してビューのデフォルト動作に任せました。

ビューへの接続はリストのときと同じです。

QTableViewへの接続
from PySide6.QtWidgets import QTableView

model = TodoModel([
    {"title": "牛乳を買う", "due": "2026-08-10", "done": False},
    {"title": "原稿を送る", "due": "2026-08-12", "done": True},
])
view = QTableView()
view.setModel(model)

データ変更の通知 — モデルがビューを呼ぶ #

カスタムモデルでいちばん多い失敗は、内部のリストだけ直して終えることです。self._todos.append(...)だけ実行すると、データは増えたのにビューは知りません。モデルは変更の前後で決められた通知メソッドを呼ぶ必要があり、それでこそつながっているすべてのビューが更新されます。

行の追加と値の変更 — 通知込み
    def add_todo(self, title, due):
        row = len(self._todos)
        self.beginInsertRows(QModelIndex(), row, row)
        self._todos.append({"title": title, "due": due, "done": False})
        self.endInsertRows()

    def toggle_done(self, row):
        self._todos[row]["done"] = not self._todos[row]["done"]
        index = self.index(row, 2)
        self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])

行を追加するときはbeginInsertRowsendInsertRowsで挟み、既存セルの値が変わったらdataChangedシグナルで変わった範囲を知らせます。規則はひとつに要約できます。データを直すコードは必ずモデルのメソッドの中に置き、そのメソッドが通知まで責任を持ちます。外のコードが_todosを直接触り始めると、画面とデータがずれるバグにつながります。

並べ替えと検索 — QSortFilterProxyModel #

並べ替えとフィルターのためにモデルを直す必要はありません。Qtはモデルとビューの間に挟む中間モデルを提供しています。

プロキシモデルで並べ替えと検索
from PySide6.QtCore import QSortFilterProxyModel
from PySide6.QtWidgets import QLineEdit

proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
proxy.setFilterKeyColumn(0)          # タイトル列を基準にフィルター

view.setModel(proxy)                 # ビューにはプロキシをつなぐ
view.setSortingEnabled(True)         # ヘッダークリックで並べ替え

search = QLineEdit(placeholderText="検索")
search.textChanged.connect(proxy.setFilterFixedString)

構造が核心です。ビューが見ているのは元のモデルではなくプロキシで、プロキシは元のモデルを並べ替えたり絞り込んだりした結果だけをビューに見せます。元データの順序はそのままです。検索入力欄のtextChangedシグナルをプロキシのsetFilterFixedStringスロットに直接つないだので、タイピングするたびにテーブルがリアルタイムで絞り込まれます。#3で整理したシグナル・スロットの接続が既製のスロットと出会うと、このようにコード1行になります。

選択の処理 — どの行を選んだか #

ユーザーが選んだ行を知る窓口は、ビューのselectionModelです。

選択された行を読む
view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)

def on_row_changed(current, previous):
    source_index = proxy.mapToSource(current)   # プロキシ → 元モデルの座標
    print("選択された行:", source_index.row())

view.selectionModel().currentRowChanged.connect(on_row_changed)

注意点がひとつあります。ビューがプロキシにつながっているので、選択のインデックスもプロキシ基準の座標です。並べ替えやフィルターが効いた状態では、画面の3行目が元データの3行目ではありません。元データを修正するには、mapToSourceで座標を元モデル基準に変換してから使う必要があります。プロキシを使うアプリでいちばん多いバグが、この変換の抜けです。

注記
セルをダブルクリックして値を直す編集機能まで開くには、モデルにsetDataflagsメソッドを追加で実装します。今回の範囲を超えますが、「ビューが聞き、モデルが答える」という同じ原理で編集のリクエストもモデルに委ねられるという点だけ覚えておけば、必要になったとき無理なく拡張できます。

まとめ #

今回の核心は4つです。

  • モデルはデータと質問への応答を、ビューは描画を受け持ちます。Widget型の簡便ウィジェットはこの2つをまとめた形なので、データに構造が生まれたらモデル/ビューへ移ります。
  • カスタムテーブルモデルはQAbstractTableModelを継承してrowCountcolumnCountdataに答えれば作れて、roleごとに答えたいものにだけ答えます。
  • データの変更は必ずモデルのメソッドの中で、通知(beginInsertRowsdataChanged)とともに行ってこそビューが追従します。
  • 並べ替えと検索はQSortFilterProxyModelを挟んで解決し、選択の座標はmapToSourceで元モデル基準に変換して使います。

テーブルのデータが増えると、ファイル保存やネットワーク同期のように時間のかかる作業が付いてきます。こうした作業をボタンのクリックハンドラでそのまま実行すると、ウィンドウ全体が固まります。次回の「PySide6でデスクトップアプリを作る #6 スレッドとタイマー — 止まらないUI」でこの問題を扱います。

X