Last active
June 8, 2024 15:50
-
-
Save Crozzers/145fed1b9075c32580a84157bf64b8eb to your computer and use it in GitHub Desktop.
A scrollable frame class in tkinter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import platform | |
import tkinter as tk | |
from typing import Union | |
class ScrollableFrame(tk.Frame): | |
''' | |
A class used to create a *mostly* tkinter compatible frame that is scrollable. | |
It works by creating a master frame which contains a canvas and scrollbar(s). | |
The canvas then contains a frame, which widgets will be placed on. | |
This is how the widget is structured: | |
self._master | |
- self._canvas | |
- self | |
- [widgets go here] | |
- self._scrollbar_y | |
- self._scrollbar_x | |
Because of this un-conventional structure, `self.winfo_children` will return `self._master.winfo_children` | |
and `self.winfo_children` in one list. | |
''' | |
scroll_keys = ('<MouseWheel>',) if platform.system() == 'Windows' else ('<4>', '<5>') | |
def __init__(self, parent, scroll_axis: str = 'both', **kwargs): | |
''' | |
Initialize tkinter frame and set up the scrolling | |
Args: | |
scroll_axis (str): which axis this frame will be able to scroll across. | |
Can be 'x', 'y' or 'both' | |
**kwargs: keyword arguments. Passed to `tkinter.Frame` init | |
''' | |
# initialize the master frame | |
self._master = tk.Frame(parent, **kwargs) | |
# re-map geometry and winfo related methods of master frame to self | |
for item in dir(self._master): | |
if ( | |
item.startswith(('pack', 'place', 'grid', 'winfo', 'lift', 'lower')) | |
and item != 'winfo_children' | |
): | |
# set self.[function] to self.[function]_scrollable | |
setattr(self, item + '_scrollable', getattr(super(), item)) | |
# set self._master.[function] to self.[function] | |
setattr(self, item, getattr(self._master, item)) | |
# create canvas and init the frame | |
self._canvas = tk.Canvas(self._master) | |
super().__init__(self._canvas, **kwargs) | |
# create window for self. This is where widgets are packed to | |
self._canvas_window = self._canvas.create_window((0, 0), window=self, anchor='nw') | |
# configure grid so that internal widgets will expand to fill the frame | |
self._canvas.grid(row=0, column=0, sticky='nesw') | |
self.grid_columnconfigure(0, weight=1) | |
self.grid_rowconfigure(0, weight=1) | |
# bind configure events to on_configure method | |
self.bind('<Configure>', self.on_configure) | |
self._master.bind('<Configure>', self.on_configure) | |
# create y scrollbar | |
self._scrollbar_y = tk.Scrollbar( | |
self._master, orient='vertical', command=self._canvas.yview | |
) | |
self._canvas.config(yscrollcommand=self._scrollbar_y.set) | |
# create x scrollbar | |
self._scrollbar_x = tk.Scrollbar( | |
self._master, orient='horizontal', command=self._canvas.xview | |
) | |
self._canvas.config(xscrollcommand=self._scrollbar_x.set) | |
# config will grid/remove scrollbars as needed | |
self.config(scroll_axis=scroll_axis) | |
def winfo_children(self) -> list: | |
''' | |
Returns the children of this widget and the internal | |
widgets used to create the scroll frame (eg: scrollbars) | |
''' | |
return self._master.winfo_children() + super().winfo_children() | |
def config(self, scroll_axis: str = None, **kwargs): | |
''' | |
Configures the scrolling frame, canvas and master frame | |
Args: | |
scroll_axis (str): which axis this frame can scroll across. | |
Can be 'x', 'y' or 'both' | |
**kwargs: Keyword arguments for configuring a tkinter frame, | |
passed to super().config() | |
Raises: | |
ValueError: if `scroll_axis` is not 'x', 'y' or 'both' | |
''' | |
if not (kwargs or scroll_axis): | |
# calling a tkinter widget config method with no args | |
# returns keys of configurable properties. | |
# here we return our config and canvas config merged because we will | |
# auto apply properties to whichever widget will take them. | |
# We don't return self._master.config() because that widget is the same type | |
# as self | |
return {**super().config(), **self._canvas.config()} | |
if scroll_axis: | |
self.scroll_axis = scroll_axis | |
if scroll_axis == 'both': | |
self._scrollbar_y.grid(row=0, column=1, sticky='ns') | |
self.bind_scroll() | |
self._scrollbar_x.grid(row=1, column=0, sticky='ew') | |
elif scroll_axis == 'y': | |
self._scrollbar_y.grid(row=0, column=1, sticky='ns') | |
self.bind_scroll() | |
self._scrollbar_x.grid_forget() | |
elif scroll_axis == 'x': | |
self._scrollbar_y.grid_forget() | |
self.unbind_scroll() | |
self._scrollbar_x.grid(row=1, column=0, sticky='ew') | |
else: | |
raise ValueError("scroll_axis must be 'x', 'y' or 'both'") | |
if kwargs: | |
for w in [self, self._canvas, self._master]: | |
try: | |
w.config(**kwargs) | |
except tk.TclError: | |
# try to apply as many kwargs as we can that are relevant to this widget | |
w.config(**{k: v for k, v in kwargs.items() if k in w.config()}) | |
def bind_scroll(self): | |
'''Binds mouse scroll-wheel events to the canvas scroll''' | |
for w in [self._canvas, self, self._master]: | |
for k in self.scroll_keys: | |
w.bind_all(k, self.scroll) | |
def unbind_scroll(self): | |
'''Unbinds mouse scroll-wheel events from the canvas scroll''' | |
for w in [self._canvas, self, self._master]: | |
for k in self.scroll_keys: | |
w.unbind_all(k) | |
def scroll(self, amount: Union[int, tk.Event], axis='y'): | |
''' | |
Scrolls the canvas a set amount | |
Args: | |
amount (int or tk.Event): the amount to scroll by. | |
axis (str): the axis by which to scroll. Can be 'x', 'y' or 'both' | |
''' | |
if type(amount) == tk.Event: | |
if platform.system() == 'Windows': | |
amount = 1 if amount.delta < 0 else -1 | |
else: # linux | |
amount = -1 if amount.num in (4, 6) else 1 | |
if axis in ('both', 'y'): | |
self._canvas.yview_scroll(amount, 'units') | |
if axis in ('both', 'x'): | |
self._canvas.xview_scroll(amount, 'units') | |
def on_configure(self, event: tk.Event): | |
''' | |
Configures the scrolling frame, adjusting width and height and repacking scrollbars as needed. | |
Args: | |
event (tk.Event): ignored | |
''' | |
# unbind config events so this function doesnt get called | |
# while we adjust the widget | |
self.unbind('<Configure>') | |
self._master.unbind('<Configure>') | |
if self._scrollbar_y.winfo_ismapped(): | |
# if we have a y scrollbar then configure the canvas window height | |
# to be either the height of the canvas or the height of the frame | |
# inside the canvas, whichever is bigger | |
height = max(self._canvas.winfo_reqheight(), super().winfo_reqheight()) | |
else: | |
# if we don't have a y scrollbar then just set the height of the window | |
# as the height of the canvas | |
height = self._canvas.winfo_height() | |
if self._scrollbar_x.winfo_ismapped(): | |
# same logic as height stuff just above, but for the width. | |
width = max(self._canvas.winfo_reqwidth(), super().winfo_reqwidth()) | |
else: | |
width = self._canvas.winfo_width() | |
self._canvas.itemconfigure(self._canvas_window, width=width, height=height) | |
self._canvas.configure(scrollregion=self._canvas.bbox('all')) | |
# decide if each scrollbar is needed to fit widgets on | |
if self.scroll_axis in ('both', 'y'): | |
if super().winfo_reqheight() <= self._canvas.winfo_height(): | |
self._scrollbar_y.grid_forget() | |
# move scrollbar back to 0 position (the top) | |
self._canvas.yview_moveto(0) | |
self.unbind_scroll() | |
else: | |
self._scrollbar_y.grid(row=0, column=1, sticky='ns') | |
self.bind_scroll() | |
if self.scroll_axis in ('both', 'x'): | |
if super().winfo_reqwidth() <= self._canvas.winfo_width(): | |
self._scrollbar_x.grid_forget() | |
# move scrollbar back to 0 position (the left) | |
self._canvas.xview_moveto(0) | |
else: | |
self._scrollbar_x.grid(row=1, column=0, sticky='ew') | |
# re-bind the configure events after adjustments are done | |
self.bind('<Configure>', self.on_configure) | |
self._master.bind('<Configure>', self.on_configure) | |
def destroy(self): | |
'''Destroys the scrollable frame''' | |
self.unbind_all('<Configure>') | |
self._master.unbind_all('<Configure>') | |
self.unbind_scroll() | |
super().destroy() | |
self._master.destroy() | |
if __name__ == '__main__': | |
def change_sb_axis(): | |
if scrollframe.scroll_axis == 'both': | |
scrollframe.config(scroll_axis='y') | |
elif scrollframe.scroll_axis == 'y': | |
scrollframe.config(scroll_axis='x') | |
elif scrollframe.scroll_axis == 'x': | |
scrollframe.config(scroll_axis='both') | |
root = tk.Tk() | |
scrollframe = ScrollableFrame(root, scroll_axis='y') | |
scrollframe.pack(fill='both', expand=True) | |
tk.Button(scrollframe, text='Change scrollbar axis', command=change_sb_axis).pack(side='top', anchor='w') | |
for i in range(15): | |
tmp = tk.Frame(scrollframe) | |
tmp.pack(side='top') | |
for j in range(10): | |
tk.Label(tmp, text=f'row {i} column {j}').pack(side='left', anchor='w') | |
root.mainloop() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment