2

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?

2 Answers2

1

If you want to disable the scrolling when the frame is shorter than the height of the canvas, you can extend the scrollregion to the height of the canvas:

def _configure_interior(self, event):
    ...
    # make the height of the scrollregion at least the same as that of the canvas
    height = max(self.interior.winfo_reqheight(), self._canvas.winfo_height())
    size = (self.interior.winfo_reqwidth(), height)
    self._canvas.config(scrollregion="0 0 %s %s" % size)
    ...
acw1668
  • 40,144
  • 5
  • 22
  • 34
  • Hey, thanks for answering. Tried running with your suggestion and the problem still persists: when I'm increasing the height of the application so the entire content is visible, I can still drag the scrollbar and move the content. – Maya Linetsky Jun 20 '23 at 10:03
  • Wired. It works for me. – acw1668 Jun 20 '23 at 10:08
0

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>")