Bài 9: OOP với Tkinter
Giai đoạn 3 – Dự Án Thực Tế · Mục tiêu: Viết code Tkinter sạch, có cấu trúc với OOP — class, kế thừa, đa màn hình và kiến trúc MVC đơn giản.
1. Tại sao dùng OOP với Tkinter?
Cách viết thủ tục (không dùng class)
root = tk.Tk()
count = 0 # Biến toàn cục — dễ xung đột, khó maintain
def increment():
global count # Phụ thuộc biến toàn cục
count += 1
label.config(text=str(count))
label = tk.Label(root, text="0")
label.pack()
tk.Button(root, text="+", command=increment).pack()
root.mainloop()
Cách viết OOP (dùng class)
class CounterApp:
def __init__(self, root):
self.root = root
self.count = 0 # Biến instance — rõ ràng, không xung đột
self._build_ui()
def _build_ui(self):
self.label = tk.Label(self.root, text="0", font=("Arial", 30))
self.label.pack(pady=20)
tk.Button(self.root, text="+", command=self.increment).pack()
def increment(self):
self.count += 1
self.label.config(text=str(self.count))
root = tk.Tk()
app = CounterApp(root) # Tất cả gói gọn trong class
root.mainloop()
Lợi ích OOP:
- Đóng gói: dữ liệu và hành vi ở cùng một class
- Tái sử dụng: kế thừa, không viết lại
- Bảo trì: dễ sửa, mở rộng
- Đọc hiểu: code có cấu trúc rõ ràng
2. Cấu trúc class chuẩn cho App
import tkinter as tk
from tkinter import ttk
class App(tk.Tk):
"""Kế thừa trực tiếp từ tk.Tk."""
def __init__(self):
super().__init__()
# Cấu hình cửa sổ
self.title("My Application")
self.geometry("600x450")
self.resizable(False, False)
# Dữ liệu ứng dụng
self.user_data = {}
# Khởi tạo UI
self._setup_style()
self._build_ui()
self._bind_shortcuts()
# Xử lý đóng cửa sổ
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _setup_style(self):
"""Cấu hình style/theme."""
self.style = ttk.Style()
self.style.theme_use("clam")
def _build_ui(self):
"""Xây dựng giao diện."""
ttk.Label(self, text="Hello, OOP Tkinter!",
font=("Arial", 18)).pack(expand=True)
def _bind_shortcuts(self):
"""Gán phím tắt."""
self.bind("<Control-q>", lambda e: self._on_close())
def _on_close(self):
"""Xử lý khi thoát."""
self.destroy()
if __name__ == "__main__":
app = App()
app.mainloop()
3. Frame navigation — Nhiều màn hình
Kỹ thuật phổ biến: dùng nhiều Frame chồng lên nhau, raise frame cần hiển thị.
import tkinter as tk
from tkinter import ttk
class BasePage(tk.Frame):
"""Base class cho mọi màn hình."""
def __init__(self, parent, controller):
super().__init__(parent, bg="#F5F5F5")
self.controller = controller
class LoginPage(BasePage):
def __init__(self, parent, controller):
super().__init__(parent, controller)
tk.Label(self, text=" Đăng nhập",
font=("Arial", 20, "bold"), bg="#F5F5F5",
fg="#2C3E50").pack(pady=30)
card = tk.Frame(self, bg="white", padx=30, pady=20)
card.pack(padx=60, fill="x")
tk.Label(card, text="Tên đăng nhập:", bg="white").pack(anchor="w", pady=(5,2))
self.entry_user = tk.Entry(card, font=("Arial", 11))
self.entry_user.pack(fill="x", ipady=5)
self.entry_user.insert(0, "admin")
tk.Label(card, text="Mật khẩu:", bg="white").pack(anchor="w", pady=(10,2))
self.entry_pass = tk.Entry(card, show="*", font=("Arial", 11))
self.entry_pass.pack(fill="x", ipady=5)
ttk.Button(self, text="Đăng nhập",
command=self.login).pack(pady=15, ipadx=30, ipady=5)
ttk.Button(self, text="Đăng ký →",
command=lambda: controller.show_frame("RegisterPage")).pack()
def login(self):
user = self.entry_user.get()
password = self.entry_pass.get()
if user == "admin" and password == "123":
self.controller.show_frame("DashboardPage")
else:
tk.messagebox.showerror("Lỗi", "Sai tên đăng nhập hoặc mật khẩu!")
class RegisterPage(BasePage):
def __init__(self, parent, controller):
super().__init__(parent, controller)
tk.Label(self, text=" Đăng ký",
font=("Arial", 20, "bold"), bg="#F5F5F5",
fg="#2C3E50").pack(pady=30)
card = tk.Frame(self, bg="white", padx=30, pady=20)
card.pack(padx=60, fill="x")
for label in ["Họ tên:", "Email:", "Mật khẩu:", "Xác nhận mật khẩu:"]:
tk.Label(card, text=label, bg="white").pack(anchor="w", pady=(5,1))
e = tk.Entry(card, show="*" if "khẩu" in label else "")
e.pack(fill="x", ipady=4)
ttk.Button(self, text="Đăng ký",
command=lambda: controller.show_frame("LoginPage")).pack(pady=15, ipadx=30)
ttk.Button(self, text="← Đã có tài khoản?",
command=lambda: controller.show_frame("LoginPage")).pack()
class DashboardPage(BasePage):
def __init__(self, parent, controller):
super().__init__(parent, controller)
# Header
header = tk.Frame(self, bg="#2C3E50")
header.pack(fill="x")
tk.Label(header, text=" Dashboard", font=("Arial", 14, "bold"),
fg="white", bg="#2C3E50", pady=12).pack(side="left", padx=20)
ttk.Button(header, text="Đăng xuất",
command=lambda: controller.show_frame("LoginPage")).pack(side="right", padx=15, pady=8)
# Stats cards
stats_frame = tk.Frame(self, bg="#F5F5F5")
stats_frame.pack(fill="x", padx=15, pady=15)
for i, (title, value, color) in enumerate([
("Tổng sinh viên", "128", "#3498DB"),
("Hoàn thành", "94", "#27AE60"),
("Đang học", "34", "#F39C12"),
]):
card = tk.Frame(stats_frame, bg=color, padx=20, pady=15)
card.grid(row=0, column=i, padx=8, sticky="ew")
stats_frame.columnconfigure(i, weight=1)
tk.Label(card, text=value, font=("Arial", 24, "bold"),
fg="white", bg=color).pack()
tk.Label(card, text=title, fg="white", bg=color).pack()
tk.Label(self, text="Chào mừng! Đăng nhập thành công ",
font=("Arial", 12), bg="#F5F5F5", fg="#27AE60").pack(pady=20)
class App(tk.Tk):
"""Controller chính."""
def __init__(self):
super().__init__()
self.title("Multi-Screen App")
self.geometry("480x380")
self.resizable(False, False)
container = tk.Frame(self)
container.pack(fill="both", expand=True)
container.grid_rowconfigure(0, weight=1)
container.grid_columnconfigure(0, weight=1)
self.frames = {}
for PageClass in (LoginPage, RegisterPage, DashboardPage):
name = PageClass.__name__
frame = PageClass(container, self)
self.frames[name] = frame
frame.grid(row=0, column=0, sticky="nsew")
self.show_frame("LoginPage")
def show_frame(self, name: str):
"""Hiển thị frame theo tên."""
self.frames[name].tkraise()
if __name__ == "__main__":
from tkinter import messagebox
app = App()
app.mainloop()
4. Custom Widget — Tái sử dụng
import tkinter as tk
from tkinter import ttk
class LabeledEntry(tk.Frame):
"""Widget nhập liệu có label kèm theo."""
def __init__(self, parent, label, show="", placeholder="", **kwargs):
super().__init__(parent, bg="white")
tk.Label(self, text=label, bg="white",
font=("Arial", 9), fg="#7F8C8D").pack(anchor="w")
self.var = tk.StringVar()
self.entry = tk.Entry(self, textvariable=self.var,
show=show, font=("Arial", 11),
relief="flat", bg="#F8F9FA",
highlightthickness=1,
highlightbackground="#DEE2E6",
highlightcolor="#3498DB",
**kwargs)
self.entry.pack(fill="x", ipady=6)
if placeholder:
self.entry.insert(0, placeholder)
self.entry.config(fg="gray")
self.entry.bind("<FocusIn>", self._clear_placeholder)
self.entry.bind("<FocusOut>", self._add_placeholder)
self._placeholder = placeholder
def _clear_placeholder(self, event):
if self.entry.get() == self._placeholder:
self.entry.delete(0, tk.END)
self.entry.config(fg="black")
def _add_placeholder(self, event):
if not self.entry.get():
self.entry.insert(0, self._placeholder)
self.entry.config(fg="gray")
def get(self):
val = self.var.get()
return "" if val == self._placeholder else val
def set(self, value):
self.var.set(value)
class IconButton(tk.Label):
"""Nút bấm kiểu Label với hiệu ứng hover."""
def __init__(self, parent, text, command=None,
normal_bg="#3498DB", hover_bg="#2980B9",
fg="white", **kwargs):
super().__init__(parent, text=text, fg=fg, bg=normal_bg,
cursor="hand2", padx=15, pady=8,
font=("Arial", 10, "bold"), **kwargs)
self._normal_bg = normal_bg
self._hover_bg = hover_bg
self._command = command
self.bind("<Enter>", lambda e: self.config(bg=hover_bg))
self.bind("<Leave>", lambda e: self.config(bg=normal_bg))
self.bind("<Button-1>", lambda e: command() if command else None)
# Demo sử dụng custom widgets
if __name__ == "__main__":
root = tk.Tk()
root.title("Custom Widgets Demo")
root.geometry("400x350")
root.configure(bg="white")
tk.Label(root, text="Form đăng ký", font=("Arial", 16, "bold"),
bg="white", fg="#2C3E50").pack(pady=15)
name_entry = LabeledEntry(root, "Họ tên", placeholder="Nguyễn Văn A")
name_entry.pack(fill="x", padx=30, pady=5)
email_entry = LabeledEntry(root, "Email", placeholder="example@email.com")
email_entry.pack(fill="x", padx=30, pady=5)
pass_entry = LabeledEntry(root, "Mật khẩu", show="*")
pass_entry.pack(fill="x", padx=30, pady=5)
def submit():
print(f"Tên: {name_entry.get()}")
print(f"Email: {email_entry.get()}")
btn_frame = tk.Frame(root, bg="white")
btn_frame.pack(pady=15)
IconButton(btn_frame, " Đăng ký", command=submit).pack(side="left", padx=5)
IconButton(btn_frame, " Hủy", command=root.destroy,
normal_bg="#E74C3C", hover_bg="#CB4335").pack(side="left", padx=5)
root.mainloop()
5. Mô hình MVC đơn giản
Model — Dữ liệu và nghiệp vụ (không biết UI)
View — Giao diện người dùng (không biết logic)
Controller — Kết nối Model và View
import tkinter as tk
from tkinter import ttk
import json, os
# ── MODEL ────────────────────────────────────────────────
class StudentModel:
FILE = "students.json"
def __init__(self):
self.students = self._load()
def _load(self):
if os.path.exists(self.FILE):
with open(self.FILE, "r", encoding="utf-8") as f:
return json.load(f)
return []
def save(self):
with open(self.FILE, "w", encoding="utf-8") as f:
json.dump(self.students, f, ensure_ascii=False, indent=2)
def add(self, student: dict):
self.students.append(student)
self.save()
def delete(self, index: int):
self.students.pop(index)
self.save()
def get_all(self):
return self.students.copy()
def search(self, keyword: str):
kw = keyword.lower()
return [s for s in self.students if kw in s["ten"].lower()]
# ── VIEW ─────────────────────────────────────────────────
class StudentView(tk.Frame):
def __init__(self, parent):
super().__init__(parent)
self._build()
def _build(self):
# Thanh tìm kiếm
search_frame = tk.Frame(self)
search_frame.pack(fill="x", padx=10, pady=8)
tk.Label(search_frame, text=" Tìm:").pack(side="left")
self.search_var = tk.StringVar()
self.search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=30)
self.search_entry.pack(side="left", padx=5)
self.btn_search = ttk.Button(search_frame, text="Tìm")
self.btn_search.pack(side="left")
# Bảng danh sách
cols = ("stt", "ma_sv", "ten", "diem")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=8)
for col, heading, width in [
("stt", "STT", 50),
("ma_sv", "Mã SV", 100),
("ten", "Họ tên", 180),
("diem", "Điểm", 80),
]:
self.tree.heading(col, text=heading)
self.tree.column(col, width=width, anchor="center" if col != "ten" else "w")
self.tree.pack(fill="both", expand=True, padx=10, pady=5)
# Form nhập liệu
form = tk.LabelFrame(self, text="Thêm sinh viên", padx=10, pady=8)
form.pack(fill="x", padx=10, pady=5)
self.entry_ma = self._field(form, "Mã SV:", 0)
self.entry_ten = self._field(form, "Họ tên:", 1)
self.entry_diem = self._field(form, "Điểm:", 2)
# Nút
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
self.btn_add = ttk.Button(btn_frame, text=" Thêm")
self.btn_add.pack(side="left", padx=5)
self.btn_del = ttk.Button(btn_frame, text=" Xóa")
self.btn_del.pack(side="left", padx=5)
def _field(self, parent, label, row):
tk.Label(parent, text=label).grid(row=row, column=0, sticky="e", pady=3)
entry = tk.Entry(parent, width=25)
entry.grid(row=row, column=1, padx=8, pady=3)
return entry
def get_form_data(self):
return {
"ma_sv": self.entry_ma.get().strip(),
"ten": self.entry_ten.get().strip(),
"diem": self.entry_diem.get().strip(),
}
def clear_form(self):
for e in (self.entry_ma, self.entry_ten, self.entry_diem):
e.delete(0, tk.END)
def refresh_table(self, students):
self.tree.delete(*self.tree.get_children())
for i, s in enumerate(students, 1):
self.tree.insert("", tk.END, values=(i, s["ma_sv"], s["ten"], s["diem"]))
def get_selected_index(self):
sel = self.tree.selection()
if sel:
return self.tree.index(sel[0])
return None
# ── CONTROLLER ───────────────────────────────────────────
class StudentController:
def __init__(self, root):
self.model = StudentModel()
self.view = StudentView(root)
self.view.pack(fill="both", expand=True)
# Gán sự kiện
self.view.btn_add.config(command=self.add_student)
self.view.btn_del.config(command=self.delete_student)
self.view.btn_search.config(command=self.search)
self.view.search_entry.bind("<Return>", lambda e: self.search())
self.refresh()
def refresh(self, students=None):
data = students or self.model.get_all()
self.view.refresh_table(data)
def add_student(self):
from tkinter import messagebox
data = self.view.get_form_data()
if not all(data.values()):
messagebox.showwarning("Thiếu dữ liệu", "Vui lòng điền đầy đủ thông tin!")
return
try:
float(data["diem"])
except ValueError:
messagebox.showerror("Lỗi", "Điểm phải là số!")
return
self.model.add(data)
self.view.clear_form()
self.refresh()
def delete_student(self):
from tkinter import messagebox
idx = self.view.get_selected_index()
if idx is None:
messagebox.showwarning("Chưa chọn", "Chọn sinh viên cần xóa!")
return
if messagebox.askyesno("Xác nhận", "Xóa sinh viên này?"):
self.model.delete(idx)
self.refresh()
def search(self):
kw = self.view.search_var.get()
results = self.model.search(kw) if kw else self.model.get_all()
self.refresh(results)
if __name__ == "__main__":
root = tk.Tk()
root.title("Quản lý sinh viên — MVC")
root.geometry("550x500")
controller = StudentController(root)
root.mainloop()
Bài tập thực hành
Trắc nghiệm
-
Lợi ích chính khi dùng OOP với Tkinter là gì?
- A. Code chạy nhanh hơn
- B. Tổ chức rõ ràng, dễ bảo trì, tái sử dụng
- C. Ít code hơn
- D. Giao diện đẹp hơn tự động
-
Kỹ thuật nào dùng để chuyển đổi màn hình trong Tkinter?
- A.
switch_screen() - B.
navigate() - C.
frame.tkraise() - D.
root.change_page()
- A.
-
Trong mô hình MVC, Model có nhiệm vụ gì?
- A. Hiển thị giao diện
- B. Xử lý dữ liệu và nghiệp vụ
- C. Kết nối View và Controller
- D. Xử lý sự kiện người dùng
Thực hành
- Refactor lại bài Counter ở Bài 1 thành class
CounterAppđầy đủ, thêm history (xem 10 thao tác gần nhất). - Tạo app đa màn hình 3 trang: Trang chủ → Giới thiệu → Liên hệ, có thanh nav ở trên.
- Xây dựng custom widget
CardWidget(Frame bo góc có tiêu đề và nội dung) và tái sử dụng 3 lần trong app. - (Nâng cao) Áp dụng mô hình MVC cho app quản lý danh sách công việc (Todo List): Model (list + JSON), View (Treeview + Form), Controller (CRUD).
Bài trước: Bài 8 – ttk & Style · Bài tiếp: Bài 10 – Dự Án Tổng Hợp