はじめに
Tkinter の入門記事は pack() でボタンを並べるところで終わっているものが多く、
「検索バー・一覧・詳細ペインが 1 画面に収まっていて、ウィンドウを広げると
一覧だけが伸びる」ような実用的な画面をどう組むかは案外まとまっていません。
この記事では、次のような 1 画面構成をゼロから組み立てます。
+-------------------------------------------------------------+
| 検索バー(固定の高さ) |
+---------------------------+---------------------------------+
| 一覧 (Treeview) | 詳細ペイン |
| | |
| ← ウィンドウを広げるとここだけが伸びる → |
+---------------------------+---------------------------------+
| ステータスバー(固定の高さ) |
+-------------------------------------------------------------+
環境は Python 3.10 以降(tkinter は標準添付なので追加インストール不要)です。
1. pack と grid は「混ぜない」が、「使い分ける」
最初に踏む地雷がこれです。
同じ親ウィジェットの中で
pack()とgrid()を混ぜてはいけない。
混ぜるとフリーズします(例外すら出ずに固まることがあります)。
ただしこれは「同じ親の中では」という条件付きなので、
親が違えばフレームごとに好きなほうを使えます。
実際、今回の構成では次のように使い分けるのが素直です。
-
全体の骨格 →
grid(どの行が伸びるかを指定したいから) -
検索バーの中で部品を横に並べる →
pack(side="left")(順番に置くだけだから)
import tkinter as tk
from tkinter import ttk
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("サンプル")
self.geometry("1000x640")
self.minsize(800, 480)
# 骨格は grid。row=1(真ん中)だけが伸びるようにする
self.rowconfigure(1, weight=1)
self.columnconfigure(0, weight=1)
self._build_search_bar() # row=0
self._build_main_pane() # row=1
self._build_status_bar() # row=2
weight を付けた行・列だけが伸びる
ここが grid の肝です。
self.rowconfigure(1, weight=1) # row=1 は余白を全部もらう
self.columnconfigure(0, weight=1)
weight の既定値は 0、つまり伸びない。
だから検索バー(row=0)とステータスバー(row=2)は自然と固定の高さになり、
真ん中だけが伸びます。「ステータスバーを下に貼り付ける」ための特別な指定は要りません。
weight は比率としても使えます。rowconfigure(1, weight=2) と
rowconfigure(2, weight=1) なら、余った高さが 2:1 で配られます。
sticky を忘れると伸びない
weight を付けてもウィジェット自身に sticky が無いと、セルは広がるのに
中身が真ん中に小さく居座ります。
frame.grid(row=1, column=0, sticky="nsew") # 上下左右いっぱいに広げる
"nsew" は north/south/east/west。weight と sticky はセットで書くと覚えてしまうのが早いです。
2. 検索バーは pack(side=...) で素直に
横一列に並べるだけなら grid より pack のほうが読みやすいです。
def _build_search_bar(self):
bar = ttk.Frame(self, padding=(10, 8))
bar.grid(row=0, column=0, sticky="ew") # bar 自体は grid で配置
self.keyword_var = tk.StringVar()
# bar の「中」は pack。親が違うので混ざらない
ttk.Label(bar, text="キーワード").pack(side="left")
entry = ttk.Entry(bar, textvariable=self.keyword_var, width=20)
entry.pack(side="left", padx=(4, 10))
ttk.Button(bar, text="検索", command=self._search).pack(side="left")
# 右端に寄せたいものは side="right"。右から順に詰まっていく
ttk.Button(bar, text="更新", command=self._reload).pack(side="right")
side="right" で置いたものは右から順に詰まります。
2 つ並べたいときは、右端に置きたいほうを先に pack します。
padx にタプルを渡すと左右を別々に指定できる
entry.pack(side="left", padx=(4, 10)) # 左に 4px、右に 10px
地味ですが、ラベルと入力欄はくっつけて、次の項目とは離す、という調整が
これだけで書けます。
3. tk と ttk は「見た目が違う」
tkinter には古い tk.Button と、新しい ttk.Button があります。
-
tk.*… OS に関係なく同じ(古臭い)見た目 -
ttk.*… OS のテーマに合わせた見た目
特別な理由が無ければ ttk を使います。 見た目が今風になるだけでなく、
ttk.Treeview や ttk.PanedWindow のような便利なウィジェットは ttk にしかありません。
ただし ttk に無いものもあります。tk.StringVar のような変数クラス、
tk.Tk(ウィンドウ本体)、tk.Canvas などは tk 側です。混在は正常です。
4. PanedWindow で「境界をドラッグできる」分割
左右の幅をユーザーが変えられるようにすると、それだけでアプリらしくなります。
def _build_main_pane(self):
pane = ttk.PanedWindow(self, orient="horizontal")
pane.grid(row=1, column=0, sticky="nsew", padx=10, pady=(0, 6))
left = ttk.Frame(pane)
right = ttk.Frame(pane)
# weight は初期の配分比率
pane.add(left, weight=2)
pane.add(right, weight=3)
pane.add() に渡すのは直接の子フレームです。
PanedWindow の中に grid で置こうとすると効きません(add が配置そのものです)。
5. Treeview を「表」として使う
ttk.Treeview は名前のとおりツリー表示のウィジェットですが、
show="headings" を指定するとツリーの列が消えて、ただの表になります。
columns = ("occurred_at", "place", "magnitude", "scale")
tree = ttk.Treeview(left, columns=columns, show="headings", selectmode="browse")
headings = {
"occurred_at": ("発生日時", 140, "w"),
"place": ("場所", 170, "w"),
"magnitude": ("規模", 60, "center"),
"scale": ("最大震度", 70, "center"),
}
for key, (text, width, anchor) in headings.items():
tree.heading(key, text=text) # 見出しの文字
tree.column(key, width=width, anchor=anchor, # 列の幅と揃え
stretch=(key == "place")) # 伸ばす列を 1 つだけ選ぶ
ポイントは 3 つです。
show="headings"
これを書かないと、左端に空っぽのツリー列(#0)が居座ります。
表として使うなら必ず指定します。
stretch
既定では全部の列が均等に伸びて、幅の指定が台無しになります。
伸びてほしい列だけ stretch=True にすると、他の列は指定幅を保ちます。
selectmode="browse"
1 行だけ選べるモード。既定の "extended" は複数選択できてしまうので、
「選んだ 1 件の詳細を出す」ような画面では browse のほうが素直です。
行の追加と、行 ID の使い方
tree.insert("", "end", iid=item.id, values=(a, b, c, d))
第 1 引数は親(表として使うなら空文字)、第 2 引数は挿入位置。
覚えておくと効くのが iid です。行に自分で ID を付けられます。
ここに元データの ID を入れておくと、選択されたときに辞書 1 つで元データを引けます。
self._items = {item.id: item for item in items} # 表示中のデータ
def _on_select(self, _event=None):
selection = tree.selection() # 選択中の iid のタプル
if not selection:
return
item = self._items[selection[0]] # 行 → 元データ が一発で引ける
self._show_detail(item)
tree.bind("<<TreeviewSelect>>", self._on_select)
インデックス番号で管理して、並べ替えのたびにズレて悩む……というのを避けられます。
スクロールバーは自分で繋ぐ
Treeview は自動でスクロールバーを出しません。相互に繋ぎます。
scrollbar = ttk.Scrollbar(left, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=scrollbar.set)
left.rowconfigure(0, weight=1)
left.columnconfigure(0, weight=1)
tree.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
command=tree.yview(バーを動かしたら表を動かす)と
yscrollcommand=scrollbar.set(表が動いたらバーを動かす)の両方向が必要です。
片方だけだと「ドラッグはできるがホイールでバーが動かない」といった半端な状態になります。
6. 表示は StringVar 経由にする
ステータスバーのように後から書き換えるラベルは、StringVar を挟むと楽です。
self.status_var = tk.StringVar(value="準備中…")
ttk.Label(frame, textvariable=self.status_var, anchor="w").grid(row=2, column=0, sticky="ew")
# あとはどこからでも
self.status_var.set("12 件見つかりました")
ラベルのオブジェクトを持ち回らなくてよくなるので、
「あのラベルどこで作ったっけ」と探す時間が消えます。
7. Enter キーで検索できるようにする
毎回ボタンを狙わせるのは不親切なので、キーバインドを足します。
self.bind("<Return>", lambda _event: self._search())
bind に渡す関数にはイベントオブジェクトが 1 つ渡ってくるので、
引数を受け取らない関数をそのまま渡すと TypeError になります。
lambda _event: ... で捨てるのが定番です。
まとめ
| やりたいこと | 書き方 |
|---|---|
| 特定の行・列だけ伸ばす |
rowconfigure(i, weight=1) + sticky="nsew"
|
| 横一列に並べる | 親フレームを分けて pack(side="left")
|
| 見た目を今風にする |
tk.* ではなく ttk.*
|
| 境界をドラッグできる分割 |
ttk.PanedWindow + add(child, weight=n)
|
| 表を出す |
ttk.Treeview(show="headings") + stretch
|
| 行と元データを結ぶ | insert(..., iid=元のID) |
| ラベルの書き換え |
StringVar + textvariable
|
grid の weight と sticky さえ腹落ちすれば、Tkinter のレイアウトで
困ることはかなり減ります。
次は、この画面で通信を始めたときに必ずぶつかる
「ボタンを押すと画面が固まる」問題を扱います。