pythontkinterscrollable

Tkinter Scrollable Frame scrolls even if all content is fully visible


I have implemented a ScrollableFrame class with Tkinter in python, which works fine when the content is bigger than the window (meaning you scroll to see all content) but when there isn't enough content to justify scrolling, the scrolling still happens, resulting in the content being moved through the frame.

Here is a GIF summarizing this: Scrollable Frame scrolls even when the content is fully visible

Here is a minimal reconstruction of the code (see my workaround in the edits):

import functools
import logging
import tkinter as tk

from sys import platform
from tkinter import ttk
from tkinter.constants import *

fp = functools.partial


class ScrollableFrame(ttk.Frame):
    """
       A scrollable frame with a scroll bar to the right.
       Add content to the scrollable area by making self.interior the root object.
    """

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

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

        # The Scrollbar, layout to the right
        self._scrollbar = ttk.Scrollbar(self, orient="vertical")
        self._scrollbar.grid(row=0, column=1, sticky="nes")

        # The Canvas which supports the Scrollbar Interface, layout to the left
        self._canvas = tk.Canvas(self, bd=0, highlightthickness=0)
        self._canvas.grid(row=0, column=0, sticky="news")

        # Bind the Scrollbar to the canvas Scrollbar Interface
        self._canvas.configure(yscrollcommand=self._scrollbar.set)
        self._scrollbar.configure(command=self._canvas.yview)

        # Reset the view
        self._canvas.xview_moveto(0)
        self._canvas.yview_moveto(0)

        # The scrollable area, placed into the canvas
        # All widgets to be scrolled have to use this Frame as parent
        self.interior = ttk.Frame(self._canvas)
        self._canvas_frame = self._canvas.create_window(0, 0,
                                                        window=self.interior,
                                                        anchor=NW)

        self.interior.bind("<Configure>", self._configure_interior)
        self._canvas.bind("<Configure>", self._configure_canvas)

        # Bind mousewheel when the mouse is hovering the canvas
        self._canvas.bind('<Enter>', self._bind_to_mousewheel)
        self._canvas.bind('<Leave>', self._unbind_from_mousewheel)

    def _configure_interior(self, event):
        """
        Configure canvas size and scroll region according to the interior frame's size
        """
        logging.getLogger().debug(f"_configure_interior")
        size = (self.interior.winfo_reqwidth(), self.interior.winfo_reqheight())
        self._canvas.config(scrollregion="0 0 %s %s" % size)
        if self.interior.winfo_reqwidth() != self._canvas.winfo_width():
            # Update the canvas's width to fit the inner frame.
            self._canvas.config(width=self.interior.winfo_reqwidth())

    def _configure_canvas(self, event):
        logging.getLogger().debug(f"_configure_canvas")
        if self.interior.winfo_reqwidth() != self._canvas.winfo_width():
            # Update the inner frame's width to fill the canvas.
            self._canvas.itemconfigure(self._canvas_frame,
                                       width=self._canvas.winfo_width())

    def _on_mousewheel(self, event, scroll=None):
        """
        Can handle windows or linux
        """
        if platform == "linux" or platform == "linux2":
            self._canvas.yview_scroll(int(scroll), "units")
        else:
            self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

    def _bind_to_mousewheel(self, event):
        if platform == "linux" or platform == "linux2":
            self._canvas.bind_all("<MouseWheel>", fp(self._on_mousewheel, scroll=-1))
            self._canvas.bind_all("<Button-5>", fp(self._on_mousewheel, scroll=1))
        else:
            self.bind_all("<MouseWheel>", self._on_mousewheel)

    def _unbind_from_mousewheel(self, event):

        if platform == "linux" or platform == "linux2":
            self._canvas.unbind_all("<Button-4>")
            self._canvas.unbind_all("<Button-5>")
        else:
            self.unbind_all("<MouseWheel>")


class App(tk.Tk):
    def __init__(self):
        super().__init__()

        items = 10
        sbf = ScrollableFrame(self)
        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)
        sbf.grid(row=0, column=0, sticky='nsew')

        frame = sbf.interior
        frame.grid_columnconfigure(0, weight=1)
        frame.grid_columnconfigure(1, weight=1)
        for row in range(items):
            text = "%s" % row
            tk.Label(frame, text=text, width=3, borderwidth=0, relief="solid").grid(row=row, column=0, sticky="news")

            text = "this is the second column for row %s" % row
            tk.Label(frame, text=text).grid(row=row, column=1, sticky="news")

        label = ttk.Label(self, text="This is a label")
        label.grid(row=1, column=0, columnspan=2, sticky="nw")


if __name__ == "__main__":
    App().mainloop()

My implementation was inspired by https://stackoverflow.com/a/62446457/18258194 and https://stackoverflow.com/a/29322445/18258194. Both of which don't show the subcase where the content is fully visible and doesn't require scrolling.

EDIT 1: I've tried adding to the function _configure_interior a reset to the view if the requested y position is positive:

    def _configure_interior(self, event):
    """
    Configure canvas size and scroll region according to the interior frame's size
    """
    logging.getLogger().debug(f"_configure_interior")
    reqy = event.y
    if reqy > 0:  # requested to move below the canvas's top
        logging.getLogger().debug(f"resetting view to zero")
        self._canvas.yview_moveto(0)

    reqwidth, reqheight = self.interior.winfo_reqwidth(), self.interior.winfo_reqheight()
    self._canvas.config(scrollregion=f"0 0 {reqwidth} {reqheight}")
    if self.interior.winfo_reqwidth() != self._canvas.winfo_width():
        # Update the canvas's width to fit the inner frame.
        self._canvas.config(width=self.interior.winfo_reqwidth())

Which does undo the content being separated from the top, but this results in a jittery UI, because it firstly moves the content down, and then returns it to the top.

If I'll be able to override the content going down too much in the first place, then I'll have solved the issue. Anyone knows how to find who is moving the content down, and how to override it?


Solution

  • My solution included fixing the two things which move the content around:

    1. The mouse binding to canvas.yview_scroll.
    2. The scrollbar binding to canvas.yview.

    The first issue was solved by switching yview_scroll with yview_moveto and then making sure the value sent to the function is valid.

    The second issue was solved by giving the scrollbar's command a custom wrapper of canvas.yview and then making sure the value is valid.

    Here is the final working code:

    class ScrollableFrame(ttk.Frame):
    """
       A scrollable frame with a scroll bar to the right, which can be moved using the mouse wheel.
    
       Add content to the scrollable area by making self.interior the root object.
    
       Taken from
    """
    def __init__(self, root, *args, **kwargs):
        super().__init__(root, *args, **kwargs)
    
        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)
    
        # The Scrollbar, layout to the right
        self._scrollbar = ttk.Scrollbar(self, orient="vertical")
        self._scrollbar.grid(row=0, column=1, sticky="nes")
    
        # The Canvas which supports the Scrollbar Interface, layout to the left
        self._canvas = tk.Canvas(self, bd=0, highlightthickness=0)
        self._canvas.grid(row=0, column=0, sticky="news")
    
        # Bind the Scrollbar to the canvas Scrollbar Interface
        self._canvas.configure(yscrollcommand=self._scrollbar.set)
        self._scrollbar.configure(command=self.yview_wrapper)
    
        # Reset the view
        self._canvas.xview_moveto(0)
        self._canvas.yview_moveto(0)
    
        # The scrollable area, placed into the canvas
        # All widgets to be scrolled have to use this Frame as parent
        self.interior = ttk.Frame(self._canvas)
        self._canvas_frame = self._canvas.create_window(0, 0,
                                                        window=self.interior,
                                                        anchor=NW)
    
        self.interior.bind("<Configure>", self._on_interior_configure)
        self._canvas.bind("<Configure>", self._on_canvas_configure)
    
        # Bind mousewheel when the mouse is hovering the canvas
        self._canvas.bind('<Enter>', self._bind_to_mousewheel)
        self._canvas.bind('<Leave>', self._unbind_from_mousewheel)
    
    def yview_wrapper(self, *args):
        logging.getLogger().debug(f"yview_wrapper({args})")
        moveto_val = float(args[1])
        new_moveto_val = str(moveto_val) if moveto_val > 0 else "0.0"
        return self._canvas.yview('moveto', new_moveto_val)
    
    def _on_interior_configure(self, event):
        """
        Configure canvas size and scroll region according to the interior frame's size
        """
        reqwidth, reqheight = self.interior.winfo_reqwidth(), self.interior.winfo_reqheight()
        self._canvas.config(scrollregion=f"0 0 {reqwidth} {reqheight}")
        if self.interior.winfo_reqwidth() != self._canvas.winfo_width():
            # Update the canvas's width to fit the inner frame.
            self._canvas.config(width=self.interior.winfo_reqwidth())
    
    def _on_canvas_configure(self, event):
        logging.getLogger().debug(f"_configure_canvas")
        if self.interior.winfo_reqwidth() != self._canvas.winfo_width():
            # Update the inner frame's width to fill the canvas.
            self._canvas.itemconfigure(self._canvas_frame,
                                       width=self._canvas.winfo_width())
    
    def _on_mousewheel(self, event, scroll=None):
        """
        Can handle windows or linux
        """
        speed = 1 / 6
        if platform == "linux" or platform == "linux2":
            fraction = self._scrollbar.get()[0] + scroll * speed
        else:
            units = event.delta / 120
            fraction = self._scrollbar.get()[0] - units * speed
    
        fraction = max(0, fraction)
        self._canvas.yview_moveto(fraction)
    
    def _bind_to_mousewheel(self, event):
        if platform == "linux" or platform == "linux2":
            self._canvas.bind_all("<MouseWheel>", fp(self._on_mousewheel, scroll=-1))
            self._canvas.bind_all("<Button-5>", fp(self._on_mousewheel, scroll=1))
        else:
            self.bind_all("<MouseWheel>", self._on_mousewheel)
    
    def _unbind_from_mousewheel(self, event):
    
        if platform == "linux" or platform == "linux2":
            self._canvas.unbind_all("<Button-4>")
            self._canvas.unbind_all("<Button-5>")
        else:
            self.unbind_all("<MouseWheel>")