我先直接说结论哈:如果你平时写 Python 小工具,总嫌 Tkinter 太丑、太上古,那真可以试试 CustomTkinter,学习成本几乎一样,但颜值直接上一个档次,不用你自己画控件、写一堆样式。
我前两天在公司加班,晚上九点多,人有点迷糊,想给测试同事写个小工具:批量改接口环境的配置,本来打算整一个命令行脚本,结果对方说一句:“能不能点点按钮的那种,我不想记参数。”
你要是直接上 Tkinter,默认那种灰扑扑的窗口+古早风按钮,给同事看一眼,大概率会被吐槽“这啥年代的界面”。但你要上 PyQt / Qt,又太重,打包、依赖、体积都不太友好。
这时候 CustomTkinter 就很适合:还是 Tkinter 的那套机制,但控件全是现代风 UI,自带暗黑模式、主题颜色,代码量变化不大。
先跑起来:第一个 CustomTkinter 窗口
先别急着看大项目,先搞一个能跑、能点的窗口出来,心里有点底。
安装就一行,环境里有 pip 就行:
pip install customtkinter
然后来个最小可用 Demo,我用的是比较常见的写法,把 CustomTkinter 简写成 ctk:
import customtkinter as ctk
# 全局外观设置(可选)
ctk.set_appearance_mode("dark") # "light" / "dark" / "system"
ctk.set_default_color_theme("blue") # "blue" / "green" / "dark-blue"
# 创建主窗口
app = ctk.CTk()
app.title("CustomTkinter Demo")
app.geometry("400x250") # 窗口大小
# 一个标签
label = ctk.CTkLabel(app, text="点下面的按钮试试:")
label.pack(pady=20)
# 点击计数
count = 0
def on_click():
nonlocal count # Python 3.8+ 可以这么用
count += 1
label.configure(text=f"你点了 {count} 下啦")
# 一个按钮
btn = ctk.CTkButton(app, text="点我", command=on_click)
btn.pack(pady=10)
# 一个暗黑/明亮模式切换
def toggle_mode():
current = ctk.get_appearance_mode()
if current == "Dark":
ctk.set_appearance_mode("light")
else:
ctk.set_appearance_mode("dark")
switch = ctk.CTkSwitch(app, text="切换明暗模式", command=toggle_mode)
switch.pack(pady=10)
app.mainloop()
你跑一下就知道它和原生 Tkinter 差别在哪儿了:同样的大小、同样的布局,但按钮、字体、背景都是现代风 UI,而且还支持暗黑模式切换,这个在 Tkinter 原生里自己搞会很费劲。
有几个点顺带说一下:
CTk() 就是 CustomTkinter 的主窗口,等价于 Tkinter 里的 tk.Tk()。
- 所有控件基本都前面多了个 C:
CTkLabel、CTkButton、CTkEntry、CTkTextbox...
set_appearance_mode 和 set_default_color_theme 是全局的,最好在创建窗口之前调用。
做个完整一点的小工具:待办清单 ToDo
光一个按钮没意思,我们搞个非常实用但不复杂的小东西:一个小型 ToDo 应用,能:
- 输入任务
- 点击添加后显示在列表里
- 支持勾选“已完成”,自动加删除线或标记
- 右侧有个清理已完成的按钮
功能不复杂,但把常用的控件、布局、回调都串联起来了。
先来一个相对完整的版本,你可以直接复制跑:
import customtkinter as ctk
from datetime import datetime
ctk.set_appearance_mode(“dark”)
ctk.set_default_color_theme(“dark-blue”)
class TodoApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title(“CustomTkinter 待办清单”)
self.geometry(“600x400”)
# 整体分左右两块:左边主区域,右边工具栏
self.columnconfigure(0, weight=3)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
self.rowconfigure(1, weight=0)
# ===== 顶部标题 =====
title = ctk.CTkLabel(self, text=“今天要做啥?”, font=ctk.CTkFont(size=20, weight=“bold”))
title.grid(row=0, column=0, columnspan=2, sticky=“w”, padx=20, pady=(15, 5))
# ===== 中间任务列表(可滚动区域)=====
self.task_frame = ctk.CTkScrollableFrame(self, label_text=“待办列表”)
self.task_frame.grid(row=1, column=0, sticky=“nsew”, padx=20, pady=10)
self.task_frame.columnconfigure(0, weight=1)
self.tasks = [] # 记录所有任务控件
# ===== 右侧工具栏 =====
toolbar = ctk.CTkFrame(self)
toolbar.grid(row=1, column=1, sticky=“ns”, padx=(0, 20), pady=10)
# 输入框
self.entry = ctk.CTkEntry(toolbar, width=180, placeholder_text=“输入任务内容...”)
self.entry.pack(pady=(10, 5))
# 添加按钮
add_btn = ctk.CTkButton(toolbar, text=“添加任务”, command=self.add_task)
add_btn.pack(pady=5)
# 清理已完成
clear_btn = ctk.CTkButton(toolbar, text=“清理已完成”, fg_color=”#a83232",
hover_color=”#7a2525”, command=self.clear_done)
clear_btn.pack(pady=(20, 5))
# 模式切换
self.mode_switch = ctk.CTkSwitch(toolbar, text=“暗黑模式”, command=self.toggle_mode)
self.mode_switch.select() # 默认选中 = dark
self.mode_switch.pack(pady=(20, 5))
# 状态栏
self.status_label = ctk.CTkLabel(self, text=“欢迎~”, anchor=“w”)
self.status_label.grid(row=2, column=0, columnspan=2, sticky=“we”, padx=20, pady=(0, 10))
# 输入框回车直接添加
self.entry.bind(“<Return>”, lambda event: self.add_task())
def add_task(self):
text = self.entry.get().strip()
if not text:
self.set_status(“内容为空,不添加。”)
return
# 单行:左边一个勾选框 + 右边时间
row = len(self.tasks)
frame = ctk.CTkFrame(self.task_frame)
frame.grid(row=row, column=0, sticky=“we”, pady=2, padx=5)
frame.columnconfigure(0, weight=1)
var = ctk.BooleanVar(value=False)
checkbox = ctk.CTkCheckBox(
frame,
text=text,
variable=var,
onvalue=True,
offvalue=False,
command=lambda: self.on_task_check(checkbox, var)
)
checkbox.grid(row=0, column=0, sticky=“w”)
time_label = ctk.CTkLabel(frame, text=datetime.now().strftime(“%H:%M”))
time_label.grid(row=0, column=1, sticky=“e”, padx=(10, 5))
self.tasks.append((frame, checkbox, var))
self.entry.delete(0, “end”)
self.set_status(f“添加任务:{text}”)
def on_task_check(self, checkbox, var):
# 勾选后给任务做个简单标记
if var.get():
checkbox.configure(text=f“✅ {checkbox.cget(‘text’)}”)
else:
txt = checkbox.cget(“text”)
if txt.startswith(“✅ ”):
checkbox.configure(text=txt[2:])
self.update_status_counts()
def clear_done(self):
# 把已经选择的任务删掉
remain = []
removed = 0
for frame, checkbox, var in self.tasks:
if var.get():
frame.destroy()
removed += 1
else:
remain.append((frame, checkbox, var))
self.tasks = remain
# 重新排一下行号
for idx, (frame, _, _) in enumerate(self.tasks):
frame.grid_configure(row=idx)
self.set_status(f“清理已完成任务 {removed} 条”)
self.update_status_counts()
def update_status_counts(self):
total = len(self.tasks)
done = sum(1 for _, _, v in self.tasks if v.get())
self.status_label.configure(text=f“总共 {total} 条,已完成 {done} 条”)
def toggle_mode(self):
if self.mode_switch.get():
ctk.set_appearance_mode(“dark”)
self.mode_switch.configure(text=“暗黑模式”)
else:
ctk.set_appearance_mode(“light”)
self.mode_switch.configure(text=“亮色模式”)
def set_status(self, msg: str):
self.status_label.configure(text=msg)
if __name__ == “__main__”:
app = TodoApp()
app.mainloop()
这个小例子里,其实已经用到 CustomTkinter 很多日常会遇到的东西:
CTkScrollableFrame 做滚动区域,比自己绑 Scrollbar 省事多了。
CTkCheckBox + BooleanVar 管理状态,很符合 Tk 的一贯风格。
grid 布局配合 rowconfigure/columnconfigure,窗口缩放时会自适应。
- 小小的
status_label 做状态提示,这个在实际工具里特别有用。
你可以先跑一遍,熟悉一下窗体布局、控件创建的节奏。
再往里一点:常用控件和布局的小习惯
你以后用 CustomTkinter 写东西,七成时间就围着几类控件打转:
- 文本类:
CTkLabel、CTkTextbox(多行输入)、CTkEntry(单行)
- 按钮类:
CTkButton、CTkSwitch、CTkCheckBox
- 选择类:
CTkOptionMenu、CTkComboBox(新版支持)
- 进度类:
CTkProgressBar、CTkSlider
- 容器类:
CTkFrame、CTkScrollableFrame, 再加 CTkTabview
随便举个“下载进度条”风格的小 UI,给你看看进度条和滑块用法:
import customtkinter as ctk
import time
import threading
ctk.set_appearance_mode(“dark”)
ctk.set_default_color_theme(“green”)
class DownloadDemo(ctk.CTk):
def __init__(self):
super().__init__()
self.title(“下载进度 Demo”)
self.geometry(“400x220”)
self.label = ctk.CTkLabel(self, text=“准备就绪”, font=ctk.CTkFont(size=16))
self.label.pack(pady=(25, 10))
self.progress = ctk.CTkProgressBar(self)
self.progress.set(0)
self.progress.pack(fill=“x”, padx=40, pady=10)
self.speed_label = ctk.CTkLabel(self, text=“模拟下载速度:1x”)
self.speed_label.pack(pady=(10, 0))
self.speed_var = ctk.DoubleVar(value=1.0)
self.speed_slider = ctk.CTkSlider(
self,
from_=0.5,
to=3.0,
number_of_steps=5,
variable=self.speed_var,
command=self.on_speed_change
)
self.speed_slider.pack(fill=“x”, padx=40, pady=5)
self.btn = ctk.CTkButton(self, text=“开始下载”, command=self.start_download)
self.btn.pack(pady=10)
self._downloading = False
def on_speed_change(self, value):
self.speed_label.configure(text=f“模拟下载速度:{value:.1f}x”)
def start_download(self):
if self._downloading:
return
self._downloading = True
self.btn.configure(state=“disabled”)
self.label.configure(text=“下载中...”)
threading.Thread(target=self._download_task, daemon=True).start()
def _download_task(self):
progress = 0.0
while progress < 1.0:
time.sleep(0.1 / self.speed_var.get())
progress += 0.02
# UI 更新要丢回主线程,CustomTkinter 这里小范围直接用 after 也行
self.after(0, lambda v=progress: self.progress.set(min(v, 1.0)))
def done():
self.label.configure(text=“下载完成 ✅”)
self.btn.configure(state=“normal”)
self._downloading = False
self.after(0, done)
if __name__ == “__main__”:
DownloadDemo().mainloop()
这里顺便踩了一个实战中经常会遇到的坑:长任务 (比如下载、计算、IO) 千万别直接在按钮回调里 time.sleep 或者跑大循环,会把整个 GUI 卡死。上面这个写法里,用了 threading.Thread + self.after,就是比较常见的组合:
- 耗时逻辑丢给子线程,让主线程专注画界面、响应事件。
- 子线程里每次进度变化,用
after(0, ...) 把 UI 更新交给主线程执行,避免跨线程直接操作控件。
这套关于多线程与异步任务处理的习惯你要是一开始就用对了,后面做工具的时候会省很多坑。
稍微上一个台阶:用类和模块把 GUI 收拾干净
很多人学 GUI 的时候,刚开始都是这样写:
app = ctk.CTk()
btn = ctk.CTkButton(app, ...)
btn.pack()
entry = ctk.CTkEntry(app, ...)
entry.pack()
# 一堆全局变量 + 函数
def on_click():
...
app.mainloop()
小 Demo 没问题,但稍微大一点就开始乱:变量到处飞、回调函数一大堆、跨模块调用很难写单元测试。
上面 ToDo 的例子,其实就是一个比较好的练习方向:把窗口本身建成一个类,把所有控件挂到 self 上,逻辑也变成这个类的方法,传递状态就简单很多。
再举个结构稍微清晰一点的骨架,你做项目的时候可以按这个套路来扩:
import customtkinter as ctk
class SettingsView(ctk.CTkFrame):
“”“右侧设置区域”“”
def __init__(self, master, on_env_change):
super().__init__(master)
self.on_env_change = on_env_change
self.label = ctk.CTkLabel(self, text=“环境选择”)
self.label.pack(pady=(10, 5))
self.env_var = ctk.StringVar(value=“dev”)
self.env_menu = ctk.CTkOptionMenu(
self,
values=[“dev”, “test”, “prod”],
variable=self.env_var,
command=self._env_changed
)
self.env_menu.pack(pady=5)
def _env_changed(self, value):
if self.on_env_change:
self.on_env_change(value)
class MainView(ctk.CTkFrame):
“”“左侧主操作区域”“”
def __init__(self, master):
super().__init__(master)
self.info = ctk.CTkLabel(self, text=“当前环境:dev”)
self.info.pack(pady=20)
self.run_btn = ctk.CTkButton(self, text=“执行操作”, command=self.run)
self.run_btn.pack(pady=10)
def set_env(self, env: str):
self.info.configure(text=f“当前环境:{env}”)
def run(self):
# 这里写你的实际业务逻辑
print(“执行操作喽”)
class EnvToolApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title(“环境切换小工具”)
self.geometry(“520x260”)
self.columnconfigure(0, weight=2)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
self.main_view = MainView(self)
self.main_view.grid(row=0, column=0, sticky=“nsew”, padx=(15, 5), pady=15)
self.settings = SettingsView(self, on_env_change=self.on_env_change)
self.settings.grid(row=0, column=1, sticky=“nsew”, padx=(5, 15), pady=15)
def on_env_change(self, env):
self.main_view.set_env(env)
if __name__ == “__main__”:
ctk.set_appearance_mode(“system”)
ctk.set_default_color_theme(“blue”)
EnvToolApp().mainloop()
这段代码你以后可以当模板用:
- 所有“区域”用
CTkFrame 子类表示(MainView、SettingsView)。
- 主窗口负责把这些区域拼起来、做跨区域的事件配合。
- 具体控件的细节封装在各自 View 里,调用的时候就不用到处找变量。
做复杂点的 GUI,比如多 Tab、多页面的配置工具,基本就是在这个骨架上不断加 View。
CustomTkinter 小技巧顺手说几个
这个话题再展开就太长了,我挑几个日常非常常用的点说一下,你后面自己查官方文档或者源码,可以玩得更花一点:
-
主题颜色自定义
不想用内置的 “blue” / “green” / “dark-blue”,可以加载自定义 json 主题:
ctk.set_default_color_theme(“my_theme.json”)
json 里可以定义主色、按钮色、文字色等等,适合对 UI 比较较真的同学。
-
窗口图标 & 居中
Tkinter 那一套也能照搬:
app = ctk.CTk()
app.iconbitmap(“xxx.ico”) # Windows 下换图标
# 简单居中一下
w, h = 600, 400
app.geometry(f“{w}x{h}+{(app.winfo_screenwidth()-w)//2}+{(app.winfo_screenheight()-h)//2}”)
-
和原生 Tkinter 混用(不太建议,但可以)
CustomTkinter 是基于 Tkinter 做的,你确实可以在一个窗口里混合用一点 tk 原生控件,比如你想用 tk.Canvas 画点东西,基本上也跑得起来。但一般不推荐这样做,风格会不统一,能用 CustomTkinter 的控件尽量用它那套。
差不多就先聊到这儿,再写我得去泡杯咖啡续命了。你可以先把上面的 Demo 都跑一遍,然后拿其中一个小骨架,改成你自己现在手上需要的小工具,比如:
- 快速切换 hosts / 环境配置
- 小型接口调试面板
- 本地脚本的参数面板(帮测试、运营点点按钮就能跑脚本)
写着写着,你就会发现:原来 Python 写个顺眼的 GUI,并没有想象中那么麻烦。
希望这篇从实战出发的指南能帮你快速上手 CustomTkinter。如果你想查看更多类似的 Python 工具开发技巧,欢迎访问 云栈社区 与我们交流。