Introduction

In this project we demonstrate some features of responsive design and how to implement them using Tkinter. We implement the following responsive design techniques: responsive font size, automatic text wrapping, and responsive layout.

The responsive behavior in this project is dependent on the width of the program window.

Project Structure

As with our previous Tkinter programs we utilize the MVC software architectural pattern. Our file structure is below.

flexview/
| - flexview/
|   | - __init__.py
|   | - application.py
|   | - models.py
|   | - styles.py
|   | - views.py
| - flexview.py
| - longtext.txt
| - README.md

Responsive Design

The focus of this project is utilizing responsive design. We want to recreate features of CSS responsive design in the Tkinter setting. The following subsections demonstrate how to implement responsive font size, automatic text wrapping, and create a responsive layout.

Responsive Font Size

When creating a text document or writing a report, we are required to set a font and a font size. Often times we have a section header with a larger font size than the text of the body. This works well when we have pages with a fixed width. Sometimes, however, we need to adjust our font size when working with larger screens. This can increase readability and reduce excess white space. Let's take a look at our program with default width and then stretch it out a bit.

Image 1: Default Layout Comparison

These images are aligned pixel for pixel. Each image has the title "Responsive Window Design" above their text body. What is different is the font size. In the wider image we use a larger font size. Let's take a look at how this is accomplished. In views.py we have

self.title = ttk.Label(self.scrollable_frame, text="Responsive Window Design", style='title.TLabel')

Then, in styles.py we have

# Styles for mainview title widget
self.configure('title.TLabel', font=('Helvetica', 20))

This style is used to set the default font size seen in the top image. To have a responsive font size we need to be a bit more creative. This is accomplished using the following method from our StyleSheet class in styles.py:

def update_title_fontsize(self, width):
    """Updates fontsize based on width."""

    min_font_size = 14
    max_font_size = 30

    calculated_font = round((2/165) * (width - 600) + 14)

    new_font_size = min(max(calculated_font, min_font_size), max_font_size)

    self.configure('title.TLabel', font=('Helvetica', new_font_size))

With this method we can update the font size for widgets with the title.TLabel style. This method works by setting an upper and lower bound for font size. We then make a calculated font size using a linear equation. Finally, we check if our font size is too large or too small and calculate the new font size for our title.

So, how does the program know when to update the view? This is accomplished using an event binding. In particular, in views.py we have

# ---------- Event Bindings ---------- #
self.canvas.bind("<Configure>", self.responsive_page)

This detects when the canvas widget changes size. The size of the canvas widget is important since our view is built on top of it. So, once a change in the size of the canvas widget is detected it triggers an event. This in turn calls the self.responsive_page method we bound to the event.

def responsive_page(self, event):
    """
    Utilize canvas width to add responsive behavior to view.

    This includes
    * Responsive grid
    * Responsive font size
    * Responsive Label widget wraplength

    :arguments
    ----------
    event : tkinter.Event
        Collects information from a triggered event. In this case it
        includes information about canvas width.
    """

    canvas_width = event.width  # can also use self.canvas.winfo_width()

    # Adjust canvas_window width to allow scrollable frame width
    # to expand to width of canvas
    self.canvas.itemconfigure(self.canvas_window, width=canvas_width)

    # DO NOT DELETE: Required to ensure scrollbars update properly
    self.canvas.configure(scrollregion=self.canvas.bbox("all"))

    # Adjust Grid as needed.
    if canvas_width >= 600:  # Regular / Wide view
        self.final_frame.grid(row=0, column=1)
        self.media_frame.grid_columnconfigure(1, weight=2)
    else:  # Mobile / Thin view
        self.final_frame.grid(row=1, column=0)
        self.media_frame.grid_columnconfigure(1, weight=0)

    # Update title font size based on widget width.
    self.callbacks['update_title_fontsize'](canvas_width)

    # Update wraplength based on widget width.
    self.text.config(wraplength=canvas_width)

This method controls all of our responsive behavior. Along with updating the title font size, this method helps enforce automatic text wrapping and updating the layout. Each of these responsive behaviors are based on the program window width, though we take our width measurements on the canvas widget which is a fixed number of pixels thinner.

Once we have the canvas width we send it to the method in our StyleSheet class to calculate the new font size.

Note: We calculate width based on a canvas widget since this widget is crucial for creating a scrollable frame. Having a scrollable frame is important for a responsive layout if the window height becomes too short. If necessary, we could add a constant value to the canvas width to calculate the window width.

Automatic Text Wrapping

As with responsive font size, we use the same event binding and method to update the width allowed for text wrapping. This is done by setting the wraplength parameter to the width of the canvas widget when the responsive_page method is called. This is straight forward in this application, however, if we added padding to the self.text label widget we would have to take that into account to ensure our text takes up the correct amount of space.

Responsive Layout

As with our other responsive features, we utilize the responsive_page method to update the layout. In the view, we utilize the grid geometry manager to position our widgets. Thus, we can simply update our grid placement to change the grid. The grid layout we use is based on the width of our canvas. Below we can see our program with a thinner window. Notice how the Title is very small, the text now takes up more lines due to wrapping and instead of a side by side layout for our gold and silver rectangles one is above the other.

Image 2: Thin Layout Top & Bottom

Code

In this section we include the majority of the program's code. The full code can be seen on Github. Note that __init__.py is empty and is only present so that python treats the inner flexview directory as a package. We also have the small file flexview.py which is used to start the program:

from flexview.application import Application

if __name__ == '__main__':
    app = Application()
    app.mainloop()

The remainder of the code included in this post are in the following subsections.

application.py

import tkinter as tk
from tkinter import ttk
from . models import DataModel
from . import views as v
from . styles import StyleSheet


class Application(tk.Tk):
    """Controller for model and view."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.wm_title("Flex View")
        self.geometry("800x600")

        self.callbacks = {
            'get_text': self.get_text,
            'update_title_fontsize': self.update_title_fontsize,
        }

        self.datamodel = DataModel()
        self.stylesheet = StyleSheet()
        self.mainview = v.MainView(self, self.callbacks)

        # Setup Grid
        self.mainview.grid(row=0, column=0, sticky='nsew')

        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

    def get_text(self):
        """Used to request text data from the model."""

        return self.datamodel.long_text

    def update_title_fontsize(self, width):
        """
        Used to update title font size when window changes.

        :arguments
        ----------
        width: int
            width of screen in pixels
        """

        self.stylesheet.update_title_fontsize(width)

views.py

import tkinter as tk
from tkinter import ttk


class MainView(ttk.Frame):
    """Main view for application."""

    def __init__(self, master, callbacks, *args, **kwargs):
        """
        Initialize MainView

        :arguments
        ----------
        master : tkinter object
            parent widget for MainView
        callbacks : dictionary
            contains references to callable methods in `master`
        """

        super().__init__(master, *args, **kwargs)

        self.callbacks = callbacks

        # ---------- Scrollable Frame Setup ---------- #
        # Create scrollable frame using canvas with adjacent vertical scrollbar
        # This requires seting up a canvas widget with the interior frame
        # attached using the .create_window method (cannot grid onto a canvas)

        # Canvas setup
        # highlightthickness = 0 is important to ensure canvas start appears at (0, 0)
        self.canvas = tk.Canvas(self, bg='red', bd=0, highlightthickness=0)
        self.v_scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.canvas.yview)

        self.canvas.grid(row=0, column=0, sticky='nsew')
        self.v_scrollbar.grid(row=0, column=1, sticky='ns')

        self.canvas.configure(yscrollcommand=self.v_scrollbar.set)
        self.canvas.bind('<Configure>', lambda e: self.canvas.configure(scrollregion = self.canvas.bbox("all")))

        # Interior scrollable frame setup
        self.scrollable_frame = ttk.Frame(self.canvas, style='scrollframe.TFrame')
        self.canvas_window = self.canvas.create_window((0,0), window=self.scrollable_frame, anchor="nw")

        # Grid configuration
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(0, weight=1)

        self.scrollable_frame.grid_columnconfigure(0, weight=1)
        self.scrollable_frame.grid_rowconfigure(0, weight=1)

        # ---------- Scrollable Frame Content ---------- #
        self.title = ttk.Label(self.scrollable_frame, text="Responsive Window Design", style='title.TLabel')
        example_text = self.callbacks['get_text']()
        self.text = ttk.Label(self.scrollable_frame, text=example_text, justify='left', style='text.TLabel')
        self.media_frame = ttk.Frame(self.scrollable_frame, style='media.TFrame')

        self.title.grid(row=0, column=0, sticky='nsew')
        self.text.grid(row=1, column=0, sticky='nsew')
        self.media_frame.grid(row=2, column=0, sticky='nsew')

        self.media_frame.grid_columnconfigure(0, weight=1)
        self.media_frame.grid_columnconfigure(1, weight=2)

        # ---------- Media Frame Content ---------- #
        self.initial_frame = ttk.Frame(self.media_frame, style='initial.TFrame', height=200)
        self.final_frame = ttk.Frame(self.media_frame, style='final.TFrame', height=200)

        self.initial_frame.grid(row=0, column=0, sticky='nsew', padx=10, pady=10)
        self.final_frame.grid(row=0, column=1, sticky='nsew', padx=10, pady=10)

        # ---------- Event Bindings ---------- #
        self.canvas.bind("<Configure>", self.responsive_page)

    def responsive_page(self, event):
        """
        Utilize canvas width to add responsive behavior to view.

        This includes
        * Responsive grid
        * Responsive font size
        * Responsive Label widget wraplength

        :arguments
        ----------
        event : tkinter.Event
            Collects information from a triggered event. In this case it
            includes information about canvas width.
        """

        canvas_width = event.width  # can also use self.canvas.winfo_width()

        # Adjust canvas_window width to allow scrollable frame width
        # to expand to width of canvas
        self.canvas.itemconfigure(self.canvas_window, width=canvas_width)

        # DO NOT DELETE: Required to ensure scrollbars update properly
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

        # Adjust Grid as needed.
        if canvas_width >= 600:  # Regular / Wide view
            self.final_frame.grid(row=0, column=1)
            self.media_frame.grid_columnconfigure(1, weight=2)
        else:  # Mobile / Thin view
            self.final_frame.grid(row=1, column=0)
            self.media_frame.grid_columnconfigure(1, weight=0)

        # Update title font size based on widget width.
        self.callbacks['update_title_fontsize'](canvas_width)

        # Update wraplength based on widget width.
        self.text.config(wraplength=canvas_width)

styles.py

import tkinter as tk
from tkinter import ttk


class StyleSheet(ttk.Style):
    """The StyleSheet class holds the styles for the application."""

    def __init__(self, *args, **kwargs):

        super().__init__(*args, **kwargs)

        # Styles for mainview title widget
        self.configure('title.TLabel', font=('Helvetica', 20))

        # Styles for mainview text widget
        self.configure('text.TLabel', font=("Helvetica", 11))

        # Styles for mainview media_frame widget
        self.configure('media.TFrame', background='black')

        # Styles for widgets contained in media_frame
        self.configure('initial.TFrame', background='silver')
        self.configure('final.TFrame', background='gold')

    def update_title_fontsize(self, width):
        """Updates fontsize based on width."""

        min_font_size = 14
        max_font_size = 30

        calculated_font = round((2/165) * (width - 600) + 14)

        new_font_size = min(max(calculated_font, min_font_size), max_font_size)

        self.configure('title.TLabel', font=('Helvetica', new_font_size))

models.py

class DataModel:
    """Model which manages how program interacts with data."""

    def __init__(self):
        self.long_text = self.get_text("longtext.txt")

    @property
    def long_text(self):
        return self._long_text

    @long_text.setter
    def long_text(self, new_data):
        self._long_text = new_data

    def get_text(self, fp):
        """
        Get text data from text file

        :arguments
        ----------
        fp: str
            file path of file being loaded
        """

        try:
            with open(fp, 'rb') as f:
                results = f.read()
        except FileNotFoundError:
                results = "Loading Error - Text File Not Found."
        return results

Conclusion

Overall, it appears that responsive design is very manageable with Tkinter. We were successfully able to implement responsive font size, automatic text wrapping, and a responsive layout. Of course these are simple examples, but we can scale each of these methods up to larger and more complex problems.

Default Layout Comparison with Wider Page Below Thin Layout