跳转至

Tkinter 简介

欢迎,Python程序员!如果你已经掌握了Python的基础知识,并想开始设计强大的图形用户界面(GUI)应用程序,那么这本书就是为你准备的。

到现在为止,你无疑已经体验到了Python的强大和简洁。也许你已经编写过网络服务、进行过数据分析,或者管理过服务器。也许你编写过游戏、自动化过日常任务,或者只是用代码做过一些尝试。但现在,你已经准备好挑战GUI了。

随着网络、移动和服务器端编程受到如此多的重视,简单的桌面GUI应用程序开发似乎越来越像一门失传的艺术,许多其他方面有经验的开发人员从未学习过如何创建GUI,这真是太遗憾了!

桌面计算机在工作和家庭计算中仍然发挥着至关重要的作用,而能够为这个无处不在的平台构建简单、实用的应用程序,应该是每位软件开发人员工具箱中的一部分。幸运的是,对于Python程序员来说,由于有了Tkinter,这种能力触手可及。

在本章中,将涵盖以下主题:

  • 在Tkinter和Tk简介中,你将了解Tkinter,这是一个内置于Python标准库中的快速、有趣、易于学习的GUI库;以及IDLE,这是一个用Tkinter编写的编辑器和开发环境。
  • 在Tkinter基础概述中,你将通过一个“Hello World”程序学习Tkinter的基础知识,并创建一个调查应用程序。

Tk和Tkinter简介

Tk控件库源自工具命令语言(Tcl)编程语言。Tcl和Tk由约翰·奥斯特豪特(John Ousterhout)于20世纪80年代末在伯克利担任教授时创建,旨在为一种更简便的方法来编程大学中使用的工程工具。由于其速度和相对简洁性,Tcl/Tk迅速在学术界、工程界和Unix程序员中流行起来。

与Python本身非常相似,Tcl/Tk起源于Unix平台,后来才迁移到macOS和Windows。Tk的实用设计初衷和Unix渊源至今仍影响着其设计,而与其他工具包相比,其简洁性仍然是一个主要优势。

Tkinter是Tk图形用户界面(GUI)库的Python接口,自1994年Python 1.1版本发布以来,它一直是Python标准库的一部分,从而成为Python事实上的GUI库。Tkinter的文档以及进一步学习的链接可以在标准库文档中找到,网址为:https://docs.python.org/3/library/tkinter.html

Tkinter的优缺点

想要构建图形用户界面(GUI)的Python程序员有几种工具包选项可供选择,不幸的是,Tkinter常常被视为过时的选择而受到诟病或被忽视。

公平地说,它并不是一种你可以用时髦的流行语和夸张的宣传来描述的华丽技术,但是,Tkinter不仅适用于各种应用程序,而且还具有一些不可忽视的优势:

  • Tkinter是标准库的一部分:除少数例外情况外,只要Python可用,Tkinter就可用。无需安装pip、创建虚拟环境、编译二进制文件或在网上搜索安装包。对于需要快速完成的简单项目来说,这是一个明显的优势。

  • Tkinter稳定可靠:虽然Tkinter的开发并未停止,但进展缓慢且循序渐进。其应用程序接口(API)多年来一直保持稳定,变化主要是增加功能和修复错误。你的Tkinter代码可能会在未来数年或数十年内无需修改地运行。

  • Tkinter仅是一个GUI工具包:与其他一些GUI库不同,Tkinter没有自己的线程库、网络栈或文件系统API。它依赖于常规的Python库来处理这些事情,因此非常适合为现有的Python代码添加GUI。

  • Tkinter简单实用:Tkinter非常基础且直截了当,它可以在过程式和面向对象的GUI设计中有效地使用。要使用Tkinter,你无需学习数百个控件类、标记或模板语言、新的编程范式、客户端-服务器技术或不同的编程语言。

当然,Tkinter并不完美。它也有一些缺点:

  • Tkinter的默认外观显得过时:Tkinter的默认外观长期以来一直落后于当前的趋势,仍然保留了一些20世纪90年代Unix世界的痕迹。虽然它缺少诸如动画控件、渐变或可缩放图形等美观元素,但在过去几年中,由于Tk本身的更新和主题控件库的添加,它已经有了很大的改进。在本书中,我们将学习如何修复或避免Tkinter一些陈旧的默认设置。

  • Tkinter缺少更复杂的控件:Tkinter缺少诸如富文本编辑器、3D图形嵌入、HTML查看器或专用输入控件等高级控件。正如我们将在本书后面看到的,Tkinter允许我们通过定制和组合其简单控件来创建复杂控件。对于游戏用户界面或华丽的商业应用程序来说,Tkinter可能不是合适的选择。然而,对于数据驱动的应用程序、简单实用程序、配置对话框和其他业务逻辑应用程序,Tkinter提供了所需的一切甚至更多。在本书中,我们将逐步开发一个适用于工作环境的数据输入应用程序,这是Tkinter能够出色处理的任务。

安装Tkinter

对于Windows和macOS分发版,Tkinter已包含在Python标准库中。因此,如果您在这些平台上使用官方安装程序安装了Python,则无需进行任何操作来安装Tkinter。

然而,本书将专门关注Python 3.9,因此,您需要确保已安装此版本或更高版本。

在Windows上安装Python 3.9

您可以通过以下步骤从python.org网站获取Windows版的Python 3安装程序:

  1. 访问 https://www.python.org/downloads/windows
  2. 选择最新的Python 3版本。在撰写本文时,最新版本是3.9.2。
  3. 在“Files(文件)”部分,根据您的系统架构选择合适的Windows可执行安装程序(32位Windows选择x86,64位Windows选择x86-64;如果不确定,x86可以在两者上运行)。
  4. 启动下载的安装程序。
  5. 点击“自定义安装”。确保选中了tcl/tk和IDLE选项(默认情况下应该已选中)。
  6. 按照安装程序的默认设置继续进行。

在macOS上安装Python 3

在撰写本文时,macOS自带了内置的Python 2.7。然而,Python 2已在2020年正式停止支持,并且本书中的代码无法在Python 2上运行,因此macOS用户需要安装Python 3才能跟随本书学习。请按照以下步骤在macOS上安装Python 3:

  1. 访问 https://www.python.org/downloads/mac-osx/
  2. 选择最新的Python 3版本。在撰写本文时,最新版本是3.9.2。
  3. 在“Files(文件)”部分,选择并下载macOS 64位/32位安装程序。
  4. 启动您下载的.pkg文件,并按照安装向导的步骤进行操作,选择默认设置。

在Linux上安装Python 3和Tkinter

大多数Linux发行版都同时包含Python 2和Python 3;然而,Tkinter并不总是与它们一起捆绑或默认安装。要检查Tkinter是否已安装,请打开终端并尝试以下命令:

1
$ python3 -m tkinter

这应该会打开一个简单窗口,显示一些关于Tkinter的信息。如果您收到ModuleNotFoundError,则需要使用包管理器来安装适用于Python 3的Tkinter包。在大多数主要发行版中,包括Debian、Ubuntu、Fedora和openSUSE,这个包名为python3-tk

介绍IDLE

IDLE是一个集成开发环境,它与Windows和macOS的官方Python软件分发版一起提供(在大多数Linux发行版中也很容易获得,通常名为idle或idle3)。

IDLE是用Python和Tkinter编写的,它不仅为我们提供了一个编辑Python代码的环境,还展示了Tkinter在实际应用中的一个很好例子。因此,尽管经验丰富的Python程序员可能不认为IDLE的基本功能集达到专业级别,尽管您可能已经有了编写Python代码的首选环境,但我鼓励您在阅读本书时花一些时间使用IDLE。

IDLE有两种主要模式:Shell模式(交互式模式)和编辑器模式。我们将在本节中介绍这两种模式。

使用IDLE的Shell模式

当你启动IDLE时,会直接进入Shell模式,这只是一个Python的读取-求值-输出循环(REPL),类似于你在终端窗口中键入python时所得到的环境。

你可以在这张截图中看到IDLE的Shell模式:

图 1.1: IDLE的Shell模式

IDLE 的 shell 具有一些命令行 REPL 所不具备的便捷功能,比如语法高亮和制表符自动补全。REPL 对于 Python 开发过程至关重要,因为它允许你实时测试代码,并检查类和 API,而无需编写完整的脚本。在后面的章节中,我们将使用 shell 模式来探索模块的功能和行为。如果你没有打开 shell 窗口,可以通过点击 IDLE 菜单中的Run | Python Shell来打开一个。

使用IDLE的编辑模式

编辑模式用于创建 Python 脚本文件,之后你可以运行这些文件。当书中告诉你创建一个新文件时,就是要使用这个模式。要在编辑模式下打开一个新文件,只需在菜单中导航到文件 | 新建文件,或者按键盘上的 Ctrl + N

这张图片展示的是 IDLE 的文件编辑器:

图 1.2: IDLE的编辑模式

在编辑模式下,你可以通过按 F5 键来运行你的脚本,无需离开 IDLE,IDLE 将打开一个 shell 模式窗口来执行脚本并显示输出。

IDLE 作为 Tkinter 的一个示例

在我们开始使用 Tkinter 编写代码之前,让我们先快速看看通过检查 IDLE 的一些用户界面(UI)可以用它做些什么。从主菜单导航到Options | Configure IDLE以打开 IDLE 的配置设置。在这里,你可以更改 IDLE 的字体、颜色和主题、快捷键以及默认行为,如这张截图所示:

图 1.3: IDLE的配置选项

以下是这个用户界面的一些组成部分,请考虑它们:

  • 有下拉菜单,允许你在大量选项之间进行选择。
  • 有可选按钮,允许你在少量选项之间进行选择。
  • 有许多可以按下的按钮,点击它们可以执行操作。
  • 有一个文本窗口,可以显示多色文本。
  • 有带标签的框架,其中包含一组组件。
  • 屏幕顶部有选项卡,可以选择配置的不同部分。

在 Tkinter(以及大多数图形用户界面库中),这些组件中的每一个都被称为小部件

我们将在本书中陆续介绍这些小部件以及更多内容,并学习如何像这里一样使用它们。

不过,我们将从一些更简单的内容开始。

创建TKinter的Hello World程序

在任何编程语言或库中,都有一个伟大的传统,那就是创建一个“Hello World”程序:即一个显示“Hello World”然后退出的程序。让我们逐步创建一个 Tkinter 的“Hello World”应用程序,并在过程中讲解其各个部分。

首先,在 IDLE 或你喜欢的编辑器中创建一个新文件,命名为 hello_tkinter.py,并输入以下代码:

1
2
"""Tkinter 的 Hello World 应用程序"""
import tkinter as tk

第一行称为文档字符串,每个 Python 脚本都应该以一个文档字符串开始。至少,它应该给出程序的名称,但也可以包括关于如何使用它、谁编写的以及它需要什么等详细信息。

第二行将 tkinter 模块导入到我们的程序中。虽然 Tkinter 是标准库的一部分,但在使用它的任何类或函数之前,我们必须先导入它。

有时,你可能会看到这种导入方式写成 from tkinter import *。这种方法称为通配符导入,它会导致所有对象都被带入全局命名空间。虽然在教程中因其简单性而广受欢迎,但在实际代码中这是一个坏主意,因为我们的变量名和 tkinter 模块中的所有名称之间可能会发生冲突,从而导致难以察觉的错误。

为了避免这个问题,我们将把 tkinter 保留在其自己的命名空间中;但是,为了保持代码简洁,我们将 tkinter 别名为 tk。这种约定将在整本书中使用。

每个 Tkinter 程序都必须有且仅有一个根窗口,它既代表我们应用程序的顶级窗口,也代表应用程序本身。让我们像这样创建我们的根窗口:

1
root = tk.Tk()

根窗口是 Tk 类的一个实例。我们通过调用 Tk() 来创建它,就像这里所做的那样。在创建任何其他 Tkinter 对象之前,这个对象必须存在,并且当它被销毁时,应用程序将退出。现在,让我们在窗口中创建一个小部件:

1
label = tk.Label(root, text="Hello World")

这是一个 Label 小部件,它只是一个可以显示一些文本的面板。对于任何 Tkinter 小部件,第一个参数总是父小部件(有时称为主小部件)。在这里,我们传入了对根窗口的引用。父小部件是我们放置 Label 的小部件,因此这个 Label 将直接位于应用程序的根窗口上。Tkinter GUI 中的小部件是按层次结构排列的,每个小部件都被另一个小部件所包含,一直到根窗口。

我们还传入了一个关键字参数 text。当然,这个参数定义了将放置在小部件上的文本。对于大多数 Tkinter 小部件,大部分配置都是通过这样的关键字参数来完成的。现在我们已经创建了一个小部件,实际上需要将它放置在 GUI 上:

1
label.pack()

Label 小部件的 pack() 方法称为几何管理器方法。它的任务是确定小部件将如何附加到其父小部件,并在那里绘制它。如果没有这个调用,你的小部件将存在,但你不会在窗口的任何地方看到它。pack() 是三个几何管理器之一,我们将在下一节中了解更多关于它们的内容。我们程序的最后一行如下所示:

1
root.mainloop()

这行代码启动了应用程序的事件循环。事件循环是一个无限循环,它不断地处理程序执行期间发生的任何事件。事件可以是击键、鼠标点击或其他用户生成的活动。这个循环一直运行到程序退出为止,因此这行代码之后的任何代码在主窗口关闭之前都不会运行。因此,这行代码通常是 Tkinter 程序的最后一行。

在 IDLE 中按 F5 运行程序,或者在终端中输入以下命令运行程序:

1
$ python hello_tkinter.py

你应该会看到一个非常小的窗口弹出,显示文本Hello World,如下图所示:

图 1.4: 我们的Hello World程序

你可以随意在这个脚本中添加更多的小部件,在 root.mainloop() 调用之前进行尝试。你可以添加更多的 Label 对象,或者尝试一些 Button(创建一个可点击的按钮)或 Entry(创建一个文本字段)小部件。就像 Label 一样,这些小部件在初始化时需要传入一个父对象(使用 root)和一个 text 参数。别忘了对每个小部件调用 pack() 方法,将它们放置在根窗口上。

Tip

本书所有章节的示例代码可以从 https://github.com/PacktPublishing/Python-GUI-Programming-with-Tkinter-2E下载。你可能希望现在就下载这些代码,以便跟随学习。

当你准备好后,请继续阅读下一节,我们将创建一个更有趣的应用程序。

Tkinter 基础概述

尽管在屏幕上看到第一个图形用户界面窗口弹出可能很令人兴奋,但“Hello World”并不是一个特别有趣的应用程序。让我们重新开始,在构建一个稍大一些的程序时,更深入地了解一下 Tkinter。由于下一章你将在一家虚构的农业实验室找到一份研究果树的工作,让我们创建一个小程序来了解一下你对香蕉的看法。

使用 Tkinter 小部件构建图形用户界面

在你的编辑器中新建一个名为 banana_survey.py 的文件,并像这样导入 tkinter:

1
2
3
# banana_survey.py
"""用 Python 和 Tkinter 编写的香蕉偏好调查"""
import tkinter as tk

hello_tkinter.py 一样,在创建任何小部件或其他 Tkinter 对象之前,我们需要创建一个根窗口:

1
root = tk.Tk()

再次说明,我们将这个对象命名为 root。根窗口可以以各种方式进行配置;例如,我们可以为它设置窗口标题或调整其大小,如下所示:

1
2
3
4
5
# 设置标题
root.title('Banana interest survey')
# 设置根窗口大小
root.geometry('640x480+300+300')
root.resizable(False, False)

title() 方法设置我们的窗口标题(即任务管理器和窗口装饰中显示的名称),而 geometry() 设置窗口大小。在这个例子中,我们告诉根窗口的大小为 640x480 像素。+300+300 设置窗口在屏幕上的位置——在这个例子中,距离顶部 300 像素,距离左侧 300 像素(位置部分是可选的,如果你只关心大小)。请注意,geometry() 的参数是一个字符串。在 Tcl/Tk 中,每个参数都被视为字符串。由于 Tkinter 只是一个将参数传递给 Tcl/Tk 的包装器,我们经常会发现字符串被用来配置 Tkinter 对象——即使在我们可能期望使用整数或浮点数的情况下也是如此。

resizable() 方法设置我们的窗口是否可以分别在水平和垂直方向上调整大小。True 表示窗口可以在该方向上调整大小,False 表示该维度是固定的。在这个例子中,我们希望防止窗口调整大小,这样我们就不必担心使布局适应窗口大小的变化。

现在,让我们开始为我们的调查添加小部件。我们已经了解了 Label 小部件,所以让我们添加一个:

1
2
3
4
5
6
7
title = tk.Label(   
    root,
    text='Please take the survey',
    font=('Arial 16 bold'),
    bg='brown', 
    fg='#FF0' 
)

正如我们在“Hello World”示例中所看到的,传递给任何 Tkinter 小部件的第一个参数是新小部件将要放置的父小部件。在这个例子中,我们将把这个 Label 小部件放置在根窗口上。小部件的其余参数作为关键字参数指定。在这里,我们指定了以下内容:

  • text:标签将显示的文本。
  • font:指定用于显示文本的字体族、大小和粗细。再次注意,字体设置与我们的 geometry 设置一样,是作为一个简单的字符串指定的。
  • bg:设置小部件的背景颜色。这里我们使用了一个颜色名称;Tkinter 识别许多颜色名称,类似于 CSS 或 X11 中使用的颜色名称。
  • fg:设置小部件的前景(文本)颜色。在这个例子中,我们指定了一个简短的十六进制字符串,其中三个字符分别代表红色、绿色和蓝色值。我们也可以使用一个六字符的十六进制字符串(例如,#FFE812)来更精细地控制颜色。在第 9 章“使用样式和主题改善外观”中,我们将学习设置字体和颜色的更复杂方法,但目前这样已经足够了。

当然,Tkinter 有许多用于数据输入的交互式小部件,其中最简单的是 Entry 小部件:

1
2
name_label = tk.Label(root, text='What is your name?')
name_inp = tk.Entry(root)

Entry 小部件只是一个简单的文本输入框,设计用于输入单行文本。Tkinter 中的大多数输入小部件不包含任何类型的标签,因此我们添加了一个标签,以便用户清楚输入框的用途。一个例外是接下来我们将创建的 Checkbutton 小部件:

1
2
3
4
eater_inp = tk.Checkbutton(
    root,
    text='Check this box if you eat bananas'
)

Checkbutton 创建了一个复选框输入;它包含一个位于框旁边的标签,我们可以使用 text 参数设置其文本。

为了输入数字,Tkinter 提供了 Spinbox 小部件。让我们添加一个:

1
2
3
4
5
num_label = tk.Label(
    root,
    text='How many bananas do you eat per day?'
)
num_inp = tk.Spinbox(root, from_=0, to=1000, increment=1)

Spinbox 类似于 Entry,但具有箭头按钮,可以增加或减少框中的数字。我们在这里使用了几个参数来配置它:

  • from_to 参数分别设置按钮可以递减或递增到的最小值和最大值。请注意,from_ 后面有一个额外的下划线;这不是拼写错误!由于 from 是 Python 的关键字(用于导入模块),因此不能用作变量名,所以 Tkinter 的作者选择使用 from_
  • increment 参数设置箭头按钮增加或减少数字的量。

Tkinter 有几个小部件允许你从预设的选择值中进行选择;其中最简单的一个是 Listbox,它看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
color_label = tk.Label(
    root,
    text='What is the best color for a banana?'
)
color_inp = tk.Listbox(root, height=1)  # 仅显示选定项

# 添加选项
color_choices = (
    'Any', 'Green', 'Green-Yellow',
    'Yellow', 'Brown Spotted', 'Black'
)

for choice in color_choices:
    color_inp.insert(tk.END, choice)

Listbox 接受一个 height 参数,用于指定可见的行数;默认情况下,框的大小足以显示所有选项。我们将其更改为 1,以便仅显示当前选定的选项。可以使用箭头键访问其他选项。

要向框中添加选项,我们需要调用其 insert() 方法,并逐个添加每个选项。在这里,我们使用了一个 for 循环来完成这一操作,以节省重复编码的工作量。insert 的第一个参数指定了我们希望插入选项的位置;请注意,我们使用了 Tkinter 提供的一个特殊常量(constant) tk.END。这是 Tkinter 中为某些配置值定义的许多特殊常量之一。在这种情况下,tk.END 表示小部件的末尾,因此我们插入的每个选项都将放置在末尾。

另一种让用户在一小部分选项之间进行选择的方法是使用 Radiobutton 小部件;这些类似于 Checkbutton 小部件,但类似于(非常、非常旧的)汽车收音机中的机械预设按钮,它们一次只允许选中一个。让我们创建几个 Radiobutton 小部件:

1
2
3
4
plantain_label = tk.Label(root, text='Do you eat plantains?')
plantain_frame = tk.Frame(root)
plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes')
plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!')

注意我们在这里对 plantain_frame 所做的操作:我们创建了一个 Frame 对象,并将其用作每个 Radiobutton 小部件的父小部件。Frame 只是一个空白面板,上面没有任何内容,它对于分层组织布局非常有用。在这本书中,我们将经常使用 Frame 小部件来将一组小部件组合在一起。

Entry 小部件适用于单行字符串,但多行字符串呢?对于这种情况,Tkinter 提供了 Text 小部件,我们可以这样创建:

1
2
3
4
5
banana_haiku_label = tk.Label(
    root, 
    text='Write a haiku about bananas'
)
banana_haiku_inp = tk.Text(root, height=3)

Text 小部件的功能远不止多行文本,我们将在第 9 章“使用样式和主题改善外观”中探索其一些更高级的功能。不过,现在我们将仅将其用于文本。

如果没有提交按钮,我们的调查 GUI 就不完整,提交按钮由 Button 类提供,如下所示:

1
submit_btn = tk.Button(root, text='Submit Survey')

我们将使用这个按钮来提交调查并显示一些输出。我们可以使用什么小部件来显示该输出呢?事实证明,Label 对象不仅适用于静态消息,我们还可以使用它们在运行时显示消息。

让我们为我们的程序输出添加一个:

1
output_line = tk.Label(root, text='', anchor='w', justify='left')

在这里,我们创建了一个没有文本的 Label 小部件(因为我们还没有输出)。我们还为 Label 使用了一些额外的参数:

  • anchor 确定如果小部件比文本宽,文本将贴在小部件的哪一侧。Tkinter 有时在需要指定小部件的一侧时,会使用方位(北、南、东和西)的首字母缩写;在这种情况下,字符串 'w' 表示小部件的西侧(或左侧)。
  • justify 确定在有多行代码时,文本将对齐到哪一侧。与 anchor 不同,它使用常规的 'left''right''center' 选项。anchorjustify 可能看起来有些冗余,但它们的行为略有不同。在多行文本的情况下,文本可以每行居中对齐,但整组文本可以锚定在小部件的西侧,例如。换句话说,anchor 影响的是包含小部件中整个文本块的位置,而 justify 影响的是相对于其他行的每一行文本的对齐方式。

Tkinter 还有许多其他小部件,我们将在本书的剩余部分中介绍其中的许多。

使用几何管理器排列我们的小部件

如果你在这个脚本中添加 root.mainloop() 并直接执行它,你会看到……一个空白窗口。嗯,我们刚刚创建的那些小部件都去哪儿了?嗯,你可能还记得在 hello_tkinter.py 中,我们需要使用一个几何管理器,比如 pack(),来实际将它们放置在父小部件的某个位置。Tkinter 有三种可用的几何管理器方法:

  • pack() 是最古老的方法,它只是按顺序将小部件添加到窗口的四个边之一。
  • grid() 是较新且更受推荐的方法,它允许你在一个二维网格表中放置小部件。
  • place() 是第三种选项,它允许你将小部件放置在特定的像素坐标上。不建议使用它,因为它在窗口大小、字体大小和屏幕分辨率变化时响应不佳,因此我们不会在本书中使用它。

虽然 pack() 对于涉及少量小部件的简单布局来说当然没问题,但在没有过多 Frame 小部件嵌套的情况下,它不太适用于更复杂的布局。出于这个原因,大多数 Tkinter 程序员依赖于更现代的 grid() 几何管理器。顾名思义,grid() 允许你在一个二维网格上布局小部件,就像电子表格文档或 HTML 表格一样。在本书中,我们将主要关注 grid()

让我们开始使用 grid() 布局我们的 GUI 小部件,从标题标签开始:

1
title.grid()

默认情况下,调用 grid() 会将小部件放置在下一个空行的第一列(第0列)中。因此,如果我们简单地对下一个小部件调用 grid(),它将直接出现在第一个小部件的下方。但是,我们也可以使用 rowcolumn 参数来明确指定位置,如下所示:

1
name_label.grid(row=1, column=0)

行和列从小部件的左上角开始计数,从0开始。因此,row=1, column=0 将小部件放置在第二行第一列。如果我们想要一个额外的列,我们只需要在其中放置一个小部件,如下所示:

1
name_inp.grid(row=1, column=1)

每当我们向新行或新列添加小部件时,网格会自动扩展。如果一个小部件比当前列的宽度或行的高度大,那么该列或行中的所有单元格都会扩展以适应它。我们可以使用 columnspanrowspan 参数分别告诉一个小部件跨越多列或多行。例如,让我们的标题跨越表单的宽度可能会更好,所以让我们相应地修改它:

1
title.grid(columnspan=2)

随着列和行的扩展,小部件默认情况下不会随之扩展。如果我们希望它们扩展,我们需要使用 sticky 参数,如下所示:

1
eater_inp.grid(row=2, columnspan=2, sticky='we')

sticky 告诉 Tkinter 将小部件的边粘贴到其包含单元格的边,以便在单元格扩展时小部件也会拉伸。就像我们上面学到的 anchor 参数一样,sticky 采用基本方向:n(北)、s(南)、e(东)和 w(西)。在这个例子中,我们指定了 West(西)和 East(东),这将导致小部件在列进一步扩展时水平拉伸。

作为字符串的替代方案,我们也可以使用 Tkinter 的常量作为 sticky 的参数:

1
2
num_label.grid(row=3, sticky=tk.W)
num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E))

就 Tkinter 而言,使用常量和字符串字面量之间并没有真正的区别;然而,使用常量的优点是,你的编辑软件更容易识别出你是否使用了一个不存在的常量,而不是一个无效的字符串。

grid() 方法还允许我们为小部件添加内边距,如下所示:

1
2
color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10)
color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25)

padxpady 表示外部内边距,即它们会扩展包含单元格,但不会扩展小部件本身。而 ipadxipady 则表示内部内边距。指定这些参数将扩展小部件本身(从而也扩展包含单元格)。

图 1.5: 内部内边距(ipadx,ipady)与外部内边距(padx,pady)

Tkinter 不允许我们在同一个父级小部件上混合使用几何管理器;一旦我们对任何子小部件调用了 grid(),再对同级小部件调用 pack()place() 方法将会产生错误,反之亦然。

然而,我们可以在同级小部件的子小部件上使用不同的几何管理器。例如,我们可以使用 pack() 来放置 plantain_frame 小部件上的子小部件,如下所示:

1
2
3
4
plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5)
plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5)
plantain_label.grid(row=6, columnspan=2, sticky=tk.W)
plantain_frame.grid(row=7, columnspan=2, sticky=tk.W)  # 注意:这里应该是 sticky=tk.W 而不是 stick=tk.W

plantain_labelplantain_frame 小部件作为 root 的子小部件,必须使用 grid() 来放置;然而,plantain_yesplantain_noplantain_frame 的子小部件,因此我们可以选择对它们使用 pack()(或 place()),如果我们愿意的话。以下图表说明了这一点:

图 1.6: 每个小部件的子小部件必须使用相同的几何管理器方法

能够为每个容器小部件选择几何管理器的方法,使我们在布局 GUI 时具有极大的灵活性。虽然 grid() 方法确实能够指定大多数布局,但在某些时候,pack()place() 的语义可能更适合我们界面的某个部分。

Tip

虽然 pack() 几何管理器与 grid() 共享了一些参数,比如 padxpady,但大多数参数是不同的。例如,示例中使用的 side 参数决定了小部件将从哪一侧进行打包,而 fill 参数决定了小部件将在哪个轴上扩展。

让我们在窗口中添加最后几个小部件:

1
2
3
4
banana_haiku_label.grid(row=8, sticky=tk.W)
banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW')
submit_btn.grid(row=99)
output_line.grid(row=100, columnspan=2, sticky='NSEW')

注意,我们将 Text 小部件(banana_haiku_inp)粘贴到了其容器的四边。这将使它在网格拉伸时在垂直和水平方向上同时扩展。另外,请注意,我们为最后两个小部件跳过了第 99 行和第 100 行。请记住,未使用的行会折叠为无,因此通过跳过行或列,我们可以为 GUI 的未来扩展留出空间。

默认情况下,Tkinter 会使我们的窗口刚好足够大,以包含我们放置在其上的所有小部件;但是,如果我们的窗口(或包含框架)变得比小部件所需的空间更大,会发生什么?默认情况下,小部件将保持原样,粘贴在应用程序的左上角。如果我们希望 GUI 扩展并填满可用空间,我们必须告诉父级小部件哪些网格列和行将扩展。我们通过使用父级小部件的 columnconfigure()rowconfigure() 方法来做到这一点。

例如,如果我们希望我们的第二列(包含大多数输入小部件的列)扩展到未使用的空间,我们可以这样做:

1
root.columnconfigure(1, weight=1)

第一个参数指定我们要影响的列(从 0 开始计数)。关键字参数 weight 接受一个整数,该整数将决定该列将获得多少额外空间。在仅指定一列的情况下,任何大于 0 的值都将使该列扩展到剩余空间。rowconfigure() 方法的工作原理相同:

1
2
root.rowconfigure(99, weight=2)
root.rowconfigure(100, weight=1)

这次,我们给两行分配了权重值,但请注意,第 99 行的权重为 2,而第 100 行的权重为 1。在此配置中,任何额外的垂直空间将在第 99 行和第 100 行之间分配,但第 99 行将获得第 100 行的两倍空间。

如你所见,通过结合使用 grid()pack() 子框架以及一些仔细规划,我们可以在 Tkinter 中相当容易地实现复杂的 GUI 布局。

让表单实际执行一些操作

现在我们已经有了一个漂亮的表单,还配有一个提交按钮;那么我们如何让它实际执行一些操作呢?如果你以前只编写过程序代码,那么可能会对 GUI 应用程序中代码的执行流程感到困惑。与过程脚本不同,GUI 不能简单地从上到下执行所有代码。相反,它必须响应用户的操作,如按钮点击或按键,无论这些操作何时发生以及以何种顺序发生。这样的操作被称为事件。为了让程序响应事件,我们需要将事件绑定到一个函数,我们称之为回调函数(Callback)。

在 Tkinter 中,有几种方法可以将事件绑定到回调函数;对于按钮来说,最简单的方法是配置其 command 属性,如下所示:

1
submit_btn.configure(command=on_submit)

command 参数可以在创建小部件时指定(例如,submit_btn = Button(root, command=on_submit)),也可以在创建小部件后使用其 configure() 方法指定。configure() 允许你在小部件创建后更改其配置,方法是传入与创建小部件时相同的参数。

在这两种情况下,command 都指定了一个回调函数引用,当按钮被点击时将调用该函数。注意,在这里我们不在函数名后加括号;这样做会导致函数被立即调用,并且其返回值会被赋给 command。我们在这里只需要函数的引用。

在将回调函数传递给 command 之前,该函数必须存在。因此,在调用 submit_btn.configure() 之前,让我们创建 on_submit() 函数:

1
2
3
4
5
def on_submit():
    """用户提交表单时运行"""
    pass

submit_btn.configure(command=on_submit)

按照惯例,当回调函数是专门为响应特定事件而创建时,会以其事件名称的格式命名为 on_<event_name>。然而,这并不是必须的,也不总是合适的(例如,如果一个函数是多个事件的回调函数)。

一种更强大的绑定事件的方法是使用小部件的 bind() 方法,我们将在第 6 章《规划应用程序的扩展》中更详细地讨论这一点。

我们目前的 on_submit() 回调函数相当无趣,所以让我们来改进一下。移除 pass 语句,并添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def on_submit():
    """用户提交表单时运行"""
    name = name_inp.get()
    number = num_inp.get()
    selected_idx = color_inp.curselection()
    if selected_idx:
        color = color_inp.get(selected_idx)
    else:
        color = ''
    haiku = banana_haiku_inp.get('1.0', tk.END)
    message = (
        f'感谢参与调查,{name}\n'
        f'享受你的 {number}{color} 香蕉吧!'
    )
    output_line.configure(text=message)
    print(haiku)

在这个函数中,我们首先要从一些输入控件中获取值。对于许多输入控件,使用 get() 方法来检索小部件的当前值。请注意,这个值将作为字符串返回,即使在我们的 Spinbox 控件中也是如此。对于我们的列表控件 color,情况要复杂一些。它的 get() 方法需要一个选择项的索引号,并返回该索引号对应的文本。我们可以使用小部件的 curselection() 方法来获取选中的索引。如果没有进行选择,则选中的索引将是一个空元组。在这种情况下,我们将 color 设置为空字符串。如果有选择,我们可以将值传递给 get()。从 Text 控件获取数据的方式再次略有不同。它的 get() 方法需要两个值,一个用于起始位置,另一个用于结束位置。这些遵循一种特殊的语法(我们将在第 3 章《使用 Tkinter 和 Ttk 小部件创建基本表单》中讨论),但基本上 1.0 表示第一行的第一个字符,而 tk.END 是一个常量,表示 Text 控件的末尾。

Tip

在不使用 Tkinter 控制变量的情况下,无法从我们的复选框(Checkbutton)和单选按钮(Radiobutton)中检索数据。我们将在下面的部分“使用 Tkinter 控制变量处理数据”中讨论这一点。

收集完数据后,我们的回调函数最后会更新输出标签(Label)小部件的文本属性,用一个包含部分输入数据的字符串来更新,然后将用户的俳句打印到控制台。为了使这个脚本可以运行,以这行代码结束:

1
root.mainloop()

这行代码执行脚本的事件循环,以便 Tkinter 可以开始响应事件。保存你的脚本并执行它,你应该会看到类似这样的界面:

图 1.6: 我们的香蕉调查应用程序

恭喜你,你的香蕉调查应用程序起作用了!嗯,算是吧。让我们看看能不能让它完全正常运行。

使用 Tkinter 控制变量处理数据

我们已经很好地掌握了 GUI 布局,但我们的 GUI 存在一些问题。从控件中检索数据有点混乱,而且我们还不知道如何从复选框(Checkbutton)或单选按钮(Radiobutton)控件中获取值。实际上,如果你尝试操作单选按钮控件,你会发现它们完全不能正常工作。看来我们缺少了一个重要的拼图。

我们缺少的是 Tkinter 控制变量。控制变量是特殊的 Tkinter 对象,允许我们存储数据;有四种类型的控制变量:

StringVar:用于存储任意长度的字符串 • IntVar:用于存储整数 • DoubleVar:用于存储浮点数 • BooleanVar:用于存储布尔值(True/False)

但是等等!Python 已经有了可以存储这些类型数据甚至更多数据的变量。为什么我们还需要这些类?简而言之,这些变量类具有一些普通 Python 变量所缺乏的特殊功能,例如:

• 我们可以在控制变量和小部件之间创建双向绑定,这样无论是小部件的内容发生变化还是变量的内容发生变化,两者都会保持同步。 • 我们可以在变量上设置追踪。追踪将变量事件(如读取或更新变量)绑定到回调函数。(追踪将在第 4 章“使用类组织代码”中讨论。) • 我们可以在小部件之间建立关系。例如,我们可以告诉两个单选按钮(Radiobutton)小部件它们是相互关联的。让我们看看控制变量如何帮助我们的调查应用程序。回到顶部定义名称输入的地方,让我们添加一个变量:

1
2
3
name_var = tk.StringVar(root)
name_label = tk.Label(root, text='What is your name?')
name_inp = tk.Entry(root, textvariable=name_var)

我们可以通过调用 StringVar() 来创建一个 StringVar 对象;注意,我们传递了根窗口作为第一个参数。控制变量需要引用一个根窗口;然而,在几乎所有情况下,它们都可以自动解决这个问题,因此在这里很少需要实际指定根窗口。不过,重要的是要理解,在 Tk 对象存在之前,无法创建任何控制变量对象。

一旦我们有了 StringVar 对象,就可以通过将其传递给 textvariable 参数来绑定到我们的 Entry 小部件。这样做之后,name_inp 小部件的内容和 name_var 变量的内容将保持同步。调用变量的 get() 方法将返回文本框的当前内容,如下所示:

1
print(name_var.get())

对于复选框,使用 BooleanVar

1
2
eater_var = tk.BooleanVar()
eater_inp = tk.Checkbutton(root, variable=eater_var, text='如果你吃香蕉,请勾选此框')

这次,我们使用 variable 参数将变量传递给 Checkbutton。按钮小部件将使用关键字 variable 来绑定控制变量,而输入型或返回字符串值的小部件通常使用关键字 textvariable

Tip

按钮小部件也确实接受 textvariable 参数,但它并不绑定按钮的值;而是绑定按钮标签的文本。此功能允许您动态更新按钮的文本。

可以使用 value 参数为变量初始化默认值,如下所示:

1
2
3
num_var = tk.IntVar(value=3)
num_label = tk.Label(text='你每天吃多少根香蕉?')
num_inp = tk.Spinbox(root, textvariable=num_var, from_=0, to=1000, increment=1)

在这里,我们使用 IntVar() 创建了一个整数变量,并将其值设置为 3;当我们启动表单时,num_inp 小部件将被设置为 3。请注意,尽管我们认为 Spinbox 是用于输入数字的,但它使用 textvariable 参数来绑定其控制变量。实际上,Spinbox 小部件不仅可以用于数字,因此其数据在内部存储为文本。但是,通过绑定 IntVarDoubleVar,检索到的值将自动转换为整数或浮点数。

Attention

如果用户能够在输入框中输入字母、符号或其他无效字符,那么 IntVarDoubleVar 进行的自动转换为整数或浮点数的操作可能会引发问题。如果对绑定到包含无效数字字符串(例如,“1.1.2”或“I like plantains”)的小部件的整数或双精度变量调用 get(),将会引发异常,导致我们的应用程序崩溃。在第 5 章“通过验证和自动化减少用户错误”中,我们将学习如何解决这个问题。

之前,我们使用 Listbox 向用户显示选项列表。不幸的是,Listbox 与控制变量配合得不是很好,但另一个小部件 OptionMenu 可以做到。

让我们用 OptionMenu 小部件替换 color_inp

1
2
3
4
color_var = tk.StringVar(value='Any')
color_label = tk.Label(root, text='香蕉的最佳颜色是什么?')
color_choices = ('Any', 'Green', 'Green Yellow', 'Yellow', 'Brown Spotted', 'Black')
color_inp = tk.OptionMenu(root, color_var, *color_choices)

OptionMenu 保存的是字符串形式的选项列表,因此我们需要创建一个 StringVar 来绑定它。请注意,与 ListBox 小部件不同,OptionMenu 允许我们在创建时指定选项。OptionMenu 构造函数也与其他 Tkinter 小部件构造函数略有不同,它将控制变量和选项作为位置参数,如下所示:

1
2
# 示例,不要添加到程序中
menu = tk.OptionMenu(parent, ctrl_var, opt1, opt2, ..., optN)

在我们的调查代码中,我们通过使用解包运算符 (*) 将 color_choices 列表展开为位置参数来添加选项。我们也可以显式地列出它们,但这样做可以让我们的代码更整洁一些。

Tip

当我们在第 3 章“使用 Tkinter 和 Ttk 小部件创建基本表单”中讨论 Ttk 小部件集时,我们将了解一个更好的下拉菜单选项。

Radiobutton 小部件处理变量的方式也与其他小部件略有不同。为了有效地使用 Radiobutton 小部件,我们将同一组中的所有按钮绑定到同一个控制变量,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
plantain_var = tk.BooleanVar()

plantain_yes_inp = tk.Radiobutton(
    plantain_frame,
    text='Yes',
    value=True,
    variable=plantain_var
)

plantain_no_inp = tk.Radiobutton(
    plantain_frame,
    text='Ewww, no!',
    value=False,
    variable=plantain_var
)

我们可以将任何类型的控制变量绑定到 Radiobutton 小部件上,但必须确保为每个小部件提供一个与变量类型匹配的值。在这个例子中,我们用这些按钮来回答一个是/否问题,因此使用 BooleanVar 是合适的;我们通过 value 参数将一个按钮设置为 True,另一个设置为 False。当我们调用变量的 get() 方法时,它将返回所选按钮的 value 参数值。

遗憾的是,并非所有 Tkinter 小部件都能与控制变量一起工作。特别值得注意的是,我们用于 banana_haiku_inp 输入的 Text 小部件无法绑定到变量,并且(与 Listbox 不同)没有可用的替代选项。目前,我们只能像我们之前已经做过的那样处理 Text 输入小部件。

Tip

Tkinter 的 Text 框不支持变量,因为它不仅仅是多行文本;它可以包含图像、富文本和其他无法用简单字符串表示的对象。然而,在第 4 章“使用类组织代码”中,我们将实现一个解决方案,允许我们将变量绑定到多行字符串小部件。

控制变量不仅用于绑定到输入小部件;我们还可以使用它们来更新非交互式小部件(如 Label)中的字符串。例如:

1
2
output_var = tk.StringVar(value='')
output_line = tk.Label(root, textvariable=output_var, anchor='w', justify='left')

通过将 output_var 控制变量绑定到此 Label 小部件的 textvariable 参数,我们可以在运行时通过更新 output_var 来更改标签显示的文本。

在回调函数中使用控制变量

既然我们已经创建了所有这些变量并将它们绑定到小部件上,那么我们能用它们做些什么呢?跳到回调函数 on_submit(),并删除其中的代码。我们将使用我们的控制变量重新编写它。首先从 name 值开始:

1
2
3
def on_submit():
    """用户提交表单时运行"""
    name = name_var.get()

如前所述,get() 方法用于检索变量的值。get() 返回的数据类型取决于变量的类型,具体如下: - StringVar 返回 str - IntVar 返回 int - DoubleVar 返回 float - BooleanVar 返回 bool

需要注意的是,每次调用 get() 时都会执行类型转换,因此如果小部件中的内容与变量期望的内容不兼容,此时会引发异常。例如,如果一个 IntVar 绑定到一个空的 Spinboxget() 会引发异常,因为空字符串无法转换为 int。因此,有时将 get() 放在 try/except 块中是明智的,如下所示:

1
2
3
4
try:
    number = num_var.get()
except tk.TclError:
    number = 10000

与有经验的 Python 程序员可能预期的不同,无效值引发的异常不是 ValueError。转换实际上是在 Tcl/Tk 中完成的,而不是在 Python 中,因此引发的异常是 tkinter.TclError。在这里,我们捕获了 TclError 并通过将香蕉的数量设置为 10,000 来处理它。

Tip

当 Tcl/Tk 在执行我们转换后的 Python 调用时遇到困难时,随时都会引发 TclError 异常,因此要正确处理这些异常,您可能需要从异常中提取实际的错误字符串。这有点难看且不符合 Python 的风格,但 Tkinter 没有给我们太多选择。

提取 OptionMenuCheckbuttonRadiobutton 小部件的值现在变得干净利落多了,如下所示:

1
2
3
color = color_var.get()
banana_eater = eater_var.get()
plantain_eater = plantain_var.get()

对于 OptionMenuget() 返回选定的字符串。对于 Checkbutton,如果按钮被选中则返回 True,否则返回 False。对于 Radiobutton 小部件,get() 返回选定小部件的值。控制变量的好处在于,我们不需要知道或关心它们绑定的是哪种类型的小部件;只需调用 get() 就足以检索用户的输入。

如前所述,Text 小部件不支持控制变量,因此我们必须以传统方式获取其内容:

1
haiku = banana_haiku_inp.get('1.0', tk.END)

现在我们已经有了所有这些数据,让我们为调查者构建一条消息字符串:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
message = f'感谢您参与调查,{name}\n'
if not banana_eater:
    message += "很遗憾您不喜欢香蕉!\n"
else:
    message += f'享受您的 {number}{color} 香蕉吧!\n'
if plantain_eater:
    message += '享受您的大蕉吧!'
else:
    message += '愿您能成功避开大蕉!'
if haiku.strip():
    message += f'\n\n您的俳句:\n{haiku}'

为了向用户显示我们的消息,我们需要更新 output_var 变量。这可以通过其 set() 方法来完成,如下所示:

1
output_var.set(message)

set() 方法将更新控制变量,进而更新绑定到它的 Label 小部件。这样,我们就可以动态地更新应用程序中显示的消息、小部件标签和其他文本。

Tip

记得要使用 set() 来更改控制变量的值!使用赋值运算符(=)只会用不同的对象覆盖控制变量对象,之后你将无法再与之交互。例如,output_var = message 只是将名称 output_var 重新赋值为字符串对象 message,而当前绑定到 output_line 的控制变量对象将变得没有名称。

控制变量的重要性

希望您能看出,控制变量是 Tkinter GUI 中强大且必不可少的一部分。我们将在应用程序中广泛使用它们来存储数据并在 Tkinter 对象之间传递数据。实际上,一旦我们将变量绑定到小部件上,通常就不需要再保留对小部件的引用。例如,如果我们这样定义输出部分,我们的调查代码也能正常工作:

1
2
3
4
output_var = tk.StringVar(value='')
# 在布局部分中移除对 output_line.grid() 的调用!
tk.Label(
  root, textvariable=output_var, anchor='w', justify='left' ).grid(row=100, columnspan=2, sticky="NSEW")

由于我们不需要直接与输出标签进行交互,因此可以一行代码完成其创建和放置,而无需费心保存引用。由于小部件的父级保留了对该对象的引用,Python 不会销毁该对象,并且我们可以随时使用控制变量检索其内容。当然,如果我们以后想以某种方式操作该小部件(例如更改其字体值),则需要保留对它的引用。

总结

在本章中,您学习了如何安装 Tkinter 和 IDLE,并初步体验了使用 Tkinter 构建 GUI 的轻松程度。您学习了如何创建小部件,如何使用 grid() 几何管理器将它们排列在主窗口中,以及如何将它们的内容绑定到 StringVarBooleanVar 等控制变量。您还学习了如何将按钮点击等事件绑定到回调函数,以及如何检索和处理小部件数据。

在下一章中,您将开始在 ABQ AgriLabs 的新工作,并面临一个需要运用您的 GUI 编程技能解决的问题。您将学习如何剖析这个问题,制定程序规范,并设计一个用户友好的应用程序,作为解决方案的一部分。