Python Tkinter 中填充的可滚动框架

问题描述 投票:0回答:1

我正在尝试使用 Tkinter 在 Python 中实现可滚动框架:

  • 如果内容发生变化,小部件的大小应该保持不变(基本上,我并不关心滚动条的大小是从框架中减去还是添加到父级中,尽管我确实认为这会使感觉这是否一致,但目前情况似乎并非如此)
  • 如果内容变得太大,将出现一个滚动条,以便可以滚动整个内容(但不能进一步滚动)
  • 如果内容完全适合小部件,则滚动条将消失并且无法再滚动(无需滚动,因为一切都可见)
  • 如果内容的 req 大小变得小于小部件,则内容应填充小部件

我很惊讶运行这个功能似乎有多困难,因为它似乎是一个非常基本的功能。 前三个要求看起来相对简单,但自从尝试填充小部件以来我遇到了很多麻烦。

以下实现存在以下问题:

  • 第一次出现滚动条时,框架不会填充画布(似乎取决于可用空间): 添加一列。出现水平滚动条。在滚动条和框架的白色背景之间,画布的红色背景变得可见。这个红色区域看起来与滚动条一样高。 添加或删除行或列或调整窗口大小时,红色区域会消失并且似乎不会再次出现。
  • 尺寸跳跃: 添加元素直到水平滚动条变得可见。让窗户更宽(而不是更高)。窗口的高度 [!] 随着跳跃而增加。
  • 无限循环: 添加行直到出现垂直滚动条,删除一行以便垂直滚动条再次消失,再次添加一行。窗口的大小快速增大和减小。此行为的发生取决于窗口的大小。可以通过调整窗口大小或关闭窗口来打破循环。

我做错了什么? 任何帮助将不胜感激。

#!/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()

编辑:我不认为这与这个问题重复。如果您不知道如何开始,那么这个问题的答案无疑是一个很好的起点。它解释了如何在 Tkinter 中处理滚动条的基本概念。然而,这不是我的问题。我认为我了解基本想法,并且我认为我已经实现了它。

我注意到答案提到了直接在画布上绘图而不是在其上放置框架的可能性。但是,我想要一个可重复使用的解决方案。

我的问题是,当我尝试实现内容应填充框架(如

pack(expand=tk.YES, fill=tk.BOTH)
)时,如果请求大小小于画布的大小,则会出现上面列出的三个奇怪的效果,我不明白。最重要的是,当我按照描述添加和删除行(不更改窗口大小)时,程序会陷入无限循环。


编辑2:我进一步减少了代码:

# 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()
  • 重现无限循环的过程不变: 单击“+”直到出现垂直滚动条,单击一次“-”使垂直滚动条再次消失,再次单击“+”。窗口的大小快速增大和减小。此行为的发生取决于窗口的大小。可以通过调整窗口大小或关闭窗口来打破循环。
  • 重现尺寸的跳跃: 单击“+”直到出现水平[!]滚动条(然后窗口高度会随着滚动条的大小而增加,这是可以的)。增加窗口宽度直到水平滚动条消失。窗口的高度[!]随着跳跃而增加。
  • 重现画布未填充的情况: 注释掉调用
    frame.setSize
    的行。单击“+”直到出现垂直滚动条。 在滚动条和框架的白色背景之间,画布的红色背景变得可见。这个红色区域看起来与滚动条一样宽。单击“+”或“-”或调整窗口大小时,红色区域消失并且似乎不再出现。
python tkinter scroll
1个回答
0
投票

尝试使用库customTkinter,它包含一个可滚动框架小部件,它会自动在x或y方向添加滚动条。请注意,它不能与 tk.raise 一起使用,因为它本身不是 tk 框架,因此如果使用框架切换,您必须将其放入另一个框架中。 这是文档的链接

© www.soinside.com 2019 - 2024. All rights reserved.