背景
最近想简单粗暴的用 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 并没有一种类似 事件冒泡
的机制,从叶子节点的组件开始向上传递。而只有 bind
,bind_all
, bind_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 原生提供的东西非常有限,但是可以借鉴很多其他的思路来进行扩展,从而满足我们的需求。