Tkinter 吐槽之二:Event 事件在子元素中共享

Libitum發表於2021-07-01

背景

最近想簡單粗暴的用 Python 寫一個 GUI 的小程式。因為 Tkinter 是 Python 自帶的 GUI 解決方案,為了部署方便,就直接選擇了 Tkinter。
本來覺得 GUI 發展這麼多年以來,就算功能簡陋,但也應該大差不差才對,何況我的需求本就十分簡單。但事實上 Tkinter 的簡陋仍然超出了我的想象。因此想寫幾篇文章,記錄一下踩到的坑,權當吐槽。

同系列的其它文章:
Tkinter 吐槽之一:多執行緒與 UI 互動

問題

在一些情況下,Tkinter 原生的元件因為過於簡單而無法滿足我們的需求,所以可能需要通過 Frame 自定義一些元件,並響應一些事件。

比如,自定義一個帶多個資訊的可點選的 Frame:

import tkinter as tk

class PersonFrame(tk.Frame):
    def __init__(self, master, name, age):
        super().__init__(master=master, width=100, borderwidth=2, padx=5)
        
        tk.Label(self, text=name).pack(side=tk.LEFT, fill=tk.X)
        tk.Label(self, text=age, foreground='red').pack(side=tk.RIGHT)

        self.bind('<Button-1>', self._on_click)

    def _on_click(self, _):
        print('clicked')

app = tk.Tk()
frame = PersonFrame(app, 'Tom', '27').pack(fill=tk.X)
frame = PersonFrame(app, 'Jerry', '16').pack(fill=tk.X)
app.mainloop()

這個例子看上去一切都很美好,通過自定義 Frame 的方式建立了一個元件,並且繫結了元件的 滑鼠單擊 事件。

但是經過實際測試發現,只有當滑鼠點選在 Frame 的空白位置時,才會觸發 on_click 的呼叫。如果滑鼠點在文字上,並不會有任何效果。這說明, bind 操作只對 Frame 本身生效,並不會對覆蓋在其上的子元素生效,即使邏輯上存在父子關係。
因此,這個自定義的元件沒有辦法真正意義上被當作可點選的元件使用。

方案

根本原因,在於 Tkinter 並沒有一種類似 事件冒泡 的機制,從葉子節點的元件開始向上傳遞。而只有 bindbind_allbind_class 這三種形式。很顯然,這三種形式都是無法滿足要求的。

那麼思路就演變為,有沒有可能把 Frame 及 Frame 的所有子元素,都繫結上同一個事件的方法呢?

def bind_recursively(widget: tk.Misc, event: str, callback: Callable):
    """Binds event recursively with it's all children. So a widget and all
    it's children will share one event and callback.

    """
    widget.bind(event, callback)
    for w in widget.children.values():
        bind_recursively(w, event, callback)

這段程式碼就是採用類似的思路,首先找到 widget 的所有子元素,然後以遞迴的方式繫結同一個事件的 callback。這樣可以保證一個 widget 中的所有子元素都可以響應同樣的事件。從而實現在哪裡點選都一致的效果。

總結

工程設計其實就是這樣,找到一些理論可行的方法,然後通過抽象、封裝的方式轉換成一個優雅的解決方案。 Tkinter 原生提供的東西非常有限,但是可以借鑑很多其他的思路來進行擴充套件,從而滿足我們的需求。

相關文章