t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

Python GUI: TkinterのPackレイアウトの仕組みを解説

前回はtkinterのpackレイアウトシミュレーターを作成した。
thom.hateblo.jp

今回は折角なのでこのシミュレーターを使ってPackレイアウトの仕組みについて説明しようと思う。

目次

まずはtkのコードから説明

tkのコードを見たことが無い人は意味が分からないと思うので、まずは簡単なtkコードを紹介してみようと思う。初見の方に向けてくどいほどコメントを入れたので面倒臭そうな印象を受けるかもしれないが、たった6行のコードだ。

コード

#tkinterモジュールをtkという名前を付けてインポート
import tkinter as tk

#変数rootにtkウインドウをセットし、サイズを400x300に指定
root = tk.Tk()
root.geometry("400x300")

#変数item1にラベルをセットする。
#この時、親オブジェクトをroot(つまりtkウインドウ)に指定し、
#テキストをItem 1と指定し、背景色をピンクに指定する。
item1 = tk.Label(master=root, text="Item 1", bg="pink")

#変数item1(つまりラベル)を親ウインドウにパック(詰めこむ)。
#詰め込む方向は上(side="top"、隙間は埋めない(fill="none")、
#占有領域の拡張も要求しない(expand=False)
item1.pack(side="top", fill="none", expand=False)

#実は上記3設定はpackのデフォルト値なので、単にitem1.pack()と引数無しで実行しても同じことである。
#今回は説明の都合上、フルで記載した。

#準備できたのでウインドを表示させる。
root.mainloop()

実行結果

f:id:t-hom:20220207205757p:plain

ラベルを増やしてみる

ラベルを増やすには、単純にitem変数をもう一つ準備して、そちらもパックすれば良い。

import tkinter as tk

root = tk.Tk()
root.geometry("400x300")

item1 = tk.Label(master=root, text="Item 1", bg="pink")
item2 = tk.Label(master=root, text="Item 2", bg="lightblue")

item1.pack(side="top", fill="none", expand=False)
item2.pack(side="top", fill="none", expand=False)

root.mainloop()

実行結果はこのとおり。
f:id:t-hom:20220207210618p:plain

このとき、item1の生成→item2の生成→item1のパック→item2のパックという順にコードを並べている。
一見すると生成した流れでそのままパックする方が、「変数は使用する直前に宣言する原則」に従っているような気がするけれど、これにはちゃんと理由がある。

tkの他のレイアウト方式であるgridレイアウトやplaceレイアウトでは配置の際の引数で場所を特定できるのに対し、packレイアウトではpackする順序がとても重要になる。よって、生成は生成、レイアウトはレイアウトでコードを固めた方がコントロールしやすい。

pack順が影響を及ぼす例を出すと、次のようにItem 1を下に、Item 2を上に配置させたとする。
分かりやすいようにfillとexpandも有効にしてみた。

item1.pack(side="bottom", fill="both", expand=True)
item2.pack(side="top", fill="both", expand=True)

結果はこうなる。
f:id:t-hom:20220207211856p:plain

ウインドウの端をドラッグして小さくしていくと、以下のようにItem2が先に隠れてしまうことがわかる。
f:id:t-hom:20220207212009p:plain

これは先にpackしたitem1が優先されるためだ。

次にpackの順番を変えてみる。

item2.pack(side="top", fill="both", expand=True)
item1.pack(side="bottom", fill="both", expand=True)

すると先ほどと全く同じようなウインドウが表示されるが、縮めてみるとこのとおり。
f:id:t-hom:20220207212115p:plain
今度は先にpackしたitem2が優先される。

こういうサンプルで示されてもそれがどうした?となりがちなので、私が実際に困った実例も紹介しておこう。
まさにtkinterのpackレイアウトシミュレーターを作っていたとき、設定パネルが乗ったフレームを最後にpackしていたので、画面を縮めると先に設定パネルから隠れてしまい、これを何とかしたかった。
f:id:t-hom:20220207212742p:plain

packする順番を変えたのが、現在のコード。
f:id:t-hom:20220207213019p:plain

さて、とりあえずここまででpackレイアウトのオプション指定方法やpack順の話は一区切りとして、次に実際のレイアウトに適用する方法を紹介していく。

packのレイアウトの考え方

packは「詰める・梱包・荷造り」という意味があるので、まずはそのイメージを持ってもらえればと思う。
例えば旅行に出かけるのにスーツケースの端から色々詰め込むイメージ。

詰める方向は上下左右のいずれかを指定するが、ピンポイントで「このあたりに」という操作はできない。

占有領域の考え方

packで詰め込まれたアイテムは、詰め込まれた方向と垂直方向に見えない占有領域を持っている。

たとえば、Item1にside="top"を指定して上にpackした場合、その左右いっぱいがItem1の占有領域となる。
f:id:t-hom:20220207220044p:plain

ここに、Item2にside="left"を指定して左にpackした場合、下方向へはウインドウの端まで、上方向へはItem1の占有領域とぶつかるところまでがItem2の占有領域となる。

このように、ウインドウ全体で見たとき、左にpackしたItem2が上下の中心からわずかに下に見えるのはそのせいである。占有領域内ではちゃんと中心に位置している。
f:id:t-hom:20220207220557p:plain

ここでfillを使うと、占有領域内いっぱいにアイテムを広げることができる。
以下では、Item1をx方向へ、Item2をy方向へfillしている。
f:id:t-hom:20220207221053p:plain

ただしfillでは占有領域が広がるわけではない
試しにItem 1にy方向のfillを、Item 2にx方向のfillを指定してみるが何も変わらない。
fillは占有領域を拡張してくれる訳ではないからだ。
f:id:t-hom:20220207221732p:plain

そこで登場するのがexpandである。
expandを使用すると、sideで指定された詰め込み方向と水平方向に占有領域が拡張される。
また、占有領域は詰め込み方向と垂直方向からの力に弱いため、他の占有領域が拡張されると押しつぶされることがある。

たとえば上方向に詰め込まれたItem 1をexpandすると上下方向に占有領域が拡張される。
そして左方向に詰め込まれたItem 2は上下方向からの力に弱いため、Item 1の占有領域拡張に伴って押しつぶされる。
実際にやってみると、今度はy方向のfillが効いて占有領域いっぱいにY方向にItemが引き延ばされるのが分かる。
f:id:t-hom:20220207222657p:plain

ちなみにfillをnoneに設定すると占有領域の中央にItemが配置されているのが分かる。
f:id:t-hom:20220207222941p:plain

今回は見えない占有領域を図示しているので簡単に思えるかもしれないが、この見えない占有領域の考え方が理解できるまではfillとexpandの挙動がさっぱり分からなくて苦労した。今なら理解できる。

例1) expand(拡張)してるのにアイテムが伸びずに宙ぶらりんになる。→fillできてないから。
例2) fill(埋め)してるのにアイテムが伸びない。→占有領域は水平方向には自動拡張しないから。

packする順番の重要性

tkinterではpackする順序によって基本的な占有領域の配置が決まってしまう。
私が作成したpack layout simulatorでは常にItem番号が若いほうからpackされるようにしてあるので、試しに様々な方向から詰め込んでみよう。
f:id:t-hom:20220207225022p:plain

この図から、まずItem 1を横から押せるアイテムは無いということ。オセロで角を取ったようなものだ。
Item 2を上から押せるアイテムはItem1だけだ。同様にItem3を横から押せるのはItem 2のみ。

つまり先にpackした方が占有領域において有利に働くことになる。

ちなみに同じ方向へのExpandは力が釣り合うところまで押し返すことができる。
たとえばItem1だけexpandするとこうなるが、
f:id:t-hom:20220207225701p:plain

Item6もexpandしてやると、Item1と力が釣り合って同じYサイズのところで止まる。
f:id:t-hom:20220207225727p:plain

packの実用レイアウト

例えばpackで次のようなレイアウトを作りたいとする。
f:id:t-hom:20220207230213p:plain

このときに間違えがちなのが、素直にコンテンツ1から配置しようとすることだ。
これはBottomをフッターでまるごとX方向に占有する必要があるので、まずフッターを置かないと上手く行かない。

正解の設定はこんな感じで、ボトムをexpand無しでまず配置して、残りをLeftかRightに詰めてexpandすること。
f:id:t-hom:20220207230504p:plain

packでレイアウトするときは、占有領域のことを考慮しなければならない。

ヘッダーを付けたいと思ったら、こんな感じでヘッダー・フッター・中身の順にpackすることになる。
f:id:t-hom:20220207230903p:plain

フッター・ヘッダー・中身の順でも同じ見た目になるが、フォームを縮めたときにコンテンツが隠れる順が変わるのでケースバイケースで使い分けると良いかと思う。
ちなみにヘッダーやフッターをexpandしてしまうと中身が潰れるので注意。

実際のアプリへの適用

今回はtkのラベルを用いて説明したが、実際にはtk.Frameを用いて、その中にボタンやラベルやテキストボックス等のウィジェットを配置していく。

冒頭で紹介したコードをFrameに変更してみたのがこちら。

import tkinter as tk

root = tk.Tk()
root.geometry("400x300")

header = tk.Frame(master=root, bg="pink")
footer = tk.Frame(master=root, bg="lightblue")
container1 = tk.Frame(master=root, bg="lightgreen")
container2 = tk.Frame(master=root, bg="khaki")
container3 = tk.Frame(master=root, bg="mediumpurple1")

header.pack(side="top", fill="both", expand=False)
footer.pack(side="bottom", fill="both", expand=False)
container1.pack(side="left", fill="both", expand=True)
container2.pack(side="left", fill="both", expand=True)
container3.pack(side="left", fill="both", expand=True)

root.mainloop()

実行してみると、あれ?ヘッダーとフッターが無い。。
f:id:t-hom:20220207232019p:plain

これは心配無用。ラベルと違ってテキスト等の中身が無いので、中に何か配置されるまではcontainer1~3のexpandに押しつぶされているだけ。

次のようにフレームをmasterにしてLabelをフレームに配置してやれば、フレームの中身の高さは最低限確保される。

import tkinter as tk

root = tk.Tk()
root.geometry("400x300")

# フレームを準備
header = tk.Frame(master=root, bg="pink")
footer = tk.Frame(master=root, bg="lightblue")
container1 = tk.Frame(master=root, bg="lightgreen")
container2 = tk.Frame(master=root, bg="khaki")
container3 = tk.Frame(master=root, bg="mediumpurple1")

# フレームレイアウト
header.pack(side="top", fill="both", expand=False)
footer.pack(side="bottom", fill="both", expand=False)
container1.pack(side="left", fill="both", expand=True)
container2.pack(side="left", fill="both", expand=True)
container3.pack(side="left", fill="both", expand=True)

# ヘッダー内レイアウト
title_label = tk.Label(master=header, text = "Sample App", bg=header["bg"])
title_label.pack()

# フッター内レイアウト
creator_label = tk.Label(master=footer, text = "Created by thom.", bg=footer["bg"])
creator_label.pack()

root.mainloop()

実行結果は次のとおり。
f:id:t-hom:20220207232947p:plain


以上がtkinterのpackについての解説である。
読んだだけでピンとこない人のために紹介したpackのレイアウトシミュレーターは以下で公開している。
github.com

packできるようになった後は

他のレイアウトについて

packの使い方が分かった後は、gridやplace等のレイアウトも使ってみると良いかと思う。
pack以外は比較的簡単なので、適当なサイトで調べればすぐ使いこなせると思う。

レイアウトメソッドはmasterごとに1種類のみ有効だ。
今回でいうと、rootをマスターにしたフレームの配置にはpackを使っているので、rootに配置するメソッドはpackに固定されてしまうが、その中に配置するフレーム内部ではまた別のレイアウトを選択できる。
よって、最近はpackでフレーム自体をレイアウトしてしまい、中身は場合に合わせてpackかgridを使い分けるといった感じ。

クラス化について

クラス化についてはpythonの公式ドキュメントでソースコードを確認できる。
docs.python.org

こちらについてはpythonの入門書(クラスの説明までカバーされているもの)を1冊読了してから挑戦してみると良いと思う。

tkinterによるUIプログラミングは規模が大きくなるにつれて散らかってくるので、クラスを使わないと手に負えなくなる。

先ほどのコードをクラス化したのがこちら。

import tkinter as tk

class Application(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        master.geometry("400x300")
        self.master = master
        self.pack()
        self.layout_frames()
        self.layout_header()
        self.layout_footer()

    def layout_frames(self):
        # Generate
        self.header = tk.Frame(master=self.master, bg="pink")
        self.footer = tk.Frame(master=self.master, bg="lightblue")
        self.container1 = tk.Frame(master=self.master, bg="lightgreen")
        self.container2 = tk.Frame(master=self.master, bg="khaki")
        self.container3 = tk.Frame(master=self.master, bg="mediumpurple1")

        # Layout
        self.header.pack(side="top", fill="both", expand=False)
        self.footer.pack(side="bottom", fill="both", expand=False)
        self.container1.pack(side="left", fill="both", expand=True)
        self.container2.pack(side="left", fill="both", expand=True)
        self.container3.pack(side="left", fill="both", expand=True)

    def layout_header(self):
        self.title_label = tk.Label(master=self.header, text = "Sample App", bg=self.header["bg"])
        self.title_label.pack()

    def layout_footer(self):
        self.creator_label = tk.Label(master=self.footer, text = "Created by thom.", bg=self.footer["bg"])
        self.creator_label.pack()

root = tk.Tk()
app = Application(master=root)
app.mainloop()

やたらとselfが付くのがまだ慣れないが、まだ私も勉強中なので非効率なことをしていたら申し訳ない。

画面遷移

画面遷移については過去の記事で紹介したのでリンクしておく。
thom.hateblo.jp

フレームを入れ子にして使うと中のフレームごと入れ替えることができるので、例えばヘッダーとフッターは入れ替えずに中身だけ画面遷移させるということもできる。

終わりに

Packレイアウトは最初難しく感じるかもしれないが、勝手が分かってしまえばシンプルで手軽な選択肢となる。
Gridレイアウトのようにマス目を意識しなくても良いし、Placeレイアウトのようにドット単位の座標を意識しなくても良い。
ただ詰め込むだけ。ポイントは詰め込む順番と占有領域を理解すること。

(注意) 今更だけど、今回の説明で使用した「占有領域」という言葉は私が勝手にそう呼んでいるだけなので、たぶん他のtkinter使いには通用しない。ただ、個人的にはそのように名付けると動作がイメージしやすかったので、もし気に入って他の人に説明する機会があれば使ってもらうと良いかと思う。

以上

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。