第20章 Tkinter GUI开发
学习目标
完成本章学习后,读者应能够:
- 理解GUI编程模型:掌握事件驱动编程、主循环机制与控件层次结构
- 精通布局管理:灵活运用pack、grid、place三种布局管理器构建复杂界面
- 掌握核心控件:熟练使用ttk主题控件、表单控件与数据显示控件
- 实现事件处理:掌握事件绑定、回调机制与自定义事件
- 构建菜单系统:实现菜单栏、工具栏、右键菜单与快捷键
- 运用对话框:使用标准对话框与自定义对话框
- 实现MVC架构:在Tkinter中应用Model-View-Controller设计模式
- 掌握多线程GUI:解决GUI线程阻塞问题,实现异步任务处理
20.1 GUI编程基础
20.1.1 事件驱动编程模型
Tkinter基于Tcl/Tk工具包,采用事件驱动编程模型:
┌──────────────────────────────────┐
│ 主循环(mainloop) │
│ ┌────────────────────────────┐ │
│ │ 事件队列(Event Queue) │ │
│ │ ┌──────┐ ┌──────┐ │ │
│ │ │鼠标 │ │键盘 │ ... │ │
│ │ └──┬───┘ └──┬───┘ │ │
│ └─────┼────────┼───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────┐ │
│ │ 事件分发器(Dispatcher) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 回调函数(Callback) │ │
│ └────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 控件更新(Widget Update)│ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘20.1.2 应用程序结构
python
import tkinter as tk
from tkinter import ttk
class Application(tk.Tk):
def __init__(self):
super().__init__()
self.title("My Application")
self.geometry("800x600")
self.minsize(600, 400)
self._configure_styles()
self._create_menu()
self._create_widgets()
self._create_statusbar()
def _configure_styles(self):
style = ttk.Style(self)
style.theme_use("clam")
def _create_menu(self):
menubar = tk.Menu(self)
file_menu = tk.Menu(menubar, tearoff=False)
file_menu.add_command(label="新建", accelerator="Ctrl+N", command=self.on_new)
file_menu.add_command(label="打开", accelerator="Ctrl+O", command=self.on_open)
file_menu.add_separator()
file_menu.add_command(label="退出", accelerator="Ctrl+Q", command=self.quit)
menubar.add_cascade(label="文件", menu=file_menu)
self.config(menu=menubar)
self.bind_all("<Control-n>", lambda e: self.on_new())
self.bind_all("<Control-o>", lambda e: self.on_open())
self.bind_all("<Control-q>", lambda e: self.quit())
def _create_widgets(self):
main_frame = ttk.Frame(self)
main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
def _create_statusbar(self):
self.status_var = tk.StringVar(value="就绪")
statusbar = ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN)
statusbar.pack(fill=tk.X, side=tk.BOTTOM)
def on_new(self):
self.status_var.set("新建文件")
def on_open(self):
self.status_var.set("打开文件")
if __name__ == "__main__":
app = Application()
app.mainloop()20.2 布局管理
20.2.1 Grid布局详解
Grid是最常用的布局管理器,将容器划分为行和列的网格:
python
import tkinter as tk
from tkinter import ttk
class LoginForm(tk.Tk):
def __init__(self):
super().__init__()
self.title("用户登录")
self.geometry("400x250")
self.resizable(False, False)
main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)
main_frame.columnconfigure(1, weight=1)
main_frame.rowconfigure(3, weight=1)
ttk.Label(main_frame, text="用户名:").grid(
row=0, column=0, sticky=tk.W, pady=(0, 10)
)
self.username_var = tk.StringVar()
ttk.Entry(main_frame, textvariable=self.username_var).grid(
row=0, column=1, sticky=tk.EW, pady=(0, 10), padx=(10, 0)
)
ttk.Label(main_frame, text="密码:").grid(
row=1, column=0, sticky=tk.W, pady=(0, 10)
)
self.password_var = tk.StringVar()
ttk.Entry(main_frame, textvariable=self.password_var, show="●").grid(
row=1, column=1, sticky=tk.EW, pady=(0, 10), padx=(10, 0)
)
self.remember_var = tk.BooleanVar()
ttk.Checkbutton(main_frame, text="记住密码", variable=self.remember_var).grid(
row=2, column=1, sticky=tk.W, padx=(10, 0)
)
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=3, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="登录", command=self.on_login, width=12).pack(
side=tk.LEFT, padx=(0, 10)
)
ttk.Button(btn_frame, text="取消", command=self.destroy, width=12).pack(
side=tk.LEFT
)
self.bind("<Return>", lambda e: self.on_login())
def on_login(self):
username = self.username_var.get()
password = self.password_var.get()
if username and password:
print(f"Login: {username}")
else:
from tkinter import messagebox
messagebox.showwarning("提示", "请输入用户名和密码")
if __name__ == "__main__":
app = LoginForm()
app.mainloop()20.2.2 响应式布局
python
class ResponsiveLayout(tk.Tk):
def __init__(self):
super().__init__()
self.title("响应式布局")
self.geometry("800x500")
self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=3)
self.rowconfigure(0, weight=1)
sidebar = ttk.Frame(self, relief=tk.RIDGE, borderwidth=1)
sidebar.grid(row=0, column=0, sticky=tk.NSEW, padx=(5, 2), pady=5)
content = ttk.Frame(self, relief=tk.RIDGE, borderwidth=1)
content.grid(row=0, column=1, sticky=tk.NSEW, padx=(2, 5), pady=5)
for i, item in enumerate(["仪表盘", "文章管理", "用户管理", "系统设置"]):
ttk.Button(
sidebar, text=item,
command=lambda t=item: self.on_nav(t),
).pack(fill=tk.X, padx=5, pady=2)
content.columnconfigure(0, weight=1)
content.rowconfigure(0, weight=1)
self.content_text = tk.Text(content, wrap=tk.WORD)
self.content_text.grid(row=0, column=0, sticky=tk.NSEW, padx=5, pady=5)
scrollbar = ttk.Scrollbar(content, orient=tk.VERTICAL, command=self.content_text.yview)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
self.content_text.config(yscrollcommand=scrollbar.set)
def on_nav(self, item):
self.content_text.delete("1.0", tk.END)
self.content_text.insert("1.0", f"当前页面: {item}")20.3 核心控件
20.3.1 ttk主题控件
ttk(Themed Tkinter)提供更现代的控件外观:
python
import tkinter as tk
from tkinter import ttk
class WidgetShowcase(tk.Tk):
def __init__(self):
super().__init__()
self.title("控件展示")
self.geometry("700x500")
notebook = ttk.Notebook(self)
notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
input_frame = ttk.Frame(notebook, padding=10)
notebook.add(input_frame, text="输入控件")
self._create_input_widgets(input_frame)
display_frame = ttk.Frame(notebook, padding=10)
notebook.add(display_frame, text="显示控件")
self._create_display_widgets(display_frame)
selection_frame = ttk.Frame(notebook, padding=10)
notebook.add(selection_frame, text="选择控件")
self._create_selection_widgets(selection_frame)
def _create_input_widgets(self, parent):
parent.columnconfigure(1, weight=1)
ttk.Label(parent, text="文本输入:").grid(row=0, column=0, sticky=tk.W, pady=5)
self.entry_var = tk.StringVar()
entry = ttk.Entry(parent, textvariable=self.entry_var)
entry.grid(row=0, column=1, sticky=tk.EW, pady=5, padx=(10, 0))
ttk.Label(parent, text="多行文本:").grid(row=1, column=0, sticky=tk.NW, pady=5)
text_frame = ttk.Frame(parent)
text_frame.grid(row=1, column=1, sticky=tk.NSEW, pady=5, padx=(10, 0))
parent.rowconfigure(1, weight=1)
text = tk.Text(text_frame, wrap=tk.WORD, height=8)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=text.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
text.config(yscrollcommand=scrollbar.set)
ttk.Label(parent, text="数值输入:").grid(row=2, column=0, sticky=tk.W, pady=5)
spinbox = ttk.Spinbox(parent, from_=0, to=100, width=10)
spinbox.grid(row=2, column=1, sticky=tk.W, pady=5, padx=(10, 0))
def _create_display_widgets(self, parent):
tree = ttk.Treeview(
parent,
columns=("name", "age", "email"),
show="headings",
height=10,
)
tree.heading("name", text="姓名")
tree.heading("age", text="年龄")
tree.heading("email", text="邮箱")
tree.column("name", width=120)
tree.column("age", width=80, anchor=tk.CENTER)
tree.column("email", width=200)
data = [
("张三", 28, "zhangsan@example.com"),
("李四", 35, "lisi@example.com"),
("王五", 22, "wangwu@example.com"),
]
for item in data:
tree.insert("", tk.END, values=item)
tree.pack(fill=tk.BOTH, expand=True)
def _create_selection_widgets(self, parent):
self.combo_var = tk.StringVar()
ttk.Label(parent, text="下拉选择:").pack(anchor=tk.W, pady=(0, 5))
combobox = ttk.Combobox(
parent, textvariable=self.combo_var,
values=["Python", "Java", "Go", "Rust"],
state="readonly",
)
combobox.pack(fill=tk.X, pady=(0, 15))
combobox.set("Python")
ttk.Label(parent, text="单选:").pack(anchor=tk.W, pady=(0, 5))
self.radio_var = tk.StringVar(value="beginner")
for text, value in [("初级", "beginner"), ("中级", "intermediate"), ("高级", "advanced")]:
ttk.Radiobutton(parent, text=text, variable=self.radio_var, value=value).pack(
anchor=tk.W, padx=20
)
ttk.Label(parent, text="多选:").pack(anchor=tk.W, pady=(10, 5))
self.check_vars = {}
for item in ["Python", "JavaScript", "Go"]:
var = tk.BooleanVar()
self.check_vars[item] = var
ttk.Checkbutton(parent, text=item, variable=var).pack(anchor=tk.W, padx=20)
if __name__ == "__main__":
app = WidgetShowcase()
app.mainloop()20.3.2 Treeview数据表格
python
class DataTable(tk.Tk):
def __init__(self):
super().__init__()
self.title("数据表格")
self.geometry("800x500")
toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)
ttk.Button(toolbar, text="添加", command=self.add_item).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="删除", command=self.delete_item).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="刷新", command=self.refresh_data).pack(side=tk.LEFT, padx=2)
columns = ("id", "name", "category", "price", "stock", "status")
self.tree = ttk.Treeview(self, columns=columns, show="headings", selectmode="extended")
for col, heading, width, anchor in [
("id", "ID", 60, tk.CENTER),
("name", "名称", 150, tk.W),
("category", "分类", 100, tk.CENTER),
("price", "价格", 80, tk.E),
("stock", "库存", 80, tk.CENTER),
("status", "状态", 80, tk.CENTER),
]:
self.tree.heading(col, text=heading, command=lambda c=col: self.sort_by(c))
self.tree.column(col, width=width, anchor=anchor)
vsb = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
hsb = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=1, column=0, sticky=tk.NSEW, padx=5)
vsb.grid(row=1, column=1, sticky=tk.NS)
hsb.grid(row=2, column=0, sticky=tk.EW)
self.grid_rowconfigure(1, weight=1)
self.grid_columnconfigure(0, weight=1)
self.tree.bind("<Double-1>", self.on_double_click)
self.sort_reverse = False
self.refresh_data()
def refresh_data(self):
for item in self.tree.get_children():
self.tree.delete(item)
products = [
(1, "Python编程", "书籍", 89.00, 150, "在售"),
(2, "机械键盘", "外设", 399.00, 45, "在售"),
(3, "显示器", "外设", 2499.00, 0, "缺货"),
]
for product in products:
self.tree.insert("", tk.END, values=product)
def add_item(self):
dialog = ProductDialog(self)
self.wait_window(dialog)
if dialog.result:
self.tree.insert("", tk.END, values=dialog.result)
def delete_item(self):
for item in self.tree.selection():
self.tree.delete(item)
def sort_by(self, column):
items = [(self.tree.set(k, column), k) for k in self.tree.get_children("")]
items.sort(reverse=self.sort_reverse)
for index, (_, k) in enumerate(items):
self.tree.move(k, "", index)
self.sort_reverse = not self.sort_reverse
def on_double_click(self, event):
item = self.tree.identify_row(event.y)
if item:
values = self.tree.item(item, "values")
print(f"编辑: {values}")
class ProductDialog(tk.Toplevel):
def __init__(self, parent):
super().__init__(parent)
self.title("添加商品")
self.geometry("400x300")
self.transient(parent)
self.grab_set()
self.result = None
frame = ttk.Frame(self, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
self.vars = {}
for i, (label, key) in enumerate([
("名称", "name"), ("分类", "category"),
("价格", "price"), ("库存", "stock"),
]):
ttk.Label(frame, text=f"{label}:").grid(row=i, column=0, sticky=tk.W, pady=5)
var = tk.StringVar()
self.vars[key] = var
ttk.Entry(frame, textvariable=var).grid(
row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0)
)
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=4, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5)
def on_ok(self):
try:
self.result = (
0,
self.vars["name"].get(),
self.vars["category"].get(),
float(self.vars["price"].get()),
int(self.vars["stock"].get()),
"在售",
)
self.destroy()
except ValueError:
from tkinter import messagebox
messagebox.showerror("错误", "请输入有效的数值", parent=self)
if __name__ == "__main__":
app = DataTable()
app.mainloop()20.4 事件处理
20.4.1 事件绑定机制
python
import tkinter as tk
class EventDemo(tk.Tk):
def __init__(self):
super().__init__()
self.title("事件处理演示")
self.geometry("500x400")
self.canvas = tk.Canvas(self, bg="white", cursor="crosshair")
self.canvas.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.canvas.bind("<Button-1>", self.on_left_click)
self.canvas.bind("<Button-3>", self.on_right_click)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<MouseWheel>", self.on_scroll)
self.canvas.bind("<Double-Button-1>", self.on_double_click)
self.bind("<Key>", self.on_key)
self.bind("<Control-z>", self.on_undo)
self.bind("<Control-s>", self.on_save)
self.items = []
self.current_item = None
def on_left_click(self, event):
item = self.canvas.create_oval(
event.x - 5, event.y - 5, event.x + 5, event.y + 5,
fill="blue",
)
self.items.append(item)
def on_right_click(self, event):
menu = tk.Menu(self, tearoff=False)
menu.add_command(label="清除画布", command=self.clear_canvas)
menu.add_command(label="撤销", command=self.undo)
menu.post(event.x_root, event.y_root)
def on_drag(self, event):
if self.current_item:
self.canvas.coords(
self.current_item,
event.x - 5, event.y - 5, event.x + 5, event.y + 5,
)
def on_scroll(self, event):
scale = 1.1 if event.delta > 0 else 0.9
self.canvas.scale(tk.ALL, event.x, event.y, scale, scale)
def on_double_click(self, event):
item = self.canvas.create_text(
event.x, event.y, text="双击!", fill="red", font=("Arial", 14),
)
self.items.append(item)
def on_key(self, event):
print(f"按键: {event.keysym} (char={event.char}, keycode={event.keycode})")
def on_undo(self, event):
self.undo()
def on_save(self, event):
print("保存操作")
def undo(self):
if self.items:
self.canvas.delete(self.items.pop())
def clear_canvas(self):
self.canvas.delete(tk.ALL)
self.items.clear()
if __name__ == "__main__":
app = EventDemo()
app.mainloop()20.5 菜单与工具栏
python
import tkinter as tk
from tkinter import ttk, messagebox
class MenuApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("菜单与工具栏")
self.geometry("800x600")
self._create_menubar()
self._create_toolbar()
self._create_content()
def _create_menubar(self):
menubar = tk.Menu(self)
file_menu = tk.Menu(menubar, tearoff=False)
file_menu.add_command(label="新建", accelerator="Ctrl+N", command=self.on_new)
file_menu.add_command(label="打开", accelerator="Ctrl+O", command=self.on_open)
file_menu.add_command(label="保存", accelerator="Ctrl+S", command=self.on_save)
file_menu.add_separator()
recent_menu = tk.Menu(file_menu, tearoff=False)
recent_menu.add_command(label="最近文件1", command=lambda: print("Recent 1"))
recent_menu.add_command(label="最近文件2", command=lambda: print("Recent 2"))
file_menu.add_cascade(label="最近打开", menu=recent_menu)
file_menu.add_separator()
file_menu.add_command(label="退出", accelerator="Alt+F4", command=self.on_exit)
menubar.add_cascade(label="文件", menu=file_menu)
edit_menu = tk.Menu(menubar, tearoff=False)
edit_menu.add_command(label="撤销", accelerator="Ctrl+Z", command=self.on_undo)
edit_menu.add_command(label="重做", accelerator="Ctrl+Y", command=self.on_redo)
edit_menu.add_separator()
edit_menu.add_command(label="剪切", accelerator="Ctrl+X", command=self.on_cut)
edit_menu.add_command(label="复制", accelerator="Ctrl+C", command=self.on_copy)
edit_menu.add_command(label="粘贴", accelerator="Ctrl+V", command=self.on_paste)
menubar.add_cascade(label="编辑", menu=edit_menu)
view_menu = tk.Menu(menubar, tearoff=False)
self.show_toolbar_var = tk.BooleanVar(value=True)
self.show_statusbar_var = tk.BooleanVar(value=True)
view_menu.add_checkbutton(label="工具栏", variable=self.show_toolbar_var, command=self.toggle_toolbar)
view_menu.add_checkbutton(label="状态栏", variable=self.show_statusbar_var, command=self.toggle_statusbar)
menubar.add_cascade(label="视图", menu=view_menu)
help_menu = tk.Menu(menubar, tearoff=False)
help_menu.add_command(label="关于", command=self.on_about)
menubar.add_cascade(label="帮助", menu=help_menu)
self.config(menu=menubar)
def _create_toolbar(self):
self.toolbar = ttk.Frame(self, relief=tk.RAISED)
self.toolbar.pack(fill=tk.X, padx=2, pady=2)
for text, command in [
("新建", self.on_new), ("打开", self.on_open), ("保存", self.on_save),
]:
ttk.Button(self.toolbar, text=text, command=command).pack(side=tk.LEFT, padx=1)
ttk.Separator(self.toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=2)
for text, command in [
("撤销", self.on_undo), ("重做", self.on_redo),
]:
ttk.Button(self.toolbar, text=text, command=command).pack(side=tk.LEFT, padx=1)
def _create_content(self):
self.text = tk.Text(self, wrap=tk.WORD, undo=True)
self.text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.status_var = tk.StringVar(value="就绪")
self.statusbar = ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
self.statusbar.pack(fill=tk.X)
def toggle_toolbar(self):
if self.show_toolbar_var.get():
self.toolbar.pack(fill=tk.X, padx=2, pady=2, before=self.text)
else:
self.toolbar.pack_forget()
def toggle_statusbar(self):
if self.show_statusbar_var.get():
self.statusbar.pack(fill=tk.X)
else:
self.statusbar.pack_forget()
def on_new(self): self.text.delete("1.0", tk.END); self.status_var.set("新建文件")
def on_open(self): self.status_var.set("打开文件")
def on_save(self): self.status_var.set("文件已保存")
def on_exit(self): self.quit()
def on_undo(self): self.text.event_generate("<<Undo>>")
def on_redo(self): self.text.event_generate("<<Redo>>")
def on_cut(self): self.text.event_generate("<<Cut>>")
def on_copy(self): self.text.event_generate("<<Copy>>")
def on_paste(self): self.text.event_generate("<<Paste>>")
def on_about(self): messagebox.showinfo("关于", "Tkinter应用示例 v1.0")
if __name__ == "__main__":
app = MenuApp()
app.mainloop()20.6 MVC架构实践
20.6.1 MVC模式实现
python
import tkinter as tk
from tkinter import ttk, messagebox
from dataclasses import dataclass, field
from typing import Optional
import json
import os
@dataclass
class Contact:
name: str
phone: str
email: str = ""
group: str = "默认"
id: Optional[int] = field(default=None)
class ContactModel:
def __init__(self):
self.contacts: list[Contact] = []
self._next_id = 1
self._observers = []
def add_observer(self, callback):
self._observers.append(callback)
def notify_observers(self):
for callback in self._observers:
callback()
def add_contact(self, contact: Contact) -> Contact:
contact.id = self._next_id
self._next_id += 1
self.contacts.append(contact)
self.notify_observers()
return contact
def update_contact(self, contact_id: int, **kwargs) -> Optional[Contact]:
for contact in self.contacts:
if contact.id == contact_id:
for key, value in kwargs.items():
if hasattr(contact, key):
setattr(contact, key, value)
self.notify_observers()
return contact
return None
def delete_contact(self, contact_id: int) -> bool:
for i, contact in enumerate(self.contacts):
if contact.id == contact_id:
self.contacts.pop(i)
self.notify_observers()
return True
return False
def search(self, keyword: str) -> list[Contact]:
keyword = keyword.lower()
return [
c for c in self.contacts
if keyword in c.name.lower() or keyword in c.phone or keyword in c.email.lower()
]
def get_by_group(self, group: str) -> list[Contact]:
if group == "全部":
return self.contacts
return [c for c in self.contacts if c.group == group]
def get_groups(self) -> list[str]:
groups = {"全部", "默认"}
groups.update(c.group for c in self.contacts)
return sorted(groups)
def save_to_file(self, filepath: str):
data = [{"id": c.id, "name": c.name, "phone": c.phone, "email": c.email, "group": c.group} for c in self.contacts]
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_from_file(self, filepath: str):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
self.contacts.clear()
for item in data:
self.contacts.append(Contact(**item))
self._next_id = max((c.id for c in self.contacts), default=0) + 1
self.notify_observers()
class ContactView(tk.Tk):
def __init__(self):
super().__init__()
self.title("通讯录管理")
self.geometry("700x500")
self.minsize(600, 400)
self.controller: Optional["ContactController"] = None
self._create_widgets()
def set_controller(self, controller: "ContactController"):
self.controller = controller
def _create_widgets(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(toolbar, text="搜索:").pack(side=tk.LEFT, padx=(0, 5))
self.search_var = tk.StringVar()
self.search_var.trace_add("write", lambda *_: self.on_search())
ttk.Entry(toolbar, textvariable=self.search_var, width=20).pack(side=tk.LEFT, padx=(0, 10))
ttk.Label(toolbar, text="分组:").pack(side=tk.LEFT, padx=(0, 5))
self.group_var = tk.StringVar(value="全部")
self.group_combo = ttk.Combobox(toolbar, textvariable=self.group_var, state="readonly", width=10)
self.group_combo.pack(side=tk.LEFT, padx=(0, 10))
self.group_combo.bind("<<ComboboxSelected>>", lambda e: self.on_group_change())
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5)
ttk.Button(toolbar, text="添加", command=self.on_add).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="编辑", command=self.on_edit).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="删除", command=self.on_delete).pack(side=tk.LEFT, padx=2)
columns = ("name", "phone", "email", "group")
self.tree = ttk.Treeview(self, columns=columns, show="headings", selectmode="browse")
for col, heading, width in [
("name", "姓名", 150), ("phone", "电话", 130),
("email", "邮箱", 200), ("group", "分组", 100),
]:
self.tree.heading(col, text=heading)
self.tree.column(col, width=width)
vsb = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0), pady=5)
vsb.pack(side=tk.RIGHT, fill=tk.Y, padx=(0, 5), pady=5)
self.tree.bind("<Double-1>", lambda e: self.on_edit())
self.status_var = tk.StringVar(value="就绪")
ttk.Label(self, textvariable=self.status_var, relief=tk.SUNKEN).pack(fill=tk.X, side=tk.BOTTOM)
def update_contact_list(self, contacts: list[Contact]):
for item in self.tree.get_children():
self.tree.delete(item)
for contact in contacts:
self.tree.insert("", tk.END, iid=str(contact.id), values=(
contact.name, contact.phone, contact.email, contact.group,
))
self.status_var.set(f"共 {len(contacts)} 条记录")
def update_groups(self, groups: list[str]):
self.group_combo["values"] = groups
def on_search(self):
if self.controller:
self.controller.search(self.search_var.get())
def on_group_change(self):
if self.controller:
self.controller.filter_by_group(self.group_var.get())
def on_add(self):
if self.controller:
self.controller.add_contact()
def on_edit(self):
if self.controller:
selection = self.tree.selection()
if selection:
self.controller.edit_contact(int(selection[0]))
def on_delete(self):
if self.controller:
selection = self.tree.selection()
if selection:
self.controller.delete_contact(int(selection[0]))
class ContactController:
def __init__(self, model: ContactModel, view: ContactView):
self.model = model
self.view = view
self.view.set_controller(self)
self.model.add_observer(self.refresh_view)
self.refresh_view()
def refresh_view(self):
contacts = self.model.get_by_group(self.view.group_var.get())
self.view.update_contact_list(contacts)
self.view.update_groups(self.model.get_groups())
def search(self, keyword: str):
if keyword:
contacts = self.model.search(keyword)
else:
contacts = self.model.get_by_group(self.view.group_var.get())
self.view.update_contact_list(contacts)
def filter_by_group(self, group: str):
contacts = self.model.get_by_group(group)
self.view.update_contact_list(contacts)
def add_contact(self):
dialog = ContactDialog(self.view, title="添加联系人")
self.view.wait_window(dialog)
if dialog.result:
self.model.add_contact(Contact(**dialog.result))
def edit_contact(self, contact_id: int):
contact = next((c for c in self.model.contacts if c.id == contact_id), None)
if contact:
dialog = ContactDialog(
self.view, title="编辑联系人",
initial_data={"name": contact.name, "phone": contact.phone, "email": contact.email, "group": contact.group},
)
self.view.wait_window(dialog)
if dialog.result:
self.model.update_contact(contact_id, **dialog.result)
def delete_contact(self, contact_id: int):
if messagebox.askyesno("确认", "确定删除此联系人?"):
self.model.delete_contact(contact_id)
class ContactDialog(tk.Toplevel):
def __init__(self, parent, title="联系人", initial_data=None):
super().__init__(parent)
self.title(title)
self.geometry("400x300")
self.transient(parent)
self.grab_set()
self.result = None
frame = ttk.Frame(self, padding=20)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
self.vars = {}
for i, (label, key) in enumerate([
("姓名", "name"), ("电话", "phone"), ("邮箱", "email"), ("分组", "group"),
]):
ttk.Label(frame, text=f"{label}:").grid(row=i, column=0, sticky=tk.W, pady=5)
var = tk.StringVar(value=initial_data.get(key, "") if initial_data else "")
self.vars[key] = var
if key == "group":
ttk.Entry(frame, textvariable=var).grid(row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0))
else:
ttk.Entry(frame, textvariable=var).grid(row=i, column=1, sticky=tk.EW, pady=5, padx=(10, 0))
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=4, column=0, columnspan=2, pady=(20, 0))
ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5)
def on_ok(self):
name = self.vars["name"].get().strip()
phone = self.vars["phone"].get().strip()
if not name or not phone:
messagebox.showwarning("提示", "姓名和电话不能为空", parent=self)
return
self.result = {
"name": name, "phone": phone,
"email": self.vars["email"].get().strip(),
"group": self.vars["group"].get().strip() or "默认",
}
self.destroy()
if __name__ == "__main__":
model = ContactModel()
model.add_contact(Contact(name="张三", phone="13800138001", email="zhangsan@example.com", group="工作"))
model.add_contact(Contact(name="李四", phone="13800138002", email="lisi@example.com", group="朋友"))
view = ContactView()
controller = ContactController(model, view)
view.mainloop()20.7 多线程与异步
20.7.1 后台任务处理
GUI主线程不能执行耗时操作,否则界面会冻结。需要使用线程处理后台任务:
python
import tkinter as tk
from tkinter import ttk
import threading
import queue
import time
class AsyncApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("异步任务演示")
self.geometry("500x300")
self.task_queue = queue.Queue()
self._poll_queue()
main_frame = ttk.Frame(self, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)
self.progress = ttk.Progressbar(main_frame, mode="determinate")
self.progress.pack(fill=tk.X, pady=(0, 10))
self.status_var = tk.StringVar(value="就绪")
ttk.Label(main_frame, textvariable=self.status_var).pack(anchor=tk.W)
self.result_var = tk.StringVar()
ttk.Label(main_frame, textvariable=self.result_var, wraplength=400).pack(
anchor=tk.W, pady=(10, 0)
)
btn_frame = ttk.Frame(main_frame)
btn_frame.pack(pady=(20, 0))
self.start_btn = ttk.Button(btn_frame, text="开始任务", command=self.start_task)
self.start_btn.pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=self.cancel_task).pack(side=tk.LEFT, padx=5)
self._cancel_flag = False
def start_task(self):
self._cancel_flag = False
self.start_btn.config(state=tk.DISABLED)
self.progress["value"] = 0
self.status_var.set("任务执行中...")
thread = threading.Thread(target=self._background_task, daemon=True)
thread.start()
def _background_task(self):
total = 100
for i in range(total + 1):
if self._cancel_flag:
self.task_queue.put(("cancelled", None))
return
time.sleep(0.05)
self.task_queue.put(("progress", i))
self.task_queue.put(("completed", f"任务完成!处理了 {total} 个项目"))
def _poll_queue(self):
try:
while True:
event_type, data = self.task_queue.get_nowait()
if event_type == "progress":
self.progress["value"] = data
self.status_var.set(f"进度: {data}%")
elif event_type == "completed":
self.result_var.set(data)
self.status_var.set("任务完成")
self.start_btn.config(state=tk.NORMAL)
elif event_type == "cancelled":
self.status_var.set("任务已取消")
self.start_btn.config(state=tk.NORMAL)
except queue.Empty:
pass
self.after(100, self._poll_queue)
def cancel_task(self):
self._cancel_flag = True
if __name__ == "__main__":
app = AsyncApp()
app.mainloop()20.8 Canvas绘图
python
import tkinter as tk
from tkinter import ttk, colorchooser
class DrawingApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("画图程序")
self.geometry("900x600")
self.current_tool = "pen"
self.current_color = "#000000"
self.line_width = 2
self._last_x = None
self._last_y = None
self._items = []
self._create_toolbar()
self._create_canvas()
def _create_toolbar(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(toolbar, text="工具:").pack(side=tk.LEFT, padx=(0, 5))
self.tool_var = tk.StringVar(value="pen")
for text, value in [("画笔", "pen"), ("直线", "line"), ("矩形", "rect"), ("椭圆", "oval"), ("橡皮", "eraser")]:
ttk.Radiobutton(toolbar, text=text, variable=self.tool_var, value=value).pack(side=tk.LEFT, padx=2)
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
self.color_btn = tk.Button(toolbar, bg=self.current_color, width=3, command=self.choose_color)
self.color_btn.pack(side=tk.LEFT, padx=5)
ttk.Label(toolbar, text="颜色").pack(side=tk.LEFT)
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
ttk.Label(toolbar, text="线宽:").pack(side=tk.LEFT)
self.width_var = tk.IntVar(value=2)
ttk.Spinbox(toolbar, from_=1, to=20, textvariable=self.width_var, width=5).pack(side=tk.LEFT, padx=5)
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10)
ttk.Button(toolbar, text="撤销", command=self.undo).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="清空", command=self.clear).pack(side=tk.LEFT, padx=2)
def _create_canvas(self):
self.canvas = tk.Canvas(self, bg="white", cursor="crosshair")
self.canvas.pack(fill=tk.BOTH, expand=True, padx=5, pady=(0, 5))
self.canvas.bind("<Button-1>", self.on_press)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_release)
def choose_color(self):
color = colorchooser.askcolor(self.current_color, title="选择颜色")
if color[1]:
self.current_color = color[1]
self.color_btn.config(bg=self.current_color)
def on_press(self, event):
self._last_x = event.x
self._last_y = event.y
self._start_x = event.x
self._start_y = event.y
if self.tool_var.get() == "pen" or self.tool_var.get() == "eraser":
color = "white" if self.tool_var.get() == "eraser" else self.current_color
width = self.width_var.get() * 3 if self.tool_var.get() == "eraser" else self.width_var.get()
item = self.canvas.create_line(
event.x, event.y, event.x + 1, event.y + 1,
fill=color, width=width, capstyle=tk.ROUND, smooth=True,
)
self._items.append(item)
def on_drag(self, event):
tool = self.tool_var.get()
if tool in ("pen", "eraser"):
color = "white" if tool == "eraser" else self.current_color
width = self.width_var.get() * 3 if tool == "eraser" else self.width_var.get()
item = self.canvas.create_line(
self._last_x, self._last_y, event.x, event.y,
fill=color, width=width, capstyle=tk.ROUND, smooth=True,
)
self._items.append(item)
elif tool in ("line", "rect", "oval"):
if hasattr(self, "_preview_item"):
self.canvas.delete(self._preview_item)
if tool == "line":
self._preview_item = self.canvas.create_line(
self._start_x, self._start_y, event.x, event.y,
fill=self.current_color, width=self.width_var.get(), dash=(4, 4),
)
elif tool == "rect":
self._preview_item = self.canvas.create_rectangle(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(), dash=(4, 4),
)
elif tool == "oval":
self._preview_item = self.canvas.create_oval(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(), dash=(4, 4),
)
self._last_x = event.x
self._last_y = event.y
def on_release(self, event):
tool = self.tool_var.get()
if hasattr(self, "_preview_item"):
self.canvas.delete(self._preview_item)
del self._preview_item
if tool == "line":
item = self.canvas.create_line(
self._start_x, self._start_y, event.x, event.y,
fill=self.current_color, width=self.width_var.get(),
)
self._items.append(item)
elif tool == "rect":
item = self.canvas.create_rectangle(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(),
)
self._items.append(item)
elif tool == "oval":
item = self.canvas.create_oval(
self._start_x, self._start_y, event.x, event.y,
outline=self.current_color, width=self.width_var.get(),
)
self._items.append(item)
def undo(self):
if self._items:
self.canvas.delete(self._items.pop())
def clear(self):
self.canvas.delete(tk.ALL)
self._items.clear()
if __name__ == "__main__":
app = DrawingApp()
app.mainloop()20.9 前沿技术动态
20.9.1 Tkinter与现代Python
- CustomTkinter:基于Tkinter的现代UI库,提供圆角控件、暗色主题
- tkinterdnd2:支持拖放操作
- sv_ttk:Sun Valley主题,模拟Windows 11风格
python
import customtkinter as ctk
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
app = ctk.CTk()
app.geometry("400x300")
frame = ctk.CTkFrame(app)
frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
entry = ctk.CTkEntry(frame, placeholder_text="输入文本...")
entry.pack(pady=(20, 10))
button = ctk.CTkButton(frame, text="点击我", command=lambda: print(entry.get()))
button.pack(pady=10)
app.mainloop()20.9.2 Tkinter与数据可视化
python
import tkinter as tk
from tkinter import ttk
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
class ChartApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("数据可视化")
self.geometry("800x600")
self.fig = Figure(figsize=(8, 5), dpi=100)
self.ax = self.fig.add_subplot(111)
self.canvas = FigureCanvasTkAgg(self.fig, master=self)
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
toolbar = NavigationToolbar2Tk(self.canvas, self)
toolbar.update()
toolbar.pack(side=tk.BOTTOM, fill=tk.X)
self.plot_data()
def plot_data(self):
import numpy as np
x = np.linspace(0, 10, 100)
self.ax.plot(x, np.sin(x), label="sin(x)")
self.ax.plot(x, np.cos(x), label="cos(x)")
self.ax.set_title("三角函数")
self.ax.legend()
self.ax.grid(True)
self.canvas.draw()
if __name__ == "__main__":
app = ChartApp()
app.mainloop()20.10 本章小结
本章系统阐述了Tkinter GUI开发的核心知识体系:
- GUI编程模型:事件驱动编程、主循环机制与控件层次结构
- 布局管理:Grid响应式布局、Frame嵌套与权重分配
- 核心控件:ttk主题控件、Treeview数据表格与表单控件
- 事件处理:事件绑定、回调机制与自定义事件
- 菜单系统:菜单栏、工具栏、右键菜单与快捷键绑定
- MVC架构:Model-View-Controller在Tkinter中的实践
- 多线程GUI:队列轮询机制解决GUI线程阻塞问题
- Canvas绘图:绘图工具、图形操作与交互式画板
- 数据可视化:Matplotlib与Tkinter的集成
20.11 习题与项目练习
基础题
创建一个登录窗口,包含用户名、密码输入框和登录按钮,实现基本的表单验证。
使用Grid布局创建一个计算器界面,支持加减乘除四则运算。
实现一个简单的文本编辑器,包含新建、打开、保存功能和基本的编辑操作。
进阶题
使用MVC架构实现一个学生成绩管理系统,包含成绩录入、查询、统计和图表展示功能。
实现一个多线程文件搜索工具,搜索过程中界面保持响应,实时显示搜索进度和结果。
使用Canvas实现一个简单的流程图编辑器,支持节点的拖拽、连线和删除操作。
综合项目
个人财务管理应用:构建一个完整的桌面财务管理应用,包含:
- 收支记录管理(增删改查)
- 分类统计与饼图展示
- 月度/年度报表
- 数据导入/导出(CSV、JSON)
- 预算管理与预警
- MVC架构与数据持久化
Markdown编辑器:构建一个Markdown编辑器,包含:
- 实时预览
- 语法高亮
- 文件管理(新建、打开、保存)
- 工具栏快捷操作
- 主题切换(亮色/暗色)
- 导出HTML/PDF
思考题
Tkinter的
after()方法与Python的threading.Timer在GUI定时任务中有何区别?为什么在GUI编程中应优先使用after()?在MVC架构中,Model如何实现观察者模式以通知View更新?请比较回调函数、事件绑定和变量追踪(trace)三种机制的优劣。
20.12 延伸阅读
20.12.1 Tkinter官方资源
- Tkinter官方文档 (https://docs.python.org/3/library/tkinter.html) — Python标准库文档
- Tk命令手册 (https://www.tcl.tk/man/tcl8.6/TkCmd/contents.htm) — Tcl/Tk原始文档
- Tkinter参考 (https://tkdocs.com/) — 现代Tkinter教程
20.12.2 现代Tkinter扩展
- CustomTkinter (https://github.com/TomSchimansky/CustomTkinter) — 现代UI组件
- ttkbootstrap (https://ttkbootstrap.readthedocs.io/) — Bootstrap风格主题
- tkinterdnd2 (https://pypi.org/project/tkinterdnd2/) — 拖放支持
20.12.3 进阶书籍
- 《Python and Tkinter Programming》 (John Grayson) — Tkinter经典教程
- 《Tkinter GUI Application Development Blueprints》 — 实战项目教程
20.12.4 其他GUI框架
- PyQt6 (https://www.riverbankcomputing.com/static/Docs/PyQt6/) — Qt绑定
- PySide6 (https://doc.qt.io/qtforpython/) — Qt官方Python绑定
- Kivy (https://kivy.org/) — 跨平台GUI框架
- Dear PyGui (https://dearpygui.readthedocs.io/) — 现代GPU加速GUI
下一章:第21章 PyQt GUI开发