Skip to content

Instantly share code, notes, and snippets.

@Crozzers
Last active June 8, 2024 15:50
Show Gist options
  • Save Crozzers/145fed1b9075c32580a84157bf64b8eb to your computer and use it in GitHub Desktop.
Save Crozzers/145fed1b9075c32580a84157bf64b8eb to your computer and use it in GitHub Desktop.
A scrollable frame class in tkinter.
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