How To Organize Multi-frame TKinter Application With MVC Pattern
I don’t have much experience of working with TKinter or making Desktop GUI (Graphical User Interface) applications in general. Previously I made one or two desktop GUI application but they were pretty simple. They were mainly for sharing my Python scripts with my non-coder friends & family, with one or two buttons to run the script and maybe a few input options. A single file would contain the whole GUI code. But recently I planned to make a moderately complex GUI application. I was looking for some idea of how to organize a complex TKinter project. To my surprise, I found very little resources online that talks about it. Most Tkinter tutorial you will find, shows the a basic application written in a single file and the app itself has only one page. Whenever it needs to show a new page, they show it in a new window, instead of changing the view of the current window. But this is not how real life apps work. Apps can have multiple pages, for example, a sign in page, home page, settings page, etc. You won’t keep popping up windows for each page.
After doing some research online, some trial and error, gathering inspiration from different ideas, I finalize a structure that I was happy with. In this article, I am sharing my implementation.
MVC Pattern
Let’s talk about the MVC pattern first. MVC pattern separates the application logic intro three parts — Model, View and Controller. I didn’t find any strict way people follow this pattern. I found slight variations on different implementations. But the main concept is, the View part is only concerned about the representation of the data, but not the modification or updates. Whenever the user does some interaction, the Controller handles that event and updates the Model. Model carries the application data and the business logic. Whenever the Model data changes, the View gets updated.
In all implementations, the model is completely independent from the other two parts. It has no idea about the controller or the view. The controller has dependency on both the view and the model. The view may have dependency on the model (i.e. it needs to know the model to know how to represent in to the user), or we can also make the view completely independent of the model. In the later case, the controller is the bridge that knows how to translate the model to the view, and how to translate the user interaction back to the model.
In my implementation, both the model and the view are completely independent. The controller needs to know about the model and the view. The model is observable, meaning, you can add event listeners to the model. Whenever any data changes, it will trigger the corresponding event so that any part of the application that depends on that data can react to the changes. My controller observes the data changes. So whenever the model gets updated, the controller can update the view. Also when user interacts in the interface, the controller handles that event and updates the model and the view accordingly. Below is a diagram of the application.
Project Structure
We are going to build a project that has three pages or views — a sign in page, a sign up page and a home page. Each page has corresponding controllers. We will have one Auth model. To organize the business logic, like network calls, or database crud operations, we can have a service layer that the model communicates with but that is out of the scope of this article so I will be skipping it. The project structure looks like this,
root/
| - models/
| | - main.py
| | - base.py
| | - auth.py
| - views/
| | - main.py
| | - root.py
| | - signin.py
| | - signup.py
| | - home.py
| - controllers/
| | - main.py
| | - signin.py
| | - signup.py
| | - home.py
| - main.py
Model
First we want to define the base class of an observable model (models that can register event listeners). In the base.py
file, we define a class named ObservableModel
.
class ObservableModel:
def __init__(self):
self._event_listeners = {}
def add_event_listener(self, event, fn):
try:
self._event_listeners[event].append(fn)
except KeyError:
self._event_listeners[event] = [fn]
return lambda: self._event_listeners[event].remove(fn)
def trigger_event(self, event):
if event not in self._event_listeners.keys():
return
for func in self._event_listeners[event]:
func(self)
In the __init__
function, we initialize an empty dictionary _event_listeners
. This is going to store all the listeners.
We add a method add_event_listener
that takes an event name and a callback function and stores it in the dictionary. The event name is used as the dictionary key and each key stores a list of callback functions for that event. This method returns a function which can be called to remove the listener.
We add another method trigger_event
which takes the event name as the parameter, checks if any callback function is registered for that event, and if yes, calls all the callback functions registered for that event. The callback functions receive the model instance as the only argument.
Now we can define our Auth
model. It has a login
and a logout
method. In real scenario, they would perform some business logic to do actual authentication, but for simplicity we are just changing the data and skipping business logic.
from .base import ObservableModel
class Auth(ObservableModel):
def __init__(self):
super().__init__()
self.is_logged_in = False
self.current_user = None
def login(self, user):
self.is_logged_in = True
self.current_user = user
self.trigger_event("auth_changed")
def logout(self):
self.is_logged_in = False
self.current_user = None
self.trigger_event("auth_changed")
In the main.py
file, we define a Model
class. Here we are only initializing the Auth model as it is the only model in our app. If we had other models, they would get initialized here too.
from .auth import Auth
class Model:
def __init__(self):
self.auth = Auth()
View
I am going to assume you are familiar with the basic concepts of Tkinter and skip Tkinter specific explanations. As you know, we need a root or master window for our GUI application. So we are going to define the root window first in the root.py
file. We will define a Root
class that is going to inherit the Tk
class imported from tkinter
.
from tkinter import Tk
class Root(Tk):
def __init__(self):
super().__init__()
start_width = 500
min_width = 400
start_height = 300
min_height = 250
self.geometry(f"{start_width}x{start_height}")
self.minsize(width=min_width, height=min_height)
self.title("TKinter MVC Multi-frame GUI")
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
Next we are going to define each of our views. Each view class will inherit the Frame
class from tkinter
.
Here is the SignInView
defined in signin.py
file.
from tkinter import Frame, Label, Entry, Button
class SignInView(Frame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=1)
self.header = Label(self, text="Sign In with existing account")
self.header.grid(row=0, column=0, columnspan=2, padx=10, pady=10)
self.username_label = Label(self, text="Username")
self.username_input = Entry(self)
self.username_label.grid(row=1, column=0, padx=10, sticky="w")
self.username_input.grid(row=1, column=1, padx=(0, 20), sticky="ew")
self.password_label = Label(self, text="Password")
self.password_input = Entry(self, show="*")
self.password_label.grid(row=2, column=0, padx=10, sticky="w")
self.password_input.grid(row=2, column=1, padx=(0, 20), sticky="ew")
self.signin_btn = Button(self, text="Sign In")
self.signin_btn.grid(row=3, column=1, padx=0, pady=10, sticky="w")
self.signup_option_label = Label(self, text="Don't have an account?")
self.signup_btn = Button(self, text="Sign Up")
self.signup_option_label.grid(row=4, column=1, sticky="w")
self.signup_btn.grid(row=5, column=1, sticky="w")
The SignUpView
in signup.py
file,
from tkinter import Frame, Label, Entry, Checkbutton, Button, BooleanVar
class SignUpView(Frame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.grid_columnconfigure(0, weight=0)
self.grid_columnconfigure(1, weight=1)
self.header = Label(self, text="Create a new account")
self.header.grid(row=0, column=0, columnspan=2, padx=10, pady=10)
self.fullname_label = Label(self, text="Full Name")
self.fullname_input = Entry(self)
self.fullname_label.grid(row=1, column=0, padx=10, sticky="w")
self.fullname_input.grid(row=1, column=1, padx=(0, 20), sticky="ew")
self.username_label = Label(self, text="Username")
self.username_input = Entry(self)
self.username_label.grid(row=2, column=0, padx=10, sticky="w")
self.username_input.grid(row=2, column=1, padx=(0, 20), sticky="ew")
self.password_label = Label(self, text="Password")
self.password_input = Entry(self, show="*")
self.password_label.grid(row=3, column=0, padx=10, sticky="w")
self.password_input.grid(row=3, column=1, padx=(0, 20), sticky="ew")
self.has_agreed = BooleanVar()
self.agreement = Checkbutton(
self,
text="I've agreed to the Terms & Conditions",
variable=self.has_agreed,
onvalue=True,
offvalue=False,
)
self.agreement.grid(row=4, column=1, padx=0, sticky="w")
self.signup_btn = Button(self, text="Sign Up")
self.signup_btn.grid(row=5, column=1, padx=0, pady=10, sticky="w")
self.signin_option_label = Label(self, text="Already have an account?")
self.signin_btn = Button(self, text="Sign In")
self.signin_option_label.grid(row=6, column=1, sticky="w")
self.signin_btn.grid(row=7, column=1, sticky="w")
And the HomeView
in home.py
,
from tkinter import Frame, Label, Button
class HomeView(Frame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.grid_columnconfigure(0, weight=1)
self.header = Label(self, text="Home")
self.header.grid(row=0, column=0, padx=10, pady=10, sticky="ew")
self.greeting = Label(self, text="")
self.greeting.grid(row=1, column=0, padx=10, pady=10, sticky="ew")
self.signout_btn = Button(self, text="Sign Out")
self.signout_btn.grid(row=2, column=0, padx=10, pady=10)
Notice that we only defined the views, but we didn’t define any methods as the button commands. Our view is completely ignorant of the model and the controller. It has no responsibility of what to do when user clicks a button or when any data changes. We will see in the controller section how to handle those.
Changing the View
Now comes the interesting concept. How do we render the views and how do we switch between them? Let’s handle that in the main.py
file in the views
folder.
from .root import Root
from .home import HomeView
from .signin import SignInView
from .signup import SignUpView
class View:
def __init__(self):
self.root = Root()
self.frames = {}
self._add_frame(SignUpView, "signup")
self._add_frame(SignInView, "signin")
self._add_frame(HomeView, "home")
def _add_frame(self, Frame, name):
self.frames[name] = Frame(self.root)
self.frames[name].grid(row=0, column=0, sticky="nsew")
def switch(self, name):
frame = self.frames[name]
frame.tkraise()
def start_mainloop(self):
self.root.mainloop()
In the __init__
method, we initialize a dictionary named frames
which is going to store all the frames (pages) of our application. We defined a helper method _add_frame
to initialize and store the frames. The way we place the frames in the root window is we stack them over one another. the sticky='nsew'
is going to ensure that each frame is going to cover the whole root window, so that elements from other frames below cannot be seen.
The switch
method is going to handle the transition between pages. When we pass the frame name to this function, it is going to bring forward that frame by calling the tkraise
method.
You might be concerned about this approach where we are having all the pages generated but some pages may not be ever needed as user may visit them very rarely, for example, the signup page. If your app has many frames and you think that is going to cause performance issue, you can modify this implementation. For example, before switching to a new view, the switch
method will destroy the current frame by calling the destroy
method, and generate the frame that is going to be shown. We will need to store the frame classes instead of the frame instances in this case so that we can generate the frames on demand. Here is a modified implementation,
from .root import Root
from .home import HomeView
from .signin import SignInView
from .signup import SignUpView
class View:
def __init__(self):
self.root = Root()
self.frame_classes = {
"signin": SignInView,
"signup": SignUpView,
"home": HomeView,
}
self.current_frame = None
def switch(self, name):
new_frame = self.frame_classes[name](self.root)
if self.current_frame is not None:
self.current_frame.destroy()
self.current_frame = new_frame
self.current_frame.grid(row=0, column=0, sticky="nsew")
def start_mainloop(self):
self.root.mainloop()
The controller depends on how the model and the view is implemented. I am going to explain the controller assuming we went with the first approach here (generated all the frames beforehand). If you wish to go with the second approach, you may need more modification on the View
and the Controller
classes which I am not going to discuss.
Controller
Let’s now look at the controllers, starting with the home controller. Notice that our home view is very simple. It has a greeting message for the user and a button to logout. So, what our controller needs to control in this view, is the greeting message and the logout button. This is the code in the home.py
controller file,
class HomeController:
def __init__(self, model, view):
self.model = model
self.view = view
self.frame = self.view.frames["home"]
self._bind()
def _bind(self):
self.frame.signout_btn.config(command=self.logout)
def logout(self):
self.model.auth.logout()
def update_view(self):
current_user = self.model.auth.current_user
if current_user:
username = current_user["username"]
self.frame.greeting.config(text=f"Welcome, {username}!")
else:
self.frame.greeting.config(text=f"")
To initialize the controller we need an instance of the Model
class and an instance of the View
class. We defined a _bind
method that binds the logout
method as the command for the sign out
button in the view. We also defined another method update_view
that updates the greeting message.
Our other two controllers are also similar. Here is the signin.py
controller file,
class SignInController:
def __init__(self, model, view):
self.model = model
self.view = view
self.frame = self.view.frames["signin"]
self._bind()
def _bind(self):
self.frame.signin_btn.config(command=self.signin)
self.frame.signup_btn.config(command=self.signup)
def signup(self):
self.view.switch("signup")
def signin(self):
username = self.frame.username_input.get()
pasword = self.frame.password_input.get()
data = {"username": username, "password": pasword}
print(data) # of course we don't want to print the password in a real app!
self.frame.password_input.delete(0, last=len(pasword))
self.model.auth.login(data)
And here is the signup.py
controller file,
class SignUpController:
def __init__(self, model, view):
self.model = model
self.view = view
self.frame = self.view.frames["signup"]
self._bind()
def _bind(self):
self.frame.signup_btn.config(command=self.signup)
self.frame.signin_btn.config(command=self.signin)
def signin(self):
self.view.switch("signin")
def signup(self):
data = {
"fullname": self.frame.fullname_input.get(),
"username": self.frame.username_input.get(),
"password": self.frame.password_input.get(),
"has_agreed": self.frame.has_agreed.get(),
}
print(data)
self.model.auth.login(data)
Now, let’s see what the controller main.py
file looks like,
from .home import HomeController
from .signin import SignInController
from .signup import SignUpController
class Controller:
def __init__(self, model: Model, view: View:
self.view = view
self.model = model
self.signin_controller = SignInController(model, view)
self.signup_controller = SignUpController(model, view)
self.home_controller = HomeController(model, view)
self.model.auth.add_event_listener(
"auth_changed", self.auth_state_listener
)
def auth_state_listener(self, data):
if data.is_logged_in:
self.home_controller.update_view()
self.view.switch("home")
else:
self.view.switch("signin")
def start(self):
# self.model.auth.load_auth_state()
if self.model.auth.is_logged_in:
self.view.switch("home")
else:
self.view.switch("signin")
self.view.start_mainloop()
Here, in the __init__
method, we have initialized the controllers. We have also added an event listener for the “auth_changed” event. The listener function auth_state_listener
will switch the view to “signin” view if the user get logged out. Similarly, if the user get logged in, it will change the view to “home”. We also have a start
method to start the GUI. Controller controls everything so it makes sense that the controller starts the view mainloop
. Also, if we need to perform any operation before launching the GUI, for example, check the saved auth state, we can add that logic here. I commented out an example function call to give you an idea. And based on the auth state, I am selecting the view to show to the user when the GUI starts. Then finally, launching the GUI by calling the start_mainloop
method of the view object.
Combining Everything Together
Now it’s time to combine our model, view and controller in the app’s main.py
file. This is what the root main.py
file looks like,
from models.main import Model
from views.main import View
from controllers.main import Controller
def main():
model = Model()
view = View()
controller = Controller(model, view)
controller.start()
if __name__ == "__main__":
main()
I have the whole project uploaded in this Github repo. There I also have the type annotation which I have omitted here to keep the examples more focused on the logic.
If you have any suggestion on how to improve this, let me know in the comment!
Happy coding!
(If you found this article helpful, give it some claps! If you want to connect with me, send me a request on Twitter@AhsanShihab_.)