Chuyển tới nội dung chính

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

  1. 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
  2. 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()
  3. 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

  1. 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).
  2. Tạo app đa màn hình 3 trang: Trang chủ → Giới thiệu → Liên hệ, có thanh nav ở trên.
  3. 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.
  4. (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