📚 目录 第 14 章 自定义组件(复合控件)⭐ 本版核心章

第 14 章 自定义组件(复合控件)⭐ 本版核心章

本书红线(贯穿全教程,回顾):
1. 每个界面都用 class App: 封装,__init__ 里建控件,方法即事件回调。
2. 所有控件示例一律写在类里,不用脱离类的扁平脚本。
3. 代码块带 if __name__ == "__main__": 守卫,可独立运行。
4. 星级:★ 了解 · ★★ 理解 · ★★★ 掌握 · ★★★★ 重点必会。

主线项目:图形界面通讯录管理器,随章节每章新增一个控件慢慢长大。

上节回顾(第 13 章):你学会了用 Notebook(分页) 和 PanedWindow(可拖拽分隔) 把"太多内容"组织得井井有条,并体会了"每页一个构建方法"带来的清爽。但每当新界面要用到"搜索栏""联系人表单""联系人表格"时,你都在重复写一遍同样的控件组合。本章彻底解决这个问题——把它们封装成自己的控件类。

本章目标(本版核心):把"一组常一起出现的控件"封装成自定义组件类,实现复用与解耦——这是 tkinter 工程化最关键的一步。你将学会:为什么需要自定义组件、继承 ttk.Frame 的标准写法、如何设计对外接口,并完整实现三个从通讯录提炼的组件:SearchBar、ContactForm、ContactTable。


14.1 是什么 / 适用场景 ★

自定义组件(复合控件)是什么:前面 11 章,我们用的都是 tkinter 自带的"原子控件"(Label、Entry、Button、Treeview……)。但工程里经常出现一组总在一起出现、一起干活的控件——比如"输入框 + 搜索按钮"。反复手写这两者很麻烦。我们可以自己定义一个类(比如 SearchBar),让它内部自动建好输入框和按钮、对外只暴露一个干净的 get_keyword() 方法。对外它就像一个普通控件,对内它是一组控件的组合——这就是"复合控件"。

为什么需要它(痛点):

  1. 复用:搜索栏要在"通讯录页""设置页"都出现。不封装就得复制粘贴几十行;封装后一行 SearchBar(root) 搞定,改一处全生效。
  2. 解耦:外部代码只关心"给我关键词"(bar.get_keyword()),不关心内部是 Entry 还是别的;内部怎么改(比如换成 Combobox)外部完全无感。这叫"封装内部细节,只暴露接口"。
  3. 结构清晰:App 类里不再堆几百行控件代码,而是 SearchBar(...)、ContactForm(...)、ContactTable(...) 几行——读代码像读句子。
  4. 为 MVC 铺路:组件自己管内部状态,外部只调接口——第 20 章讲 MVC(模型-视图-控制器)时,这些组件就是天然的"视图(View)"。

适用场景:任何"多次出现"或"逻辑内聚"的控件组合——搜索栏、登录框、表单、带刷新的表格、带标题的信息卡……

📌 核心思想:组合优于堆砌。与其在主界面里堆 30 个原子控件,不如把它们分组,每组做成一个自定义组件。

14.2 继承 ttk.Frame 的标准写法 ★★★★

自定义组件的"标准骨架"只有三步,务必记牢:

import tkinter as tk
from tkinter import ttk


class MyWidget(ttk.Frame):            # ① 继承 ttk.Frame
    def __init__(self, master, **kw): # ② 第一个参数是父容器 master
        super().__init__(master, **kw)   # ③ 必须先初始化父类 Frame!
        # ④ 在这里创建子控件,父容器写 self(不是 master)
        #    并把它们 pack/grid 到 self 自己身上
        # ⑤ 定义对外接口方法(如 get_xxx / set_xxx)

逐条拆解:

📌 因为继承了 ttk.Frame,组件对象本身也有 pack()/grid()/config(),所以外面可以写 bar = SearchBar(root); bar.pack(),和用 ttk.Button 没区别。

14.3 对外接口设计(约定优于配置)★★★

"解耦"的关键就是接口约定。外部只通过这些方法和你的组件沟通:

接口类型命名习惯作用示例
取值get_xxx()返回组件当前数据bar.get_keyword() → 字符串
设值set_xxx(data) / set(data)用外部数据填充组件form.set_data(d)
重置clear()清空/复位内部状态form.clear()
刷新refresh(data)用新数据整体刷新(常用于表格)table.refresh(list)
回调on_xxx 参数 / command=内部发生某事时通知外部SearchBar(..., on_search=cb)

设计原则:


14.4 详细例子(三个完整组件 + 组合)★★★★

例 14.1 SearchBar:输入框 + 搜索按钮 ★★★★

对外只暴露 get_keyword();点击按钮时把关键词交给外部回调 on_search。

# 第14章 例14.1:自定义组件 SearchBar(输入框 + 搜索按钮)
import tkinter as tk
from tkinter import ttk


class SearchBar(ttk.Frame):
    """搜索栏:一个输入框 + 一个搜索按钮,对外只暴露 get_keyword()。"""

    def __init__(self, master, on_search=None, **kw):
        super().__init__(master, **kw)        # ① 必须先初始化父类 Frame
        self.on_search = on_search            # ② 保存外部传入的回调
        # ③ 子控件:父容器写 self(装进组件自己)
        self.entry = ttk.Entry(self)
        self.entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
        self.btn = ttk.Button(self, text="搜索", command=self._on_click)
        self.btn.pack(side="left")

    def _on_click(self):
        # 内部方法:点击搜索按钮时,把关键词交给外部回调
        if self.on_search:
            self.on_search(self.get_keyword())

    def get_keyword(self):
        """对外接口:返回当前输入的关键词。"""
        return self.entry.get()


if __name__ == "__main__":
    root = tk.Tk()
    root.title("通讯录管理器")
    # 用法和内置控件一样:创建后 pack
    bar = SearchBar(root, on_search=lambda kw: print("搜索:", kw))
    bar.pack(padx=10, pady=10, fill="x")
    root.mainloop()

逐行说明:

例 14.2 ContactForm:姓名/电话/分类/收藏 一组输入 ★★★★

对外 get_data()(读成字典)、set_data(data)(填充)、clear()(清空)。

# 第14章 例14.2:自定义组件 ContactForm(姓名/电话/分类/收藏)
import tkinter as tk
from tkinter import ttk


class ContactForm(ttk.Frame):
    """联系人表单:一组录入控件,对外提供 get_data()/set_data()/clear()。"""

    def __init__(self, master, **kw):
        super().__init__(master, **kw)
        # 姓名
        ttk.Label(self, text="姓名:").grid(row=0, column=0, padx=4, pady=4, sticky="e")
        self.name_entry = ttk.Entry(self)
        self.name_entry.grid(row=0, column=1, padx=4, pady=4)
        # 电话
        ttk.Label(self, text="电话:").grid(row=1, column=0, padx=4, pady=4, sticky="e")
        self.phone_entry = ttk.Entry(self)
        self.phone_entry.grid(row=1, column=1, padx=4, pady=4)
        # 分类(下拉框 Combobox)
        ttk.Label(self, text="分类:").grid(row=2, column=0, padx=4, pady=4, sticky="e")
        self.cat_combo = ttk.Combobox(self, values=["家人", "朋友", "同事", "其他"])
        self.cat_combo.grid(row=2, column=1, padx=4, pady=4)
        self.cat_combo.current(0)             # 默认选第一项
        # 收藏(勾选框)
        self.fav_var = tk.BooleanVar(value=False)   # 用变量保存是否收藏
        self.fav_check = ttk.Checkbutton(self, text="收藏", variable=self.fav_var)
        self.fav_check.grid(row=3, column=0, columnspan=2, padx=4, pady=4, sticky="w")

    def get_data(self):
        """对外接口:返回当前表单数据的字典。"""
        return {
            "name": self.name_entry.get(),
            "phone": self.phone_entry.get(),
            "category": self.cat_combo.get(),
            "favorite": self.fav_var.get(),
        }

    def set_data(self, data):
        """对外接口:用字典填充表单。"""
        self.name_entry.delete(0, "end")
        self.name_entry.insert(0, data.get("name", ""))
        self.phone_entry.delete(0, "end")
        self.phone_entry.insert(0, data.get("phone", ""))
        self.cat_combo.set(data.get("category", ""))
        self.fav_var.set(data.get("favorite", False))

    def clear(self):
        """对外接口:清空表单。"""
        self.name_entry.delete(0, "end")
        self.phone_entry.delete(0, "end")
        self.cat_combo.set("")
        self.fav_var.set(False)


if __name__ == "__main__":
    root = tk.Tk()
    root.title("通讯录管理器")
    form = ContactForm(root)
    form.pack(padx=10, pady=10)
    root.mainloop()

逐行说明:

例 14.3 ContactTable:包 Treeview + 刷新 ★★★★

对外 refresh(data)(用数据列表刷新)、on_select 回调(选中某行时通知外部)。

# 第14章 例14.3:自定义组件 ContactTable(包装 Treeview + 刷新)
import tkinter as tk
from tkinter import ttk


class ContactTable(ttk.Frame):
    """联系人表格:内部包一个 Treeview,对外提供 refresh(data) 和 on_select 回调。"""

    def __init__(self, master, on_select=None, **kw):
        super().__init__(master, **kw)
        self.on_select = on_select
        # Treeview 的列定义:姓名/电话/分类/收藏
        columns = ("name", "phone", "category", "favorite")
        self.tree = ttk.Treeview(self, columns=columns, show="headings")
        # 设置每列的标题(show="headings" 表示只显示列头、不显示首列 #0)
        self.tree.heading("name", text="姓名")
        self.tree.heading("phone", text="电话")
        self.tree.heading("category", text="分类")
        self.tree.heading("favorite", text="收藏")
        self.tree.pack(fill="both", expand=True)
        # 单击某行时触发内部回调
        self.tree.bind("<<TreeviewSelect>>", self._on_select)

    def refresh(self, data):
        """对外接口:用新的数据列表刷新表格。data 是字典组成的列表。"""
        # 先清空旧数据(用 delete + get_children 拿所有行 id)
        self.tree.delete(*self.tree.get_children())
        # 逐行插入
        for row in data:
            self.tree.insert("", "end", values=(
                row.get("name", ""),
                row.get("phone", ""),
                row.get("category", ""),
                "★" if row.get("favorite") else "",
            ))

    def _on_select(self, event):
        # 内部方法:拿到选中行的数据,交给外部回调
        sel = self.tree.selection()
        if not sel:
            return
        item = self.tree.item(sel[0])
        if self.on_select:
            self.on_select(item["values"])   # values 是 (name, phone, category, favorite)


if __name__ == "__main__":
    root = tk.Tk()
    root.title("通讯录管理器")
    table = ContactTable(root)
    table.pack(fill="both", expand=True, padx=10, pady=10)
    # 演示刷新
    table.refresh([
        {"name": "张三", "phone": "138", "category": "朋友", "favorite": True},
        {"name": "李四", "phone": "139", "category": "同事", "favorite": False},
    ])
    root.mainloop()

逐行说明:

例 14.4 组合三个组件,拼出完整通讯录界面 ★★★★

把上面三个组件像积木一样拼进 App,主界面清爽得只剩三行。这正是"组合优于堆砌"。

# 第14章 例14.4:把三个自定义组件组合成完整通讯录界面
import tkinter as tk
from tkinter import ttk


# —— 下面三个组件类与例 14.1~14.3 完全一致(真实工程里会从各自文件 import)——
class SearchBar(ttk.Frame):
    def __init__(self, master, on_search=None, **kw):
        super().__init__(master, **kw)
        self.on_search = on_search
        self.entry = ttk.Entry(self)
        self.entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
        ttk.Button(self, text="搜索", command=self._on_click).pack(side="left")
    def _on_click(self):
        if self.on_search:
            self.on_search(self.get_keyword())
    def get_keyword(self):
        return self.entry.get()


class ContactForm(ttk.Frame):
    def __init__(self, master, **kw):
        super().__init__(master, **kw)
        ttk.Label(self, text="姓名:").grid(row=0, column=0, padx=4, pady=4, sticky="e")
        self.name_entry = ttk.Entry(self)
        self.name_entry.grid(row=0, column=1, padx=4, pady=4)
        ttk.Label(self, text="电话:").grid(row=1, column=0, padx=4, pady=4, sticky="e")
        self.phone_entry = ttk.Entry(self)
        self.phone_entry.grid(row=1, column=1, padx=4, pady=4)
        ttk.Label(self, text="分类:").grid(row=2, column=0, padx=4, pady=4, sticky="e")
        self.cat_combo = ttk.Combobox(self, values=["家人", "朋友", "同事", "其他"])
        self.cat_combo.grid(row=2, column=1, padx=4, pady=4)
        self.cat_combo.current(0)
        self.fav_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(self, text="收藏", variable=self.fav_var).grid(
            row=3, column=0, columnspan=2, padx=4, pady=4, sticky="w")
    def get_data(self):
        return {"name": self.name_entry.get(), "phone": self.phone_entry.get(),
                "category": self.cat_combo.get(), "favorite": self.fav_var.get()}
    def clear(self):
        self.name_entry.delete(0, "end")
        self.phone_entry.delete(0, "end")
        self.cat_combo.set("")
        self.fav_var.set(False)


class ContactTable(ttk.Frame):
    def __init__(self, master, on_select=None, **kw):
        super().__init__(master, **kw)
        self.on_select = on_select
        columns = ("name", "phone", "category", "favorite")
        self.tree = ttk.Treeview(self, columns=columns, show="headings")
        self.tree.heading("name", text="姓名")
        self.tree.heading("phone", text="电话")
        self.tree.heading("category", text="分类")
        self.tree.heading("favorite", text="收藏")
        self.tree.pack(fill="both", expand=True)
        self.tree.bind("<<TreeviewSelect>>", self._on_select)
    def refresh(self, data):
        self.tree.delete(*self.tree.get_children())
        for row in data:
            self.tree.insert("", "end", values=(
                row.get("name", ""), row.get("phone", ""),
                row.get("category", ""),
                "★" if row.get("favorite") else ""))
    def _on_select(self, event):
        sel = self.tree.selection()
        if sel and self.on_select:
            self.on_select(self.tree.item(sel[0])["values"])


# —— 主界面:只用三行就把三大块拼起来 ——
class App:
    def __init__(self, root):
        self.root = root
        self.root.title("通讯录管理器")

        # 顶部:搜索栏(用 on_search 回调接外部逻辑)
        self.search = SearchBar(root, on_search=self.do_search)
        self.search.pack(fill="x", padx=10, pady=5)

        # 中部:表单,用 LabelFrame 包一下更好看
        self.form_frame = ttk.LabelFrame(root, text="新增联系人")
        self.form_frame.pack(fill="x", padx=10, pady=5)
        self.form = ContactForm(self.form_frame)
        self.form.pack(padx=5, pady=5)

        # 底部:表格
        self.table = ContactTable(root, on_select=self.on_select)
        self.table.pack(fill="both", expand=True, padx=10, pady=5)

        # 初始数据
        self.contacts = [
            {"name": "张三", "phone": "138", "category": "朋友", "favorite": True},
            {"name": "李四", "phone": "139", "category": "同事", "favorite": False},
        ]
        self.table.refresh(self.contacts)    # 把数据喂给表格组件

    def do_search(self, keyword):
        print("搜索关键词:", keyword)

    def on_select(self, values):
        print("选中行:", values)


if __name__ == "__main__":
    root = tk.Tk()
    App(root)
    root.mainloop()

逐行说明:


14.5 OOP 落点 ★★★★

本章是全教程 OOP 思想的集大成,请反复体会:

一句话:"把一组总在一起的控件,封装成一个继承 Frame 的类;内部状态自己管,对外只留干净接口——界面从此可复用、可解耦、可生长。"

14.6 注意事项 / 易错清单 ★★★★


14.7 本章练习

  1. 仿照 SearchBar,写一个 LabeledEntry(ttk.Frame):对外构造时传 label="姓名",内部由一个 Label + 一个 Entry 组成,对外暴露 get() 返回输入文字。
  2. 给 ContactForm 增加一个"保存"按钮,点击时调用外部传入的 on_save(data) 回调(把 get_data() 的结果送出去),并把 on_save 通过构造参数传入。
  3. 用三个组件(SearchBar、ContactForm、ContactTable)搭一个界面,让"表单保存"按钮点击时把表单数据 append 进 self.contacts 列表并 self.table.refresh(self.contacts) 刷新表格(综合例 14.4 改造)。
  4. 思考题:如果把 ContactTable 内部的 Treeview 换成纯 Label 列表,App 里那行 self.table.refresh(self.contacts) 需要改吗?(提示:不需要——这正是"封装"的价值:外部只认 refresh 接口,内部实现随便换。)
本章小结:你已掌握 tkinter 工程化的核心——自定义组件。从此通讯录管理器的界面由可复用的"积木"拼成,结构清晰、易维护、易扩展。下一组章节会把这些组件与"数据持久化(保存/读取文件、数据库)"结合,让通讯录真正能用起来。