填充Python Tkinter的可滚动框架

时间:2017-09-05 14:31:16

标签: python tkinter scroll

我试图用Tkinter在Python中实现一个可滚动的框架:

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

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

以下实施具有以下问题:

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

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

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

编辑:我认为这不是this question的副本。如果你不知道如何开始,这个问题的答案肯定是一个很好的起点。它解释了如何在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的行。单击“+”直到出现垂直滚动条。 在滚动条和框架的白色背景之间,画布的红色背景变得可见。这个红色区域与滚动条一样宽。单击“+”或“ - ”或调整窗口大小时,红色区域消失,似乎不再出现。

0 个答案:

没有答案