3

I am trying to implement a scrollable frame in Python with Tkinter:

  • if the content changes, the size of the widget is supposed to stay constant (basically, I don't really care whether the size of the scrollbar is subtracted from the frame or added to the parent, although I do think that it would make sense if this was consistent but that does not seem to be the case currently)
  • if the content becomes too big a scrollbar shall appear so that one can scroll over the entire content (but not further)
  • if the content fits entirely into the widget the scrollbar shall disappear and it shall not be possible to scroll anymore (no need to scroll, because everything is visible)
  • if the req size of the content becomes smaller than the widget, the content shall fill the widget

I am surprised how difficult it seems to get this running, because it seems like a pretty basic functionality. The first three requirements seem relatively easy but I am having a lot of trouble since trying to fill the widget.

The following implementation has the following problems:

  • first time a scrollbar appears, frame does not fill canvas (seems to depend on available space): add one column. The horizontal scrollbar appears. Between the scrollbar and the white background of the frame the red background of the canvas becomes visible. This red area looks around as high as the scrollbar. When adding or removing a row or column or resizing the window the red area disappears and does not seem to appear again.
  • size jumps: add elements until horizontal scrollbar becomes visible. make window wider (not higher). the height [!] of the window increases with a jump.
  • infinite loop: add rows until the vertical scrollbar appears, remove one row so that vertical scrollbar disappears again, add one row again. The window's size is rapidly increasing and decreasing. The occurence of this behaviour depends on the size of the window. The loop can be broken by resizing or closing the window.

What am I doing wrong? Any help would be appreciated.

#!/usr/bin/env python

# based on https://stackoverflow.com/q/30018148

try:
    import Tkinter as tk
except:
    import tkinter as tk


# I am not using something like vars(tk.Grid) because that would override too many methods.
# Methods like Grid.columnconfigure are suppossed to be executed on self, not a child.
GM_METHODS_TO_BE_CALLED_ON_CHILD = (
    'pack', 'pack_configure', 'pack_forget', 'pack_info',
    'grid', 'grid_configure', 'grid_forget', 'grid_remove', 'grid_info',
    'place', 'place_configure', 'place_forget', 'place_info',
)


class AutoScrollbar(tk.Scrollbar):
    '''
    A scrollbar that hides itself if it's not needed. 
    Only works if you use the grid geometry manager.
    '''
    def set(self, lo, hi):
        if float(lo) <= 0.0 and float(hi) >= 1.0:
            self.grid_remove()
        else:
            self.grid()
        tk.Scrollbar.set(self, lo, hi)

    def pack(self, *args, **kwargs):
        raise TclError('Cannot use pack with this widget.')

    def place(self, *args, **kwargs):
        raise TclError('Cannot use place with this widget.')


#TODO: first time a scrollbar appears, frame does not fill canvas (seems to depend on available space)
#TODO: size jumps: add elements until horizontal scrollbar becomes visible. make widget wider. height jumps from 276 to 316 pixels although it should stay constant.
#TODO: infinite loop is triggered by
#   - add rows until the vertical scrollbar appears, remove one row so that vertical scrollbar disappears again, add one row again (depends on size)
# was in the past triggered by:
#   - clicking "add row" very fast at transition from no vertical scrollbar to vertical scrollbar visible
#   - add columns until horizontal scrollbar appears, remove column so that horizointal scrollbar disappears again, add rows until vertical scrollbar should appear



class ScrollableFrame(tk.Frame):

    def __init__(self, master, *args, **kwargs):
        self._parentFrame = tk.Frame(master)
        self._parentFrame.grid_rowconfigure(0, weight = 1)
        self._parentFrame.grid_columnconfigure(0, weight = 1)

        # scrollbars
        hscrollbar = AutoScrollbar(self._parentFrame, orient = tk.HORIZONTAL)
        hscrollbar.grid(row = 1, column = 0, sticky = tk.EW)

        vscrollbar = AutoScrollbar(self._parentFrame, orient = tk.VERTICAL)
        vscrollbar.grid(row = 0, column = 1, sticky = tk.NS)

        # canvas & scrolling
        self.canvas = tk.Canvas(self._parentFrame,
            xscrollcommand = hscrollbar.set,
            yscrollcommand = vscrollbar.set,
            bg = 'red',  # should not be visible
        )
        self.canvas.grid(row = 0, column = 0, sticky = tk.NSEW)

        hscrollbar.config(command = self.canvas.xview)
        vscrollbar.config(command = self.canvas.yview)

        # self
        tk.Frame.__init__(self, self.canvas, *args, **kwargs)
        self._selfItemID = self.canvas.create_window(0, 0, window = self, anchor = tk.NW)

        # bindings
        self.canvas.bind('<Enter>', self._bindMousewheel)
        self.canvas.bind('<Leave>', self._unbindMousewheel)
        self.canvas.bind('<Configure>', self._onCanvasConfigure)

        # geometry manager
        for method in GM_METHODS_TO_BE_CALLED_ON_CHILD:
            setattr(self, method, getattr(self._parentFrame, method))


    def _bindMousewheel(self, event):
        # Windows
        self.bind_all('<MouseWheel>', self._onMousewheel)
        # Linux
        self.bind_all('<Button-4>', self._onMousewheel)
        self.bind_all('<Button-5>', self._onMousewheel)

    def _unbindMousewheel(self, event):
        # Windows
        self.unbind_all('<MouseWheel>')
        # Linux
        self.unbind_all('<Button-4>')
        self.unbind_all('<Button-5>')

    def _onMousewheel(self, event):
        if event.delta < 0 or event.num == 5:
            dy = +1
        elif event.delta > 0 or event.num == 4:
            dy = -1
        else:
            assert False

        if (dy < 0 and self.canvas.yview()[0] > 0.0) \
        or (dy > 0 and self.canvas.yview()[1] < 1.0):
            self.canvas.yview_scroll(dy, tk.UNITS)

        return 'break'

    def _onCanvasConfigure(self, event):
        self._updateSize(event.width, event.height)

    def _updateSize(self, canvWidth, canvHeight):
        hasChanged = False

        requWidth = self.winfo_reqwidth()
        newWidth  = max(canvWidth, requWidth)
        if newWidth != self.winfo_width():
            hasChanged = True

        requHeight = self.winfo_reqheight()
        newHeight  = max(canvHeight, requHeight)
        if newHeight != self.winfo_height():
            hasChanged = True

        if hasChanged:
            print("update size ({width}, {height})".format(width = newWidth, height = newHeight))
            self.canvas.itemconfig(self._selfItemID, width = newWidth, height = newHeight)
            return True

        return False

    def _updateScrollregion(self):
        bbox = (0,0, self.winfo_reqwidth(), self.winfo_reqheight())
        print("updateScrollregion%s" % (bbox,))
        self.canvas.config( scrollregion = bbox )

    def updateScrollregion(self):
        # a function called with self.bind('<Configure>', ...) is called when resized or scrolled but *not* when widgets are added or removed (is called when real widget size changes but not when required/requested widget size changes)
        # => useless for calling this function
        # => this function must be called manually when adding or removing children

        # The content has changed.
        # Therefore I need to adapt the size of self.

        # I need to update before measuring the size.
        # It does not seem to make a difference whether I use update_idletasks() or update().
        # Therefore according to Bryan Oakley I better use update_idletasks https://stackoverflow.com/a/29159152
        self.update_idletasks()
        self._updateSize(self.canvas.winfo_width(), self.canvas.winfo_height())

        # update scrollregion
        self._updateScrollregion()


    def setWidth(self, width):
        print("setWidth(%s)" % width)
        self.canvas.configure( width = width )

    def setHeight(self, height):
        print("setHeight(%s)" % width)
        self.canvas.configure( height = height )

    def setSize(self, width, height):
        print("setSize(%sx%s)" % (width, height))
        self.canvas.configure( width = width, height = height )



# ====================  TEST  ====================

if __name__ == '__main__':

    class Test(object):

        BG_COLOR = 'white'

        PAD_X = 1
        PAD_Y = PAD_X

        # ---------- initialization ----------

        def __init__(self):
            self.root = tk.Tk()
            self.buttonFrame = tk.Frame(self.root)
            self.buttonFrame.pack(side=tk.TOP)
            self.scrollableFrame = ScrollableFrame(self.root, bg=self.BG_COLOR)
            self.scrollableFrame.pack(side=tk.TOP, expand=tk.YES, fill=tk.BOTH)

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

            self.contentFrame = tk.Frame(self.scrollableFrame, bg=self.BG_COLOR)
            self.contentFrame.grid(row=0, column=0, sticky=tk.NSEW)
            self.labelRight = tk.Label(self.scrollableFrame, bg=self.BG_COLOR, text="right")
            self.labelRight.grid(row=0, column=1)
            self.labelBottom = tk.Label(self.scrollableFrame, bg=self.BG_COLOR, text="bottom")
            self.labelBottom.grid(row=1, column=0)

            tk.Button(self.buttonFrame, text="add row", command=self.addRow).grid(row=0, column=0)
            tk.Button(self.buttonFrame, text="remove row", command=self.removeRow).grid(row=1, column=0)
            tk.Button(self.buttonFrame, text="add column", command=self.addColumn).grid(row=0, column=1)
            tk.Button(self.buttonFrame, text="remove column", command=self.removeColumn).grid(row=1, column=1)

            self.row = 0
            self.col = 0

        def start(self):
            self.addRow()
            widget = self.contentFrame.grid_slaves()[0]
            width  = widget.winfo_width() + 2*self.PAD_X + self.labelRight.winfo_width()
            height = 4.9*( widget.winfo_height() + 2*self.PAD_Y ) + self.labelBottom.winfo_height()
            #TODO: why is size saved in event different from what I specify here?
            self.scrollableFrame.setSize(width, height)

        # ---------- add ----------

        def addRow(self):
            if self.col == 0:
                self.col = 1
            columns = self.col

            for col in range(columns):
                button = self.addButton(self.row, col)

            self.row += 1
            self._onChange()

        def addColumn(self):
            if self.row == 0:
                self.row = 1
            rows = self.row

            for row in range(rows):
                button = self.addButton(row, self.col)

            self.col += 1
            self._onChange()

        def addButton(self, row, col):
            button = tk.Button(self.contentFrame, text = '---------------------  %d, %d  ---------------------' % (row, col))
            # note that grid(padx) seems to behave differently from grid_columnconfigure(pad):
            # grid             : padx = "Optional horizontal padding to place around the widget in a cell."
            # grid_rowconfigure: pad  = "Padding to add to the size of the largest widget in the row when setting the size of the whole row."
            # http://effbot.org/tkinterbook/grid.htm
            button.grid(row=row, column=col, sticky=tk.NSEW, padx=self.PAD_X, pady=self.PAD_Y)

        # ---------- remove ----------

        def removeRow(self):
            if self.row <= 0:
                return
            self.row -= 1

            columns = self.col
            if columns == 0:
                return

            for button in self.contentFrame.grid_slaves():
                info = button.grid_info()
                if info['row'] == self.row:
                    button.destroy()

            self._onChange()

        def removeColumn(self):
            if self.col <= 0:
                return
            self.col -= 1

            rows = self.row
            if rows == 0:
                return

            for button in self.contentFrame.grid_slaves():
                info = button.grid_info()
                if info['column'] == self.col:
                    button.destroy()

            self._onChange()

        # ---------- other ----------

        def _onChange(self):
            print("=========== user action ==========")
            print("new size: %s x %s" % (self.row, self.col))
            self.scrollableFrame.updateScrollregion()

        def mainloop(self):
            self.root.mainloop()


    test = Test()
    test.start()
    test.mainloop()

EDIT: I do not think that this is a duplicate of this question. The answer to that question is certainly a good starting point if you don't know how to start. It explains the basic concept of how to handle scrollbars in Tkinter. That however, is not my problem. I think that I am aware of the basic idea and I think that I have implemented that.

I have noticed that the answer mentions the possibility of directly drawing on the canvas instead of putting a frame on it. However, I would like to have a reusable solution.

My problem is that when I tried to implement that the content shall fill the frame (like with pack(expand=tk.YES, fill=tk.BOTH)) if the req size is smaller than the size of the canvas the three above listed weird effects occured which I do not understand. Most importantly that is that the program runs into an infinite loop when I add and remove rows as described (without changing the window size).


EDIT 2: I have reduced the code even further:

# based on https://stackoverflow.com/q/30018148

try:
    import Tkinter as tk
except:
    import tkinter as tk


class AutoScrollbar(tk.Scrollbar):

    def set(self, lo, hi):
        if float(lo) <= 0.0 and float(hi) >= 1.0:
            self.grid_remove()
        else:
            self.grid()
        tk.Scrollbar.set(self, lo, hi)


class ScrollableFrame(tk.Frame):

    # ---------- initialization ----------

    def __init__(self, master, *args, **kwargs):
        self._parentFrame = tk.Frame(master)
        self._parentFrame.grid_rowconfigure(0, weight = 1)
        self._parentFrame.grid_columnconfigure(0, weight = 1)

        # scrollbars
        hscrollbar = AutoScrollbar(self._parentFrame, orient = tk.HORIZONTAL)
        hscrollbar.grid(row = 1, column = 0, sticky = tk.EW)

        vscrollbar = AutoScrollbar(self._parentFrame, orient = tk.VERTICAL)
        vscrollbar.grid(row = 0, column = 1, sticky = tk.NS)

        # canvas & scrolling
        self.canvas = tk.Canvas(self._parentFrame,
            xscrollcommand = hscrollbar.set,
            yscrollcommand = vscrollbar.set,
            bg = 'red',  # should not be visible
        )
        self.canvas.grid(row = 0, column = 0, sticky = tk.NSEW)

        hscrollbar.config(command = self.canvas.xview)
        vscrollbar.config(command = self.canvas.yview)

        # self
        tk.Frame.__init__(self, self.canvas, *args, **kwargs)
        self._selfItemID = self.canvas.create_window(0, 0, window = self, anchor = tk.NW)

        # bindings
        self.canvas.bind('<Configure>', self._onCanvasConfigure)


    # ---------- setter ----------

    def setSize(self, width, height):
        print("setSize(%sx%s)" % (width, height))
        self.canvas.configure( width = width, height = height )


    # ---------- listen to GUI ----------

    def _onCanvasConfigure(self, event):
        self._updateSize(event.width, event.height)


    # ---------- listen to model ----------

    def updateScrollregion(self):
        self.update_idletasks()
        self._updateSize(self.canvas.winfo_width(), self.canvas.winfo_height())
        self._updateScrollregion()


    # ---------- internal ----------

    def _updateSize(self, canvWidth, canvHeight):
        hasChanged = False

        requWidth = self.winfo_reqwidth()
        newWidth  = max(canvWidth, requWidth)
        if newWidth != self.winfo_width():
            hasChanged = True

        requHeight = self.winfo_reqheight()
        newHeight  = max(canvHeight, requHeight)
        if newHeight != self.winfo_height():
            hasChanged = True

        if hasChanged:
            print("update size ({width}, {height})".format(width = newWidth, height = newHeight))
            self.canvas.itemconfig(self._selfItemID, width = newWidth, height = newHeight)
            return True

        return False

    def _updateScrollregion(self):
        bbox = (0,0, self.winfo_reqwidth(), self.winfo_reqheight())
        print("updateScrollregion%s" % (bbox,))
        self.canvas.config( scrollregion = bbox )



# ====================  TEST  ====================

if __name__ == '__main__':

    labels = list()

    def createLabel():
        print("========= create label =========")
        l = tk.Label(frame, text="test %s" % len(labels))
        l.pack(anchor=tk.W)
        labels.append(l)
        frame.updateScrollregion()

    def removeLabel():
        print("========= remove label =========")
        labels[-1].destroy()
        del labels[-1]
        frame.updateScrollregion()

    root = tk.Tk()

    tk.Button(root, text="+", command=createLabel).pack()
    tk.Button(root, text="-", command=removeLabel).pack()

    frame = ScrollableFrame(root, bg="white")
    frame._parentFrame.pack(expand=tk.YES, fill=tk.BOTH)

    createLabel()
    frame.setSize(labels[0].winfo_width(), labels[0].winfo_height()*5.9)
    #TODO: why is size saved in event object different from what I have specified here?

    root.mainloop()
  • procedure to reproduce the infinite loop is unchanged: click "+" until the vertical scrollbar appears, click "-" once so that vertical scrollbar disappears again, click "+" again. The window's size is rapidly increasing and decreasing. The occurence of this behaviour depends on the size of the window. The loop can be broken by resizing or closing the window.
  • to reproduce the jump in size: click "+" until horizontal [!] scrollbar appears (the window height then increases by the size of the scrollbar, which is ok). Increase width of window until horizontal scrollbar disappears. The height [!] of the window increases with a jump.
  • to reproduce that the canvas is not filled: comment out the line which calls frame.setSize. Click "+" until vertical scrollbar appears. Between the scrollbar and the white background of the frame the red background of the canvas becomes visible. This red area looks around as wide as the scrollbar. When clicking "+" or "-" or resizing the window the red area disappears and does not seem to appear again.
jakun
  • 624
  • 5
  • 13
  • There is already a few good post on how to do this. You need to use a canvas to be able to do this. See [this link](https://stackoverflow.com/questions/3085696/adding-a-scrollbar-to-a-group-of-widgets-in-tkinter) – Mike - SMT Sep 05 '17 at 14:50
  • 1
    Possible duplicate of [Adding a scrollbar to a group of widgets in Tkinter](https://stackoverflow.com/questions/3085696/adding-a-scrollbar-to-a-group-of-widgets-in-tkinter) – Mike - SMT Sep 05 '17 at 14:51
  • @SierraMountainTech thank you. I am aware that there are already some good posts on the topic of scrollbars in Tkinter. That is why I have come this far already. However, I have not yet found any explaining this behaviour. Please see my edited question. – jakun Sep 05 '17 at 17:12
  • There is a large amount of code in your question can you please provide a minimal example that is testable. Something that fits the guidelines here [Minimal, Complete, and Verifiable example](https://stackoverflow.com/help/mcve). – Mike - SMT Sep 05 '17 at 17:14

0 Answers0