【Stack Overflow】在 tkinter 中给一组 widgets 添加滚动条

这是一个新的系列,筛选来自 stack overflow 的问题和解答并翻译成中文。

本文来自https://stackoverflow.com/questions/3085696/adding-a-scrollbar-to-a-group-of-widgets-in-tkinter

问题

我正在用 python 来读取日志文件记录,并用 tkinter 把记录内容显示出来,目前为止一切正常。输出的结果是一组利用 grid 方法布局的 label 组件,但有时数量过多以至于没法全部显示在屏幕上。我想添加一个滚动条,看似很简单,但是我却没法搞定。

官方文档说只有ListTextboxCanvasEntry组件支持滚动条的接口。这些组件对于显示一组利用 grid 方法布局的组件来说都不合适。可以试着把所有的组件都放在一个Canvas里面,但这样的话貌似必须使用绝对坐标了,然后我就没法再使用 grid 布局管理器了?

我试着把组件都放在一个Frame里面,因为Frame里面可以使用 grid 布局,但它不支持滚动条接口,所以下面的代码不起作用:

mainframe =  Frame(root, yscrollcommand=scrollbar.set)

谁有办法解开这个限制吗?我可不想只是为了增加一个滚动条就用 PyQt 来重写,还大大增加了我程序的占用空间。

解答

滚动条只支持少数组件,很遗憾rootFrame都不包括在内。

最常见的解决办法就是创建一个 canvas 组件然后和滚动条绑定。然后在 canvas 里面嵌入一个 Frame,把你的 label 组件都放到 frame 里去。确定好 frame 的长度和宽度,同时设置 canvas 的scrollregion选项,这样滚动区域就可以准确匹配 frame 的大小。

为什么把 label 组件都放在一个 frame 而不是直接放在 canvas 中呢?这是因为一个绑定到 canvas 的滚动条只能滚动那些利用某种create_前缀方法创建的对象。你不能滚动那些利用packplace或者grid方法添加到 canvas 的对象(也就是把 label 组件直接放在 canvas 中)。通过使用一个 frame,你可以在其中布局 label 组件,然后调用 frame 的create_window方法即可。

在 canvas 上直接绘制文本不太难,所以如果 canvas 嵌套 frame 的办法太复杂的话你可能应该重新考虑一下。既然你用了 grid 布局管理器,每个文本对象的坐标应该很容易计算出来,尤其是每行都是一样高度的情况下(当你只用一种字体的时候更是如此)。

要想直接在 canvas 上绘制,只要算出当前使用的字体对应每一行的高度(可以用现有的代码)。然后,每个 y 坐标就是row*(lineheight+spacing)。x 的坐标根据最宽的那个对象来定,也是个固定值。如果你给每个对象提供 tag,你只需要用很简单的命令来调整 x 坐标和宽度就行。

面向对象版本的解法

下面是一个 canvas 嵌套 frame 解法的示例,用了面向对象方法:

import tkinter as tk  # python 3
# import Tkinter as tk  # python 2

class Example(tk.Frame):
    def __init__(self, root):

        tk.Frame.__init__(self, root)
        self.canvas = tk.Canvas(root, borderwidth=0, background="#ffffff")
        self.frame = tk.Frame(self.canvas, background="#ffffff")
        self.vsb = tk.Scrollbar(root, orient="vertical", command=self.canvas.yview)
        self.canvas.configure(yscrollcommand=self.vsb.set)

        self.vsb.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)
        self.canvas.create_window((4,4), window=self.frame, anchor="nw", 
                                  tags="self.frame")

        self.frame.bind("<Configure>", self.onFrameConfigure)

        self.populate()

    def populate(self):
        '''Put in some fake data'''
        for row in range(100):
            tk.Label(self.frame, text="%s" % row, width=3, borderwidth="1", 
                     relief="solid").grid(row=row, column=0)
            t="this is the second column for row %s" %row
            tk.Label(self.frame, text=t).grid(row=row, column=1)

    def onFrameConfigure(self, event):
        '''Reset the scroll region to encompass the inner frame'''
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

if __name__ == "__main__":
    root=tk.Tk()
    Example(root).pack(side="top", fill="both", expand=True)
    root.mainloop()

面向过程版本的解法

不使用类也可以:

import tkinter as tk  # python 3
# import Tkinter as tk  # python 2

def populate(frame):
    '''Put in some fake data'''
    for row in range(100):
        tk.Label(frame, text="%s" % row, width=3, borderwidth="1", 
                 relief="solid").grid(row=row, column=0)
        t="this is the second column for row %s" %row
        tk.Label(frame, text=t).grid(row=row, column=1)

def onFrameConfigure(canvas):
    '''Reset the scroll region to encompass the inner frame'''
    canvas.configure(scrollregion=canvas.bbox("all"))

root = tk.Tk()
canvas = tk.Canvas(root, borderwidth=0, background="#ffffff")
frame = tk.Frame(canvas, background="#ffffff")
vsb = tk.Scrollbar(root, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)

vsb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
canvas.create_window((4,4), window=frame, anchor="nw")

frame.bind("<Configure>", lambda event, canvas=canvas: onFrameConfigure(canvas))

populate(frame)

root.mainloop()