基本的Qt小部件可以在构建简单表单时带我们走很远,但完整的应用程序包括诸如菜单、工具栏、对话框等功能,这些功能可能很繁琐和棘手,从头开始构建。幸运的是,PyQt为这些标准组件提供了现成的类,使构建应用程序相对轻松。
在本章中,我们将探讨以下主题:
到目前为止,我们一直在使用QWidget作为顶级窗口的基类。这对于简单的表单效果很好,但它缺少许多我们可能期望从应用程序的主窗口中得到的功能,比如菜单栏或工具栏。Qt提供了QMainWindow类来满足这种需求。
从第一章的应用程序模板中复制一份,并进行一个小但至关重要的更改:
classMainWindow(qtw.QMainWindow):我们不再继承自QWidget,而是继承自QMainWindow。正如您将看到的,这将改变我们编写GUI的方式,但也会为我们的主窗口添加许多很好的功能。
为了探索这些新功能,让我们构建一个简单的纯文本编辑器。以下屏幕截图显示了我们完成的编辑器的外观,以及显示QMainWindow类的主要组件的标签:
保存您更新的模板,将其复制到一个名为text_editor.py的新文件中,并在您的代码编辑器中打开新文件。让我们开始吧!
QMainWindow分为几个部分,其中最重要的是中央小部件。这是一个代表界面主要业务部分的单个小部件。
我们通过将任何小部件的引用传递给QMainWindow.setCentralWidget()方法来设置这一点,就像这样:
self.textedit=qtw.QTextEdit()self.setCentralWidget(self.textedit)只能有一个中央小部件,因此在更复杂的应用程序(例如数据输入应用程序)中,它更可能是一个QWidget对象,您在其中安排了一个更复杂的GUI;对于我们的简单文本编辑器,一个单独的QTextEdit小部件就足够了。请注意,我们没有在QMainWindow上设置布局;这样做会破坏组件的预设排列。
状态栏是应用程序窗口底部的一条条纹,用于显示短文本消息和信息小部件。在Qt中,状态栏是一个QStatusBar对象,我们可以将其分配给主窗口的statusBar属性。
我们可以像这样创建一个:
status_bar=qtw.QStatusBar()self.setStatusBar(status_bar)status_bar.showMessage('Welcometotext_editor.py')然而,没有必要费这么大的劲;如果没有状态栏,QMainWindow对象的statusBar()方法会自动创建一个新的状态栏,如果有状态栏,则返回现有的状态栏。
因此,我们可以将所有的代码简化为这样:
self.statusBar().showMessage('Welcometotext_editor.py')showMessage()方法确切地做了它所说的,显示状态栏中给定的字符串。这是状态栏最常见的用法;但是,QStatusBar对象也可以包含其他小部件。
例如,我们可以添加一个小部件来跟踪我们的字符计数:
charcount_label=qtw.QLabel("chars:0")self.textedit.textChanged.connect(lambda:charcount_label.setText("chars:"+str(len(self.textedit.toPlainText()))))self.statusBar().addPermanentWidget(charcount_label)每当我们的文本更改时,这个QLabel就会更新输入的字符数。
请注意,我们直接将其添加到状态栏,而不引用布局对象;QStatusBar具有自己的方法来添加或插入小部件,有两种模式:常规和永久。在常规模式下,如果状态栏发送了一个长消息来显示,小部件可能会被覆盖。在永久模式下,它们将保持可见。在这种情况下,我们使用addPermanentWidget()方法以永久模式添加charcount_label,这样它就不会被长文本消息覆盖。
在常规模式下添加小部件的方法是addWidget()和insertWidget();对于永久模式,请使用addPermanentWidget()和insertPermanentWidget()。
应用程序菜单对于大多数应用程序来说是一个关键功能,它提供了对应用程序所有功能的访问,以分层组织的下拉菜单形式。
我们可以使用QMainWindow.menuBar()方法轻松创建一个。
menubar=self.menuBar()menuBar()方法返回一个QMenuBar对象,与statusBar()一样,如果存在窗口的现有菜单,此方法将返回该菜单,如果不存在,则会创建一个新的菜单。
默认情况下,菜单是空白的,但是我们可以使用菜单栏的addMenu()方法添加子菜单,如下所示:
file_menu=menubar.addMenu('File')edit_menu=menubar.addMenu('Edit')help_menu=menubar.addMenu('Help')addMenu()返回一个QMenu对象,表示下拉子菜单。传递给该方法的字符串将用于标记主菜单栏中的菜单。
某些平台,如macOS,不会显示空的子菜单。有关在macOS中构建菜单的更多信息,请参阅macOS上的菜单部分。
要向这些菜单填充项目,我们需要创建一些操作。操作只是QAction类的对象,表示我们的程序可以执行的操作。要有用,QAction对象至少需要一个名称和一个回调;它们还可以为操作定义键盘快捷键和图标。
创建操作的一种方法是调用QMenu对象的addAction()方法,如下所示:
open_action=file_menu.addAction('Open')save_action=file_menu.addAction('Save')我们创建了两个名为Open和Save的操作。它们实际上什么都没做,因为我们还没有分配回调方法,但是如果运行应用程序脚本,您会看到文件菜单确实列出了两个项目,Open和Save。
创建实际执行操作的项目,我们可以传入第二个参数,其中包含一个Python可调用对象或Qt槽:
quit_action=file_menu.addAction('Quit',self.destroy)edit_menu.addAction('Undo',self.textedit.undo)对于需要更多控制的情况,可以显式创建QAction对象并将其添加到菜单中,如下所示:
redo_action=qtw.QAction('Redo',self)redo_action.triggered.connect(self.textedit.redo)edit_menu.addAction(redo_action)QAction对象具有triggered信号,必须将其连接到可调用对象或槽,以使操作产生任何效果。当我们使用addAction()方法创建操作时,这将自动处理,但在显式创建QAction对象时,必须手动执行。
虽然在技术上不是必需的,但在显式创建QAction对象时传入父窗口小部件非常重要。如果未这样做,即使将其添加到菜单中,该项目也不会显示。
QMenuBar默认包装操作系统的本机菜单系统。在macOS上,本机菜单系统有一些需要注意的特殊之处:
如果您发现这些问题对您的应用程序太具有问题,您可以始终指示Qt不使用本机菜单系统,就像这样:
self.menuBar().setNativeMenuBar(False)这将在应用程序窗口中放置菜单栏,并消除特定于平台的问题。但是,请注意,这种方法会破坏macOS软件的典型工作流程,用户可能会感到不适。
工具栏是一排长按钮,通常用于编辑命令或类似操作。与主菜单不同,工具栏不是分层的,按钮通常只用图标标记。
QMainWindow允许我们使用addToolBar()方法向应用程序添加多个工具栏,就像这样:
toolbar=self.addToolBar('File')addToolBar()方法创建并返回一个QToolBar对象。传递给该方法的字符串成为工具栏的标题。
我们可以像向QMenu对象添加QAction对象一样添加到QToolBar对象中:
toolbar.addAction(open_action)toolbar.addAction("Save")与菜单一样,我们可以添加QAction对象,也可以只添加构建操作所需的信息(标题、回调等)。
运行应用程序;它应该看起来像这样:
请注意,工具栏的标题不会显示在工具栏上。但是,如果右键单击工具栏区域,您将看到一个弹出菜单,其中包含所有工具栏标题,带有复选框,允许您显示或隐藏应用程序的任何工具栏。
默认情况下,工具栏可以从应用程序中拆下并悬浮,或者停靠到应用程序的四个边缘中的任何一个。可以通过将movable和floatable属性设置为False来禁用此功能:
toolbar.setMovable(False)toolbar.setFloatable(False)您还可以通过将其allowedAreas属性设置为来自QtCore.Qt.QToolBarAreas枚举的标志组合,限制窗口的哪些边可以停靠该工具栏。
例如,让我们将工具栏限制为仅限于顶部和底部区域:
toolbar.setAllowedAreas(qtc.Qt.TopToolBarArea|qtc.Qt.BottomToolBarArea)我们的工具栏当前具有带文本标签的按钮,但通常工具栏会有带图标标签的按钮。为了演示它的工作原理,我们需要一些图标。
我们可以从内置样式中提取一些图标,就像这样:
open_icon=self.style().standardIcon(qtw.QStyle.SP_DirOpenIcon)save_icon=self.style().standardIcon(qtw.QStyle.SP_DriveHDIcon)现在不要担心这段代码的工作原理;有关样式和图标的完整讨论将在第六章Qt应用程序的样式中进行。现在只需了解open_icon和save_icon是QIcon对象,这是Qt处理图标的方式。
这些可以附加到我们的QAction对象,然后可以将它们附加到工具栏,就像这样:
open_action.setIcon(open_icon)toolbar.addAction(open_action)如您所见,这看起来好多了:
注意,当您运行此代码时,菜单中的文件|打开选项现在也有图标。因为两者都使用open_action对象,我们对该操作对象所做的任何更改都将传递到对象的所有使用中。
图标对象可以作为第一个参数传递给工具栏的addAction方法,就像这样:
最后,就像菜单一样,我们可以显式创建QAction对象,并将它们添加到工具栏中,就像这样:
help_action=qtw.QAction(self.style().standardIcon(qtw.QStyle.SP_DialogHelpButton),'Help',self,#importanttopasstheparent!triggered=lambda:self.statusBar().showMessage('Sorry,nohelpyet!'))toolbar.addAction(help_action)要在多个操作容器(工具栏、菜单等)之间同步操作,可以显式创建QAction对象,或者保存从addAction()返回的引用,以确保在每种情况下都添加相同的操作对象。
我们可以向应用程序添加任意数量的工具栏,并将它们附加到应用程序的任何一侧。要指定一侧,我们必须使用addToolBar()的另一种形式,就像这样:
toolbar2=qtw.QToolBar('Edit')toolbar2.addAction('Copy',self.textedit.copy)toolbar2.addAction('Cut',self.textedit.cut)toolbar2.addAction('Paste',self.textedit.paste)self.addToolBar(qtc.Qt.RightToolBarArea,toolbar2)要使用这种形式的addToolBar(),我们必须首先创建工具栏,然后将其与QtCore.Qt.ToolBarArea常量一起传递。
停靠窗口类似于工具栏,但它们位于工具栏区域和中央窗口之间,并且能够包含任何类型的小部件。
添加一个停靠窗口就像显式创建一个工具栏一样:
dock=qtw.QDockWidget("Replace")self.addDockWidget(qtc.Qt.LeftDockWidgetArea,dock)与工具栏一样,默认情况下,停靠窗口可以关闭,浮动或移动到应用程序的另一侧。要更改停靠窗口是否可以关闭,浮动或移动,我们必须将其features属性设置为QDockWidget.DockWidgetFeatures标志值的组合。
例如,让我们使用户无法关闭我们的停靠窗口,通过添加以下代码:
dock.setFeatures(qtw.QDockWidget.DockWidgetMovable|qtw.QDockWidget.DockWidgetFloatable)我们已将features设置为DockWidgetMovable和DockWidgetFloatable。由于这里缺少DockWidgetClosable,用户将无法关闭小部件。
停靠窗口设计为容纳使用setWidget()方法设置的单个小部件。与我们主应用程序的centralWidget一样,我们通常会将其设置为包含某种表单或其他GUI的QWidget。
让我们构建一个表单放在停靠窗口中,如下所示:
replace_widget=qtw.QWidget()replace_widget.setLayout(qtw.QVBoxLayout())dock.setWidget(replace_widget)self.search_text_inp=qtw.QLineEdit(placeholderText='search')self.replace_text_inp=qtw.QLineEdit(placeholderText='replace')search_and_replace_btn=qtw.QPushButton("SearchandReplace",clicked=self.search_and_replace)replace_widget.layout().addWidget(self.search_text_inp)replace_widget.layout().addWidget(self.replace_text_inp)replace_widget.layout().addWidget(search_and_replace_btn)replace_widget.layout().addStretch()addStretch()方法可以在布局上调用,以添加一个扩展的QWidget,将其他小部件推在一起。
这是一个相当简单的表单,包含两个QLineEdit小部件和一个按钮。当点击按钮时,它调用主窗口的search_and_replace()方法。让我们快速编写代码:
defsearch_and_replace(self):s_text=self.search_text_inp.text()r_text=self.replace_text_inp.text()ifs_text:self.textedit.setText(self.textedit.toPlainText().replace(s_text,r_text))这种方法只是检索两行编辑的内容;然后,如果第一个中有内容,它将在文本编辑的内容中用第二个文本替换所有实例。
此时运行程序,您应该在应用程序的左侧看到我们的停靠窗口,如下所示:
请注意停靠窗口右上角的图标。这允许用户将小部件分离并浮动到应用程序窗口之外。
对话框在应用程序中通常是必需的,无论是询问问题,呈现表单还是仅向用户提供一些信息。Qt提供了各种各样的现成对话框,用于常见情况,以及定义自定义对话框的能力。在本节中,我们将看一些常用的对话框类,并尝试设计自己的对话框。
QMessageBox是一个简单的对话框,主要用于显示短消息或询问是或否的问题。使用QMessageBox的最简单方法是利用其方便的静态方法,这些方法可以创建并显示一个对话框,而不需要太多麻烦。
六个静态方法如下:
这些对话框之间的主要区别在于默认图标,默认按钮和对话框的模态性。
对话框可以是模态的,也可以是非模态的。模态对话框阻止用户与程序的任何其他部分进行交互,并在显示时阻止程序执行,并且在完成时可以返回一个值。非模态对话框不会阻止执行,但它们也不会返回值。在模态QMessageBox的情况下,返回值是表示按下的按钮的enum常量。
让我们使用about()方法向我们的应用程序添加一个关于消息。首先,我们将创建一个回调来显示对话框:
defshowAboutDialog(self):qtw.QMessageBox.about(self,"Abouttext_editor.py","ThisisatexteditorwritteninPyQt5.")关于对话框是非模态的,因此它实际上只是一种被动显示信息的方式。参数依次是对话框的父窗口小部件,对话框的窗口标题文本和对话框的主要文本。
回到构造函数,让我们添加一个菜单操作来调用这个方法:
help_menu.addAction('About',self.showAboutDialog)模态对话框可用于从用户那里检索响应。例如,我们可以警告用户我们的编辑器尚未完成,并查看他们是否真的打算使用它,如下所示:
response=qtw.QMessageBox.question(self,'MyTextEditor','Thisisbetasoftware,doyouwanttocontinue')ifresponse==qtw.QMessageBox.No:self.close()sys.exit()所有模态对话框都返回与用户按下的按钮相对应的Qt常量;默认情况下,question()创建一个带有QMessageBox.Yes和QMessageBox.No按钮值的对话框,因此我们可以测试响应并做出相应的反应。还可以通过传入第四个参数来覆盖呈现的按钮,该参数包含使用管道运算符组合的多个按钮。
例如,我们可以将No更改为Abort,如下所示:
response=qtw.QMessageBox.question(self,'MyTextEditor','Thisisbetasoftware,doyouwanttocontinue',qtw.QMessageBox.Yes|qtw.QMessageBox.Abort)ifresponse==qtw.QMessageBox.Abort:self.close()sys.exit()如果静态的QMessageBox方法不提供足够的灵活性,还可以显式创建QMessageBox对象,如下所示:
splash_screen=qtw.QMessageBox()splash_screen.setWindowTitle('MyTextEditor')splash_screen.setText('BETASOFTWAREWARNING!')splash_screen.setInformativeText('Thisisvery,verybeta,''areyoureallysureyouwanttouseit')splash_screen.setDetailedText('Thiseditorwaswrittenforpedagogical''purposes,andprobablyisnotfitforrealwork.')splash_screen.setWindowModality(qtc.Qt.WindowModal)splash_screen.addButton(qtw.QMessageBox.Yes)splash_screen.addButton(qtw.QMessageBox.Abort)response=splash_screen.exec()ifresponse==qtw.QMessageBox.Abort:self.close()sys.exit()正如您所看到的,我们可以在消息框上设置相当多的属性;这些在这里描述:
我们还可以使用addButton()方法向对话框添加任意数量的按钮,然后通过调用其exec()方法显示对话框。如果我们配置对话框为模态,此方法将返回与单击的按钮匹配的常量。
应用程序通常需要打开或保存文件,用户需要一种简单的方法来浏览和选择这些文件。Qt为我们提供了QFileDialog类来满足这种需求。
与QMessageBox一样,QFileDialog类包含几个静态方法,显示适当的模态对话框并返回用户选择的值。
此表显示了静态方法及其预期用途:
在支持的平台上,这些方法的URL版本允许选择远程文件和目录。
要了解文件对话框的工作原理,让我们在应用程序中创建打开文件的能力:
defopenFile(self):filename,_=qtw.QFileDialog.getOpenFileName()iffilename:try:withopen(filename,'r')asfh:self.textedit.setText(fh.read())exceptExceptionase:qtw.QMessageBox.critical(f"Couldnotloadfile:{e}")getOpenFileName()返回一个包含所选文件名和所选文件类型过滤器的元组。如果用户取消对话框,将返回一个空字符串作为文件名,并且我们的方法将退出。如果我们收到一个文件名,我们尝试打开文件并将textedit小部件的内容写入其中。
由于我们不使用方法返回的第二个值,我们将其分配给_(下划线)变量。这是命名不打算使用的变量的标准Python约定。
getOpenFileName()有许多用于配置对话框的参数,所有这些参数都是可选的。按顺序,它们如下:
例如,让我们配置我们的文件对话框:
filename,_=qtw.QFileDialog.getOpenFileName(self,"Selectatextfiletoopen…",qtc.QDir.homePath(),'TextFiles(*.txt);;PythonFiles(*.py);;AllFiles(*)','PythonFiles(*.py)',qtw.QFileDialog.DontUseNativeDialog|qtw.QFileDialog.DontResolveSymlinks)QDir.homePath()是一个返回用户主目录的静态方法。
请注意,过滤器被指定为单个字符串;每个过滤器都是一个描述加上括号内的通配符字符串,并且过滤器之间用双分号分隔。这将导致一个看起来像这样的过滤器下拉菜单:
保存文件对话框的工作方式基本相同,但提供了更适合保存文件的界面。我们可以实现我们的saveFile()方法如下:
defsaveFile(self):filename,_=qtw.QFileDialog.getSaveFileName(self,"Selectthefiletosaveto…",qtc.QDir.homePath(),'TextFiles(*.txt);;PythonFiles(*.py);;AllFiles(*)')iffilename:try:withopen(filename,'w')asfh:fh.write(self.textedit.toPlainText())exceptExceptionase:qtw.QMessageBox.critical(f"Couldnotsavefile:{e}")其他QFileDialog便利方法的工作方式相同。与QMessageBox一样,也可以显式创建一个QFileDialog对象,手动配置其属性,然后使用其exec()方法显示它。然而,这很少是必要的,因为内置方法对大多数文件选择情况都是足够的。
在继续之前,不要忘记在MainWindow构造函数中添加调用这些方法的操作:
open_action.triggered.connect(self.openFile)save_action.triggered.connect(self.saveFile)QFontDialogQt提供了许多其他方便的选择对话框,类似于QFileDialog;其中一个对话框是QFontDialog,允许用户选择和配置文本字体的各个方面。
与其他对话框类一样,最简单的方法是调用静态方法显示对话框并返回用户的选择,这种情况下是getFont()方法。
让我们在MainWindow类中添加一个回调方法来设置编辑器字体:
defset_font(self):current=self.textedit.currentFont()font,accepted=qtw.QFontDialog.getFont(current,self)ifaccepted:self.textedit.setCurrentFont(font)getFont以当前字体作为参数,这使得它将所选字体设置为当前字体(如果您忽略这一点,对话框将默认为列出的第一个字体)。
它返回一个包含所选字体和一个布尔值的元组,指示用户是否点击了确定。字体作为QFont对象返回,该对象封装了字体系列、样式、大小、效果和字体的书写系统。我们的方法可以将此对象传回到QTextEdit对象的setCurrentFont()槽中,以设置其字体。
与QFileDialog一样,如果操作系统有原生字体对话框,Qt会尝试使用它;否则,它将使用自己的小部件。您可以通过将DontUseNativeDialog选项传递给options关键字参数来强制使用对话框的Qt版本,就像我们在这里做的那样:
Qt包含其他对话框类,用于选择颜色、请求输入值等。所有这些类似于文件和字体对话框,它们都是QDialog类的子类。我们可以自己子类化QDialog来创建自定义对话框。
例如,假设我们想要一个对话框来输入我们的设置。我们可以像这样开始构建它:
classSettingsDialog(qtw.QDialog):"""Dialogforsettingthesettings"""def__init__(self,settings,parent=None):super().__init__(parent,modal=True)self.setLayout(qtw.QFormLayout())self.settings=settingsself.layout().addRow(qtw.QLabel('
ApplicationSettings
'),)self.show_warnings_cb=qtw.QCheckBox(checked=settings.get('show_warnings'))self.layout().addRow("ShowWarnings",self.show_warnings_cb)self.accept_btn=qtw.QPushButton('Ok',clicked=self.accept)self.cancel_btn=qtw.QPushButton('Cancel',clicked=self.reject)self.layout().addRow(self.accept_btn,self.cancel_btn)这段代码与我们在过去章节中使用QWidget创建的弹出框并没有太大的区别。然而,通过使用QDialog,我们可以免费获得一些东西,特别是这些:QDialog为我们提供了很多灵活性,可以让我们如何利用用户输入的数据。例如,我们可以使用信号来发射数据,或者重写exec()来返回数据。
在这种情况下,由于我们传入了一个可变的dict对象,我们将重写accept()来修改那个dict对象:
defaccept(self):self.settings['show_warnings']=self.show_warnings_cb.isChecked()super().accept()回到MainWindow类,让我们创建一个属性和方法来使用新的对话框:
classMainWindow(qtw.QMainWindow):settings={'show_warnings':True}defshow_settings(self):settings_dialog=SettingsDialog(self.settings,self)settings_dialog.exec()使用QDialog类就像创建对话框类的实例并调用exec()一样简单。在这种情况下,由于我们直接编辑我们的settingsdict,所以我们不需要担心连接accepted信号或使用exec()的输出。
任何合理大小的应用程序都可能积累需要在会话之间存储的设置。保存这些设置通常涉及大量繁琐的文件操作和数据序列化工作,当我们希望跨平台良好地工作时,这种工作变得更加复杂。Qt的QtCore.QSettings类解救了我们。
QSettings类是一个简单的键值数据存储,会以平台适当的方式自动持久化。例如,在Windows上,设置存储在注册表数据库中,而在Linux上,它们被放置在~/.config下的纯文本配置文件中。
让我们用QSettings对象替换我们在文本编辑器中创建的设置dict对象。
要创建一个QSettings对象,我们需要传入公司名称和应用程序名称,就像这样:
classMainWindow(qtw.QMainWindow):settings=qtc.QSettings('AlanDMoore','texteditor')这些字符串将确定存储设置的注册表键或文件路径。例如,在Linux上,此设置文件将保存在~/.config/AlanDMoore/texteditor.conf。在Windows上,它将存储在注册表中的HKEY_CURRENT_USER\AlanDMoore\texteditor\。
我们可以使用对象的value()方法查询任何设置的值;例如,我们可以根据show_warnings设置使我们的启动警告对话框成为有条件的:
ifself.settings.value('show_warnings',False,type=bool):#Warningdialogcodefollows...value()的参数是键字符串、如果未找到键则是默认值,以及type关键字参数,告诉QSettings如何解释保存的值。type参数至关重要;并非所有平台都能以明确的方式充分表示所有数据类型。例如,如果未指定数据类型,则布尔值将作为字符串true和false返回,这两者在Python中都是True。
设置键的值使用setValue()方法,就像在SettingsDialog.accept()方法中所示的那样:
self.settings.setValue('show_warnings',self.show_warnings_cb.isChecked())请注意,我们不必做任何事情将这些值存储到磁盘上;它们会被Qt事件循环定期自动同步到磁盘上。它们也会在创建QSettings对象的时候自动从磁盘上读取。简单地用QSettings对象替换我们原来的settingsdict就足以让我们获得持久的设置,而无需编写一行文件I/O代码!
尽管它们很强大,QSettings对象不能存储任何东西。设置对象中的所有值都存储为QVariant对象,因此只有可以转换为QVariant的对象才能存储。这包括了一个长列表的类型,包括几乎任何Python内置类型和QtCore中的大多数数据类。甚至函数引用也可以被存储(尽管不是函数定义)。
不幸的是,如果你尝试存储一个无法正确存储的对象,QSettings.setValue()既不会抛出异常也不会返回错误。它会在控制台打印警告并存储一些可能不会有用的东西,例如:
app=qtw.QApplication([])s=qtc.QSettings('test')s.setValue('app',app)#Prints:QVariant::save:unabletosavetype'QObject*'(typeid:39).一般来说,如果你正在存储清晰表示数据的对象,你不应该遇到问题。
QSettings对象的另一个主要限制是它无法自动识别一些存储对象的数据类型,就像我们在布尔值中看到的那样。因此,在处理任何不是字符串值的东西时,传递type参数是至关重要的。
在本章中,你学习了有助于构建完整应用程序的PyQt类。你学习了QMainWindow类,它的菜单、状态栏、工具栏和停靠窗口。你还学习了从QDialog派生的标准对话框和消息框,以及如何使用QSettings存储应用程序设置。
尝试这些问题来测试你从本章中学到的知识:
answer=qtw.QMessageBox.question(None,'Continue','Runthisprogram')ifnotanswer:sys.exit()settings=qtc.QSettings()settings.setValue('config_file','SuperPhoto.conf')settings.setValue('default_color',QColor('black'))settings.sync()settings=qtc.QSettings('MyCompany','SuperPhoto')settings.setValue('DefaultName',dialog.default_name_edit.text)settings.setValue('UseGPS',dialog.gps_checkbox.isChecked)settings.setValue('DefaultColor',dialog.color_picker.color)进一步阅读有关更多信息,请参考以下内容:
绝大多数应用软件都是用来查看和操作组织好的数据。即使在不是显式数据库应用程序的应用程序中,通常也需要以较小的规模与数据集进行交互,比如用选项填充组合框或显示一系列设置。如果没有某种组织范式,GUI和一组数据之间的交互很快就会变成一团乱麻的代码噩梦。模型-视图模式就是这样一种范式。
在本章中,我们将学习如何使用Qt的模型-视图小部件以及如何在应用程序中优雅地处理数据。我们将涵盖以下主题:
您还需要一个或两个CSV文件来使用我们的CSV编辑器。这些可以在任何电子表格程序中制作,并且应该以列标题作为第一行创建。
在模型-视图设计中,模型是保存应用程序数据并包含检索、存储和操作数据逻辑的组件。视图组件向用户呈现数据,并提供输入和操作数据的界面。通过将应用程序的这些组件分离,我们将它们的相互依赖性降到最低,使它们更容易重用或重构。
让我们通过一个简单的例子来说明这个过程。从第四章的应用程序模板开始,使用QMainWindow构建应用程序,让我们构建一个简单的文本文件编辑器:
#ThiscodegoesinMainWindow.__init__()form=qtw.QWidget()self.setCentralWidget(form)form.setLayout(qtw.QVBoxLayout())self.filename=qtw.QLineEdit()self.filecontent=qtw.QTextEdit()self.savebutton=qtw.QPushButton('Save',clicked=self.save)form.layout().addWidget(self.filename)form.layout().addWidget(self.filecontent)form.layout().addWidget(self.savebutton)这是一个简单的表单,包括一个用于文件名的行编辑,一个用于内容的文本编辑和一个调用save()方法的保存按钮。
让我们创建以下save()方法:
defsave(self):filename=self.filename.text()error=''ifnotfilename:error='Filenameempty'elifpath.exists(filename):error=f'Willnotoverwrite{filename}'else:try:withopen(filename,'w')asfh:fh.write(self.filecontent.toPlainText())exceptExceptionase:error=f'Cannotwritefile:{e}'iferror:qtw.QMessageBox.critical(None,'Error',error)这种方法检查是否在行编辑中输入了文件名,确保文件名不存在(这样你就不会在测试这段代码时覆盖重要文件!),然后尝试保存它。如果出现任何错误,该方法将显示一个QMessageBox实例来报告错误。
这个应用程序可以工作,但缺乏清晰的模型和视图分离。将文件写入磁盘的同一个方法也显示错误框并调用输入小部件方法。如果我们要扩展这个应用程序到任何程度,save()方法很快就会变成一个混合了数据处理逻辑和呈现逻辑的迷宫。
让我们用单独的Model和View类重写这个应用程序。
从应用程序模板的干净副本开始,让我们创建我们的Model类:
classModel(qtc.QObject):error=qtc.pyqtSignal(str)defsave(self,filename,content):print("save_called")error=''ifnotfilename:error='Filenameempty'elifpath.exists(filename):error=f'Willnotoverwrite{filename}'else:try:withopen(filename,'w')asfh:fh.write(content)exceptExceptionase:error=f'Cannotwritefile:{e}'iferror:self.error.emit(error)我们通过子类化QObject来构建我们的模型。模型不应参与显示GUI,因此不需要基于QWidget类。然而,由于模型将使用信号和槽进行通信,我们使用QObject作为基类。模型实现了我们在前面示例中的save()方法,但有两个变化:
接下来,让我们创建我们的View类:
classView(qtw.QWidget):submitted=qtc.pyqtSignal(str,str)def__init__(self):super().__init__()self.setLayout(qtw.QVBoxLayout())self.filename=qtw.QLineEdit()self.filecontent=qtw.QTextEdit()self.savebutton=qtw.QPushButton('Save',clicked=self.submit)self.layout().addWidget(self.filename)self.layout().addWidget(self.filecontent)self.layout().addWidget(self.savebutton)defsubmit(self):filename=self.filename.text()filecontent=self.filecontent.toPlainText()self.submitted.emit(filename,filecontent)defshow_error(self,error):qtw.QMessageBox.critical(None,'Error',error)这个类包含与之前相同的字段和字段布局定义。然而,这一次,我们的保存按钮不再调用save(),而是连接到一个submit()回调,该回调收集表单数据并使用信号发射它。我们还添加了一个show_error()方法来显示错误。
在我们的MainWindow.__init__()方法中,我们将模型和视图结合在一起:
self.view=View()self.setCentralWidget(self.view)self.model=Model()self.view.submitted.connect(self.model.save)self.model.error.connect(self.view.show_error)在这里,我们创建View类的一个实例和Model类,并连接它们的信号和插槽。
在这一点上,我们的代码的模型视图版本的工作方式与我们的原始版本完全相同,但涉及更多的代码。你可能会问,这有什么意义?如果这个应用程序注定永远不会超出它现在的状态,那可能没有意义。然而,应用程序往往会在功能上扩展,并且通常其他应用程序需要重用相同的代码。考虑以下情况:
模型视图模式不仅在设计大型应用程序时有用,而且在包含数据的小部件上也同样有用。从第四章中复制应用程序模板,使用QMainWindow构建应用程序,让我们看一个模型视图在小部件级别上是如何工作的简单示例。
在MainWindow类中,创建一个项目列表,并将它们添加到QListWidget和QComboBox对象中:
data=['Hamburger','Cheeseburger','ChickenNuggets','HotDog','FishSandwich']#Thelistwidgetlistwidget=qtw.QListWidget()listwidget.addItems(data)#Thecomboboxcombobox=qtw.QComboBox()combobox.addItems(data)self.layout().addWidget(listwidget)self.layout().addWidget(combobox)因为这两个小部件都是用相同的列表初始化的,所以它们都包含相同的项目。现在,让我们使列表小部件的项目可编辑:
foriinrange(listwidget.count()):item=listwidget.item(i)item.setFlags(item.flags()|qtc.Qt.ItemIsEditable)通过迭代列表小部件中的项目,并在每个项目上设置Qt.ItemIsEditable标志,小部件变得可编辑,我们可以改变项目的文本。运行应用程序,尝试编辑列表小部件中的项目。即使你改变了列表小部件中的项目,组合框中的项目仍然保持不变。每个小部件都有自己的内部列表模型,它存储了最初传入的项目的副本。在一个列表的副本中改变项目对另一个副本没有影响。
我们如何保持这两个列表同步?我们可以连接一些信号和插槽,或者添加类方法来做到这一点,但Qt提供了更好的方法。
QListWidget实际上是另外两个Qt类的组合:QListView和QStringListModel。正如名称所示,这些都是模型视图类。我们可以直接使用这些类来构建我们自己的带有离散模型和视图的列表小部件:
model=qtc.QStringListModel(data)listview=qtw.QListView()listview.setModel(model)我们简单地创建我们的模型类,用我们的字符串列表初始化它,然后创建视图类。最后,我们使用视图的setModel()方法连接两者。
QComboBox没有类似的模型视图类,但它仍然在内部是一个模型视图小部件,并且具有使用外部模型的能力。
因此,我们可以使用setModel()将我们的QStringListModel传递给它:
model_combobox=qtw.QComboBox()model_combobox.setModel(model)将这些小部件添加到布局中,然后再次运行程序。这一次,你会发现对QListView的编辑立即在组合框中可用,因为你所做的更改被写入了QStringModel对象,这两个小部件都会查询项目数据。
QTableWidget和QTreeWidget也有类似的视图类:QTableView和QTreeView。然而,没有现成的模型类可以与这些视图一起使用。相反,我们必须通过分别继承QAbstractTableModel和QAbstractTreeModel来创建自己的自定义模型类。
在下一节中,我们将通过构建自己的CSV编辑器来介绍如何创建和使用自定义模型类。
逗号分隔值(CSV)是一种存储表格数据的纯文本格式。任何电子表格程序都可以导出为CSV,或者您可以在文本编辑器中手动创建。我们的程序将被设计成可以打开任意的CSV文件并在QTableView中显示数据。通常在CSV的第一行用于保存列标题,因此我们的应用程序将假定这一点并使该行不可变。
在开发数据驱动的模型-视图应用程序时,模型通常是最好的起点,因为这里是最复杂的代码。一旦我们把这个后端放在适当的位置,实现前端就相当简单了。
在这种情况下,我们需要设计一个可以读取和写入CSV数据的模型。从第四章的应用程序模板中复制应用程序模板,使用QMainWindow,并在顶部添加Pythoncsv库的导入。
现在,让我们通过继承QAbstractTableModel来开始构建我们的模型:
classCsvTableModel(qtc.QAbstractTableModel):"""ThemodelforaCSVtable."""def__init__(self,csv_file):super().__init__()self.filename=csv_filewithopen(self.filename)asfh:csvreader=csv.reader(fh)self._headers=next(csvreader)self._data=list(csvreader)我们的模型将以CSV文件的名称作为参数,并立即打开文件并将其读入内存(对于大文件来说不是一个很好的策略,但这只是一个示例程序)。我们将假定第一行是标题行,并在将其余行放入模型的_data属性之前使用next()函数检索它。
为了创建我们的模型的实例以在视图中显示数据,我们需要实现三种方法:
在这种情况下,rowCount()和columnCount()都很容易:
defrowCount(self,parent):returnlen(self._data)defcolumnCount(self,parent):returnlen(self._headers)行数只是_data属性的长度,列数可以通过获取_headers属性的长度来获得。这两个函数都需要一个parent参数,但在这种情况下,它没有被使用,因为它是指父节点,只有在分层数据中才适用。
最后一个必需的方法是data(),需要更多解释;data()看起来像这样:
defdata(self,index,role):ifrole==qtc.Qt.DisplayRole:returnself._data[index.row()][index.column()]data()的目的是根据index和role参数返回表格中单个单元格的数据。现在,index是QModelIndex类的一个实例,它描述了列表、表格或树结构中单个节点的位置。每个QModelIndex包含以下属性:
在我们这种表格模型的情况下,我们对row和column属性感兴趣,它们指示我们想要的数据单元的表行和列。如果我们处理分层数据,我们还需要parent属性,它将是父节点的索引。如果这是一个列表,我们只关心row。
role是QtCore.Qt.ItemDataRole枚举中的一个常量。当视图从模型请求数据时,它传递一个role值,以便模型可以返回适合请求上下文的数据或元数据。例如,如果视图使用EditRole角色进行请求,模型应返回适合编辑的数据。如果视图使用DecorationRole角色进行请求,模型应返回适合单元格的图标。
如果没有特定角色的数据需要返回,data()应该返回空。
在这种情况下,我们只对DisplayRole角色感兴趣。要实际返回数据,我们需要获取索引的行和列,然后使用它来从我们的CSV数据中提取适当的行和列。
在这一点上,我们有一个最小功能的只读CSV模型,但我们可以添加更多内容。
能够返回数据只是模型功能的一部分。模型还需要能够提供其他信息,例如列标题的名称或排序数据的适当方法。
要在我们的模型中实现标题数据,我们需要创建一个headerData()方法:
defheaderData(self,section,orientation,role):if(orientation==qtc.Qt.Horizontalandrole==qtc.Qt.DisplayRole):returnself._headers[section]else:returnsuper().headerData(section,orientation,role)headerData()根据三个信息——section、orientation和role返回单个标题的数据。
标题可以是垂直的或水平的,由方向参数确定,该参数指定为QtCore.Qt.Horizontal或QtCore.Qt.Vertical常量。
该部分是一个整数,指示列号(对于水平标题)或行号(对于垂直标题)。
如data()方法中的角色参数一样,指示需要返回数据的上下文。
在我们的情况下,我们只对DisplayRole角色显示水平标题。与data()方法不同,父类方法具有一些默认逻辑和返回值,因此在任何其他情况下,我们希望返回super().headerData()的结果。
如果我们想要对数据进行排序,我们需要实现一个sort()方法,它看起来像这样:
defsort(self,column,order):self.layoutAboutToBeChanged.emit()#needstobeemittedbeforeasortself._data.sort(key=lambdax:x[column])iforder==qtc.Qt.DescendingOrder:self._data.reverse()self.layoutChanged.emit()#needstobeemittedafterasortsort()接受一个column号和order,它可以是QtCore.Qt.DescendingOrder或QtCore.Qt.AscendingOrder,该方法的目的是相应地对数据进行排序。在这种情况下,我们使用Python的list.sort()方法来就地对数据进行排序,使用column参数来确定每行的哪一列将被返回进行排序。如果请求降序排序,我们将使用reverse()来相应地改变排序顺序。
sort()还必须发出两个信号:
这两个信号被视图用来适当地重绘自己,因此重要的是要记得发出它们。
我们的模型目前是只读的,但因为我们正在实现CSV编辑器,我们需要实现写入数据。首先,我们需要重写一些方法以启用对现有数据行的编辑:flags()和setData()。
flags()接受一个QModelIndex值,并为给定索引处的项目返回一组QtCore.Qt.ItemFlag常量。这些标志用于指示项目是否可以被选择、拖放、检查,或者——对我们来说最有趣的是——编辑。
我们的方法如下:
defflags(self,index):returnsuper().flags(index)|qtc.Qt.ItemIsEditable在这里,我们将ItemIsEditable标志添加到父类flags()方法返回的标志列表中,指示该项目是可编辑的。如果我们想要实现逻辑,在某些条件下只使某些单元格可编辑,我们可以在这个方法中实现。
例如,如果我们有一个存储在self.readonly_indexes中的只读索引列表,我们可以编写以下方法:
defflags(self,index):ifindexnotinself.readonly_indexes:returnsuper().flags(index)|qtc.Qt.ItemIsEditableelse:returnsuper().flags(index)然而,对于我们的应用程序,我们希望每个单元格都是可编辑的。
现在模型中的所有项目都标记为可编辑,我们需要告诉我们的模型如何实际编辑它们。这在setData()方法中定义:
defsetData(self,index,value,role):ifindex.isValid()androle==qtc.Qt.EditRole:self._data[index.row()][index.column()]=valueself.dataChanged.emit(index,index,[role])returnTrueelse:returnFalsesetData()方法接受要设置的项目的索引、要设置的值和项目角色。此方法必须承担设置数据的任务,然后返回一个布尔值,指示数据是否成功更改。只有在索引有效且角色为EditRole时,我们才希望这样做。
data()方法还有一个小改变,虽然不是必需的,但会让用户更容易操作。回去编辑该方法如下:
defdata(self,index,role):ifrolein(qtc.Qt.DisplayRole,qtc.Qt.EditRole):returnself._data[index.row()][index.column()]当选择表格单元格进行编辑时,将使用EditRole角色调用data()。在这个改变之前,当使用该角色调用data()时,data()会返回None,结果,单元格中的数据将在选择单元格时消失。通过返回EditRole的数据,用户将可以访问现有数据进行编辑。
我们现在已经实现了对现有单元格的编辑,但为了使我们的模型完全可编辑,我们需要实现插入和删除行。我们可以通过重写另外两个方法来实现这一点:insertRows()和removeRows()。
insertRows()方法如下:
definsertRows(self,position,rows,parent):self.beginInsertRows(parentorqtc.QModelIndex(),position,position+rows-1)foriinrange(rows):default_row=['']*len(self._headers)self._data.insert(position,default_row)self.endInsertRows()该方法接受插入开始的位置,要插入的行数以及父节点索引(与分层数据一起使用)。
在该方法内部,我们必须在调用beginInsertRows()和endInsertRows()之间放置我们的逻辑。beginInsertRows()方法准备了底层对象进行修改,并需要三个参数:
我们可以根据传入方法的起始位置和行数来计算所有这些。一旦我们处理了这个问题,我们就可以生成一些行(以空字符串列表的形式,长度与我们的标题列表相同),并将它们插入到self._data中的适当索引位置。
在插入行后,我们调用endInsertRows(),它不带任何参数。
removeRows()方法非常相似:
defremoveRows(self,position,rows,parent):self.beginRemoveRows(parentorqtc.QModelIndex(),position,position+rows-1)foriinrange(rows):del(self._data[position])self.endRemoveRows()再次,我们需要在编辑数据之前调用beginRemoveRows(),在编辑后调用endRemoveRows(),就像我们对插入一样。如果我们想允许编辑列结构,我们可以重写insertColumns()和removeColumns()方法,它们的工作方式与行方法基本相同。现在,我们只会坚持行编辑。
到目前为止,我们的模型是完全可编辑的,但我们将添加一个方法,以便将数据刷新到磁盘,如下所示:
defsave_data(self):withopen(self.filename,'w',encoding='utf-8')asfh:writer=csv.writer(fh)writer.writerow(self._headers)writer.writerows(self._data)这个方法只是打开我们的文件,并使用Python的csv库写入标题和所有数据行。
现在我们的模型已经准备好使用了,让我们充实应用程序的其余部分,以演示如何使用它。
首先,我们需要创建一个QTableView小部件,并将其添加到我们的MainWindow中:
#inMainWindow.__init__()self.tableview=qtw.QTableView()self.tableview.setSortingEnabled(True)self.setCentralWidget(self.tableview)如您所见,我们不需要做太多工作来使QTableView小部件与模型一起工作。因为我们在模型中实现了sort(),我们将启用排序,但除此之外,它不需要太多配置。
当然,要查看任何数据,我们需要将模型分配给视图;为了创建一个模型,我们需要一个文件。让我们创建一个回调来获取一个:
defselect_file(self):filename,_=qtw.QFileDialog.getOpenFileName(self,'SelectaCSVfiletoopen…',qtc.QDir.homePath(),'CSVFiles(*.csv);;AllFiles(*)')iffilename:self.model=CsvTableModel(filename)self.tableview.setModel(self.model)我们的方法使用QFileDialog类来询问用户要打开的CSV文件。如果选择了一个文件,它将使用CSV文件来创建我们模型类的一个实例。然后使用setModel()访问方法将模型类分配给视图。
回到MainWindow.__init__(),让我们为应用程序创建一个主菜单,并添加一个“打开”操作:
menu=self.menuBar()file_menu=menu.addMenu('File')file_menu.addAction('Open',self.select_file)如果您现在运行脚本,您应该能够通过转到“文件|打开”并选择有效的CSV文件来打开文件。您应该能够查看甚至编辑数据,并且如果单击标题单元格,数据应该按列排序。
接下来,让我们添加用户界面组件,以便保存我们的文件。首先,创建一个调用MainWindow方法save_file()的菜单项:
file_menu.addAction('Save',self.save_file)现在,让我们创建我们的save_file()方法来实际保存文件:
defsave_file(self):ifself.model:self.model.save_data()要保存文件,我们实际上只需要调用模型的save_data()方法。但是,我们不能直接将菜单项连接到该方法,因为在实际加载文件之前模型不存在。这个包装方法允许我们创建一个没有模型的菜单选项。
我们想要连接的最后一个功能是能够插入和删除行。在电子表格中,能够在所选行的上方或下方插入行通常是有用的。因此,让我们在MainWindow中创建回调来实现这一点:
definsert_above(self):selected=self.tableview.selectedIndexes()row=selected[0].row()ifselectedelse0self.model.insertRows(row,1,None)definsert_below(self):selected=self.tableview.selectedIndexes()row=selected[-1].row()ifselectedelseself.model.rowCount(None)self.model.insertRows(row+1,1,None)在这两种方法中,我们通过调用表视图的selectedIndexes()方法来获取所选单元格的列表。这些列表从左上角的单元格到右下角的单元格排序。因此,对于插入上方,我们检索列表中第一个索引的行(如果列表为空,则为0)。对于插入下方,我们检索列表中最后一个索引的行(如果列表为空,则为表中的最后一个索引)。最后,在这两种方法中,我们使用模型的insertRows()方法将一行插入到适当的位置。
删除行类似,如下所示:
defremove_rows(self):selected=self.tableview.selectedIndexes()ifselected:self.model.removeRows(selected[0].row(),len(selected),None)这次我们只在有活动选择时才采取行动,并使用模型的removeRows()方法来删除第一个选定的行。
为了使这些回调对用户可用,让我们在MainWindow中添加一个“编辑”菜单:
edit_menu=menu.addMenu('Edit')edit_menu.addAction('InsertAbove',self.insert_above)edit_menu.addAction('InsertBelow',self.insert_below)edit_menu.addAction('RemoveRow(s)',self.remove_rows)此时,请尝试加载CSV文件。您应该能够在表中插入和删除行,编辑字段并保存结果。恭喜,您已经创建了一个CSV编辑器!
在本章中,您学习了模型视图编程。您学习了如何在常规小部件中使用模型,以及如何在Qt中使用特殊的模型视图类。您创建了一个自定义表模型,并通过利用模型视图类的功能快速构建了一个CSV编辑器。
我们将学习更高级的模型视图概念,包括委托和数据映射在第九章中,使用QtSQL探索SQL。
在下一章中,您将学习如何为您的PyQt应用程序设置样式。我们将使用图像、动态图标、花哨的字体和颜色来装扮我们的单调表单,并学习控制QtGUI整体外观和感觉的多种方法。
尝试这些问题来测试您从本章中学到的知识:
defsave_as(self):filename,_=qtw.QFileDialog(self)self.data.save_file(filename)classDataModel(QAbstractTreeModel):defrowCount(self,node):ifnode>2:return1else:returnlen(self._data[node])definsertColumns(self,col,count,parent):forrowinself._data:foriinrange(count):row.insert(col,'')进一步阅读您可能希望查看以下资源:
很容易欣赏到Qt默认提供的清晰、本地外观。但对于不那么商业化的应用程序,普通的灰色小部件和标准字体并不总是设置正确的语气。即使是最沉闷的实用程序或数据输入应用程序偶尔也会受益于添加图标或谨慎调整字体以增强可用性。幸运的是,Qt的灵活性使我们能够自己控制应用程序的外观和感觉。
在本章中,我们将涵盖以下主题:
在本章中,您将需要第一章中列出的所有要求,PyQt入门,以及第四章中的Qt应用程序模板,使用QMainWindow构建应用程序。
要做到这一点,打开应用程序模板的新副本,并将以下GUI代码添加到MainWindow.__init__()中:
self.setWindowTitle('FightFighterGameLobby')cx_form=qtw.QWidget()self.setCentralWidget(cx_form)cx_form.setLayout(qtw.QFormLayout())heading=qtw.QLabel("FightFighter!")cx_form.layout().addRow(heading)inputs={'Server':qtw.QLineEdit(),'Name':qtw.QLineEdit(),'Password':qtw.QLineEdit(echoMode=qtw.QLineEdit.Password),'Team':qtw.QComboBox(),'Ready':qtw.QCheckBox('Checkwhenready')}teams=('CrimsonSharks','ShadowHawks','NightTerrors','BlueCrew')inputs['Team'].addItems(teams)forlabel,widgetininputs.items():cx_form.layout().addRow(label,widget)self.submit=qtw.QPushButton('Connect',clicked=lambda:qtw.QMessageBox.information(None,'Connecting','PrepareforBattle!'))self.reset=qtw.QPushButton('Cancel',clicked=self.close)cx_form.layout().addRow(self.submit,self.reset)这是相当标准的QtGUI代码,您现在应该对此很熟悉;我们通过将输入放入dict对象中并在循环中将它们添加到布局中,节省了一些代码行,但除此之外,它相对直接。根据您的操作系统和主题设置,对话框框可能看起来像以下截图:
正如您所看到的,这是一个不错的表单,但有点单调。因此,让我们探讨一下是否可以改进样式。
我们要解决的第一件事是字体。每个QWidget类都有一个font属性,我们可以在构造函数中设置,也可以使用setFont()访问器来设置。font的值必须是一个QtGui.QFont对象。
以下是您可以创建和使用QFont对象的方法:
heading_font=qtg.QFont('Impact',32,qtg.QFont.Bold)heading_font.setStretch(qtg.QFont.ExtraExpanded)heading.setFont(heading_font)QFont对象包含描述文本将如何绘制到屏幕上的所有属性。构造函数可以接受以下任何参数:
字体的其余方面,如stretch属性,可以使用关键字参数或访问器方法进行配置。我们还可以创建一个没有参数的QFont对象,并按照以下方式进行程序化配置:
label_font=qtg.QFont()label_font.setFamily('Impact')label_font.setPointSize(14)label_font.setWeight(qtg.QFont.DemiBold)label_font.setStyle(qtg.QFont.StyleItalic)forinpininputs.values():cx_form.layout().labelForField(inp).setFont(label_font)在小部件上设置字体不仅会影响该小部件,还会影响所有子小部件。因此,我们可以通过在cx_form上设置字体而不是在单个小部件上设置字体来为整个表单配置字体。
现在,如果所有平台和操作系统(OSes)都提供了无限数量的同名字体,那么您需要了解的就是QFont。不幸的是,情况并非如此。大多数系统只提供了少数内置字体,并且这些字体中只有少数是跨平台的,甚至是平台的不同版本通用的。因此,Qt有一个处理缺失字体的回退机制。
例如,假设我们要求Qt使用一个不存在的字体系列,如下所示:
button_font=qtg.QFont('TotallyNonexistantFontFamilyXYZ',15.233)Qt不会在此调用时抛出错误,甚至不会注册警告。相反,在未找到请求的字体系列后,它将回退到其defaultFamily属性,该属性利用了操作系统或桌面环境中设置的默认字体。
QFont对象实际上不会告诉我们发生了什么;如果查询它以获取信息,它只会告诉您已配置了什么:
print(f'Fontis{button_font.family()}')#Prints:"FontisTotallyNonexistentFontFamilyXYZ"要发现实际使用的字体设置,我们需要将我们的QFont对象传递给QFontInfo对象:
actual_font=qtg.QFontInfo(button_font).family()print(f'Actualfontusedis{actual_font}')如果运行脚本,您会看到,很可能实际上使用的是默认的屏幕字体:
$pythongame_lobby.pyFontisTotallyNonexistentFontFamilyXYZActualfontusedisBitstreamVeraSans虽然这确保了用户不会在窗口中没有任何文本,但如果我们能让Qt更好地了解应该使用什么样的字体,那就更好了。
我们可以通过设置字体的styleHint和styleStrategy属性来实现这一点,如下所示:
button_font.setStyleHint(qtg.QFont.Fantasy)button_font.setStyleStrategy(qtg.QFont.PreferAntialias|qtg.QFont.PreferQuality)styleHint建议Qt回退到的一般类别,在本例中是Fantasy类别。这里的其他选项包括SansSerif、Serif、TypeWriter、Decorative、Monospace和Cursive。这些选项对应的内容取决于操作系统和桌面环境的配置。
设置这些属性后,再次检查字体,看看是否有什么变化:
actual_font=qtg.QFontInfo(button_font)print(f'Actualfontusedis{actual_font.family()}'f'{actual_font.pointSize()}')self.submit.setFont(button_font)self.cancel.setFont(button_font)根据系统的配置,您应该看到与之前不同的结果:
$pythongame_lobby.pyActualfontusedisImpact15在这个系统上,Fantasy被解释为Impact,而PreferQuality策略标志强制最初奇怪的15.233点大小成为一个漂亮的15。
此时,根据系统上可用的字体,您的应用程序应该如下所示:
字体也可以与应用程序捆绑在一起;请参阅本章中的使用Qt资源文件部分。
要创建一个,我们只需要将QPixmap传递给图像文件的路径:
logo=qtg.QPixmap('logo.png')一旦加载,QPixmap对象可以显示在QLabel或QButton对象中,如下所示:
heading.setPixmap(logo)请注意,标签只能显示字符串或像素图,但不能同时显示两者。
为了优化显示,QPixmap对象只提供了最小的编辑功能;但是,我们可以进行简单的转换,比如缩放:
iflogo.width()>400:logo=logo.scaledToWidth(400,qtc.Qt.SmoothTransformation)在这个例子中,我们使用了像素图的scaledToWidth()方法,使用平滑的转换算法将标志的宽度限制为400像素。
QPixmap对象如此有限的原因是它们实际上存储在显示服务器的内存中。QImage类似,但是它将数据存储在应用程序内存中,因此可以进行更广泛的编辑。我们将在第十二章中更多地探讨这个类,创建使用QPainter进行2D图形。
QPixmap还提供了一个方便的功能,可以生成简单的彩色矩形,如下所示:
go_pixmap=qtg.QPixmap(qtc.QSize(32,32))stop_pixmap=qtg.QPixmap(qtc.QSize(32,32))go_pixmap.fill(qtg.QColor('green'))stop_pixmap.fill(qtg.QColor('red'))通过在构造函数中指定大小并使用fill()方法,我们可以创建一个简单的彩色矩形像素图。这对于显示颜色样本或用作快速的图像替身非常有用。
以下是如何创建一个QIcon对象:
connect_icon=qtg.QIcon()connect_icon.addPixmap(go_pixmap,qtg.QIcon.Active)connect_icon.addPixmap(stop_pixmap,qtg.QIcon.Disabled)创建图标对象后,我们使用它的addPixmap()方法将一个QPixmap对象分配给小部件状态。这些状态包括Normal、Active、Disabled和Selected。
当禁用时,connect_icon图标现在将是一个红色的正方形,或者当启用时将是一个绿色的正方形。让我们将其添加到我们的提交按钮,并添加一些逻辑来切换按钮的状态:
self.submit.setIcon(connect_icon)self.submit.setDisabled(True)inputs['Server'].textChanged.connect(lambdax:self.submit.setDisabled(x==''))如果您在此时运行脚本,您会看到红色的正方形出现在提交按钮上,直到“服务器”字段包含数据为止,此时它会自动切换为绿色。请注意,我们不必告诉图标对象本身切换状态;一旦分配给小部件,它就会跟踪小部件状态的任何更改。
图标可以与QPushButton、QToolButton和QAction对象一起使用;QComboBox、QListView、QTableView和QTreeView项目;以及大多数其他您可能合理期望有图标的地方。
在程序中使用图像文件的一个重要问题是确保程序可以在运行时找到它们。传递给QPixmap构造函数或QIcon构造函数的路径被解释为绝对路径(即,如果它们以驱动器号或路径分隔符开头),或者相对于当前工作目录(您无法控制)。例如,尝试从代码目录之外的某个地方运行您的脚本:
不幸的是,指定绝对路径意味着您的程序只能从文件系统上的一个位置工作,这对于您计划将其分发到多个平台是一个重大问题。
PyQt为我们提供了一个解决这个问题的解决方案,即PyQt资源文件,我们可以使用PyQt资源编译器工具创建。基本过程如下:
让我们逐步走过这个过程——假设我们有一些队徽,以PNG文件的形式,我们想要包含在我们的程序中。我们的第一步是创建resources.qrc文件,它看起来像下面的代码块:
现在,在命令行中,我们将运行pyrcc5,如下所示:
回到我们的主要脚本中,我们只需要像导入任何其他Python文件一样导入这个文件:
importresources文件不一定要叫做resources.py;实际上,任何名称都可以。你只需要导入它,文件中的代码将确保资源对Qt可用。
现在资源文件已导入,我们可以使用资源语法指定像素图路径:
inputs['Team'].setItemIcon(0,qtg.QIcon(':/teams/crimson_sharks.png'))inputs['Team'].setItemIcon(1,qtg.QIcon(':/teams/shadow_hawks.png'))inputs['Team'].setItemIcon(2,qtg.QIcon(':/teams/night_terrors.png'))inputs['Team'].setItemIcon(3,qtg.QIcon(':/teams/blue_crew.png'))基本上,语法是:/prefix/file_name_or_alias.extension。
因为我们的数据存储在一个Python文件中,我们可以将它放在一个Python库中,它将使用Python的标准导入解析规则来定位文件。
资源文件不仅限于图像;实际上,它们可以用于包含几乎任何类型的二进制文件,包括字体文件。例如,假设我们想要在程序中包含我们喜欢的字体,以确保它在所有平台上看起来正确。
与图像一样,我们首先在.qrc文件中包含字体文件:
要在代码中使用这个字体,我们首先要将它添加到字体数据库中,如下所示:
libsans_id=qtg.QFontDatabase.addApplicationFont(':/fonts/LiberationSans-Regular.ttf')QFontDatabase.addApplicationFont()将传递的字体文件插入应用程序的字体数据库并返回一个ID号。然后我们可以使用该ID号来确定字体的系列字符串;这可以传递给QFont,如下所示:
family=qtg.QFontDatabase.applicationFontFamilies(libsans_id)[0]libsans=qtg.QFont(family)inputs['Team'].setFont(libsans)在分发应用程序之前,请确保检查字体的许可证!请记住,并非所有字体都可以自由分发。
我们的表单现在看起来更像游戏了;运行应用程序,它应该看起来类似于以下截图:
字体和图标改善了我们表单的外观,但现在是时候摆脱那些机构灰色调,用一些颜色来替换它们。在本节中,我们将看一下Qt为自定义应用程序颜色提供的三种不同方法:操纵调色板、使用样式表和覆盖应用程序样式。
由QPalette类表示的调色板是一组映射到颜色角色和颜色组的颜色和画笔的集合。
当小部件在屏幕上绘制时,Qt的绘图系统会查阅调色板,以确定用于渲染小部件的每个部分的颜色和画笔。要自定义这一点,我们可以创建自己的调色板并将其分配给一个小部件。
首先,我们需要获取一个QPalette对象,如下所示:
app=qtw.QApplication.instance()palette=app.palette()虽然我们可以直接创建一个QPalette对象,但Qt文档建议我们在运行的QApplication实例上调用palette()来检索当前配置样式的调色板的副本。
您可以通过调用QApplication.instance()来随时检索QApplication对象的副本。
现在我们有了调色板,让我们开始覆盖一些规则:
palette.setColor(qtg.QPalette.Button,qtg.QColor('#333'))palette.setColor(qtg.QPalette.ButtonText,qtg.QColor('#3F3'))QtGui.QPalette.Button和QtGui.QPalette.ButtonText是颜色角色常量,正如你可能猜到的那样,它们分别代表所有Qt按钮类的背景和前景颜色。我们正在用新颜色覆盖它们。
要覆盖特定按钮状态的颜色,我们需要将颜色组常量作为第一个参数传递:
palette.setColor(qtg.QPalette.Disabled,qtg.QPalette.ButtonText,qtg.QColor('#F88'))palette.setColor(qtg.QPalette.Disabled,qtg.QPalette.Button,qtg.QColor('#888'))在这种情况下,我们正在更改按钮处于Disabled状态时使用的颜色。
要应用这个新的调色板,我们必须将它分配给一个小部件,如下所示:
self.submit.setPalette(palette)self.cancel.setPalette(palette)setPalette()将提供的调色板分配给小部件和所有子小部件。因此,我们可以创建一个单独的调色板,并将其分配给我们的QMainWindow类,以将其应用于所有对象,而不是分配给单个小部件。
如果我们想要比纯色更花哨的东西,那么我们可以使用QBrush对象。画笔可以填充颜色、图案、渐变或纹理(即基于图像的图案)。
例如,让我们创建一个绘制白色点划填充的画笔:
图案有它们的用途,但基于渐变的画笔可能更适合现代风格。然而,创建一个可能会更复杂,如下面的代码所示:
gradient=qtg.QLinearGradient(0,0,self.width(),self.height())gradient.setColorAt(0,qtg.QColor('navy'))gradient.setColorAt(0.5,qtg.QColor('darkred'))gradient.setColorAt(1,qtg.QColor('orange'))gradient_brush=qtg.QBrush(gradient)要在画笔中使用渐变,我们首先必须创建一个渐变对象。在这里,我们创建了一个QLinearGradient对象,它实现了基本的线性渐变。参数是渐变的起始和结束坐标,我们指定为主窗口的左上角(0,0)和右下角(宽度,高度)。
Qt还提供了QRadialGradient和QConicalGradient类,用于提供额外的渐变选项。
创建对象后,我们使用setColorAt()指定颜色停止。第一个参数是0到1之间的浮点值,指定起始和结束之间的百分比,第二个参数是渐变应该在该点的QColor对象。
创建渐变后,我们将其传递给QBrush构造函数,以创建一个使用我们的渐变进行绘制的画笔。
我们现在可以使用setBrush()方法将我们的画笔应用于调色板,如下所示:
window_palette=app.palette()window_palette.setBrush(qtg.QPalette.Window,gradient_brush)window_palette.setBrush(qtg.QPalette.Active,qtg.QPalette.WindowText,dotted_brush)self.setPalette(window_palette)就像QPalette.setColor()一样,我们可以分配我们的画笔,无论是否指定了特定的颜色组。在这种情况下,我们的渐变画笔将用于绘制主窗口,而我们的点画画笔只有在小部件处于活动状态时才会使用(即当前活动窗口)。
对于已经使用过Web技术的开发人员来说,使用调色板、画笔和颜色对象来设计应用程序可能会显得啰嗦和不直观。幸运的是,Qt为您提供了一种称为QSS的替代方案,它与Web开发中使用的层叠样式表(CSS)非常相似。这是一种简单的方法,可以对我们的小部件进行一些简单的更改。
您可以按照以下方式使用QSS:
stylesheet="""QMainWindow{background-color:black;}QWidget{background-color:transparent;color:#3F3;}QLineEdit,QComboBox,QCheckBox{font-size:16pt;}"""self.setStyleSheet(stylesheet)在这里,样式表只是一个包含样式指令的字符串,我们可以将其分配给小部件的styleSheet属性。
这个语法对于任何使用过CSS的人来说应该很熟悉,如下所示:
WidgetClass{property-name:value;property-name2:value2;}如果此时运行程序,你会发现(取决于你的系统主题),它可能看起来像以下的截图:
在这里,界面大部分变成了黑色,除了文本和图像。特别是我们的按钮和复选框与背景几乎无法区分。那么,为什么会发生这种情况呢?
当您向小部件类添加QSS样式时,样式更改会传递到所有其子类。由于我们对QWidget进行了样式设置,所有其他QWidget派生类(如QCheckbox和QPushButton)都继承了这种样式。
让我们通过覆盖这些子类的样式来修复这个问题,如下所示:
stylesheet+="""QPushButton{background-color:#333;}QCheckBox::indicator:unchecked{border:1pxsolidsilver;background-color:darkred;}QCheckBox::indicator:checked{border:1pxsolidsilver;background-color:#3F3;}"""self.setStyleSheet(stylesheet)就像CSS一样,将样式应用于更具体的类会覆盖更一般的情况。例如,我们的QPushButton背景颜色会覆盖QWidget背景颜色。
请注意在QCheckBox中使用冒号-QSS中的双冒号允许我们引用小部件的子元素。在这种情况下,这是QCheckBox类的指示器部分(而不是其标签部分)。我们还可以使用单个冒号来引用小部件状态,就像在这种情况下,我们根据复选框是否选中或未选中来设置不同的样式。
如果您只想将更改限制为特定类,而不是其任何子类,只需在名称后添加一个句点(。),如下所示:
stylesheet+=""".QWidget{background:url(tile.png);}"""前面的示例还演示了如何在QSS中使用图像。就像在CSS中一样,我们可以提供一个包装在url()函数中的文件路径。
如果您已经使用pyrcc5序列化了图像,QSS还接受资源路径。
如果要将样式应用于特定小部件而不是整个小部件类,有两种方法可以实现。
第一种方法是依赖于objectName属性,如下所示:
self.submit.setObjectName('SubmitButton')stylesheet+="""#SubmitButton:disabled{background-color:#888;color:darkred;}"""在我们的样式表中,对象名称前必须加上一个
#符号用于将其标识为对象名称,而不是类。
在单个小部件上设置样式的另一种方法是调用t
使用小部件的setStyleSheet()方法和一些样式表指令,如下所示:
forinpin('Server','Name','Password'):inp_widget=inputs[inp]inp_widget.setStyleSheet('background-color:black')如果我们要直接将样式应用于我们正在调用的小部件,我们不需要指定类名或对象名;我们可以简单地传递属性和值。
经过所有这些更改,我们的应用程序现在看起来更像是一个游戏GUI:
正如您所看到的,QSS是一种非常强大的样式方法,对于任何曾经从事Web开发的开发人员来说都是可访问的;但是,它确实有一些缺点。
QSS是对调色板和样式对象的抽象,必须转换为实际系统。这使它们在大型应用程序中变得更慢,这也意味着没有默认样式表可以检索和编辑-每次都是从头开始。
正如我们已经看到的,当应用于高级小部件时,QSS可能会产生不可预测的结果,因为它通过类层次结构继承。
最后,请记住,QSS是CSS2.0的一个较小子集,带有一些添加或更改-它不是CSS。因此,过渡、动画、flexbox容器、相对单位和其他现代CSS好东西完全不存在。因此,尽管Web开发人员可能会发现其基本语法很熟悉,但有限的选项集可能会令人沮丧,其不同的行为也会令人困惑。
调色板和样式表可以帮助我们大大定制Qt应用程序的外观,对于大多数情况来说,这就是您所需要的。要真正深入了解Qt应用程序外观的核心,我们需要了解样式系统。
每个运行的Qt应用程序实例都有一个样式,负责告诉图形系统如何绘制每个小部件或GUI组件。样式是动态和可插拔的,因此不同的OS平台具有不同的样式,用户可以安装自己的Qt样式以在Qt应用程序中使用。这就是Qt应用程序能够在不同的操作系统上具有本机外观的原因。
在第一章中,使用PyQt入门,我们学到QApplication在创建时应传递sys.argv的副本,以便它可以处理一些特定于Qt的参数。其中一个参数是-style,它允许用户为其Qt应用程序设置自定义样式。
例如,让我们使用Windows样式运行第三章中的日历应用程序,使用信号和槽处理事件:
$python3calendar_app.py-styleWindows现在尝试使用Fusion样式,如下所示:
$python3calendar_app.py-styleFusion请注意外观上的差异,特别是输入控件。
样式中的大小写很重要;windows不是有效的样式,而Windows是!
常见OS平台上可用的样式如下表所示:
在许多Linux发行版中,可以从软件包存储库中获取其他Qt样式。可以通过调用QtWidgets.QStyleFactory.keys()来获取当前安装的样式列表。
样式也可以在应用程序内部设置。为了检索样式类,我们需要使用QStyleFactory类,如下所示:
if__name__=='__main__':app=qtw.QApplication(sys.argv)windows_style=qtw.QStyleFactory.create('Windows')app.setStyle(windows_style)QStyleFactory.create()将尝试查找具有给定名称的已安装样式,并返回一个QCommonStyle对象;如果未找到请求的样式,则它将返回None。然后可以使用样式对象来设置我们的QApplication对象的style属性。(None的值将导致其使用默认值。)
如果您计划在应用程序中设置样式,最好在绘制任何小部件之前尽早进行,以避免视觉故障。
构建Qt样式是一个复杂的过程,需要深入了解Qt的小部件和绘图系统,很少有开发人员需要创建一个。但是,我们可能希望覆盖运行样式的某些方面,以完成一些无法通过调色板或样式表的操作来实现的事情。我们可以通过对QtWidgets.QProxyStyle进行子类化来实现这一点。
代理样式是我们可以使用来覆盖实际运行样式的方法的覆盖层。这样,用户选择的实际样式是什么并不重要,我们的代理样式的方法(在实现时)将被使用。
例如,让我们创建一个代理样式,强制所有屏幕文本都是大写的,如下所示:
classStyleOverrides(qtw.QProxyStyle):defdrawItemText(self,painter,rect,flags,palette,enabled,text,textRole):"""Forceuppercaseinalltext"""text=text.upper()super().drawItemText(painter,rect,flags,palette,enabled,text,textRole)drawItemText()是在必须将文本绘制到屏幕时在样式上调用的方法。它接收许多参数,但我们最关心的是要绘制的text参数。我们只是要拦截此文本,并在将所有参数传回super().drawTextItem()之前将其转换为大写。
然后可以将此代理样式应用于我们的QApplication对象,方式与任何其他样式相同:
if__name__=='__main__':app=qtw.QApplication(sys.argv)proxy_style=StyleOverrides()app.setStyle(proxy_style)如果此时运行程序,您会看到所有文本现在都是大写。任务完成!
现在让我们尝试一些更有野心的事情。让我们将所有的QLineEdit输入框更改为绿色的圆角矩形轮廓。那么,我们如何在代理样式中做到这一点呢?
第一步是弄清楚我们要修改的小部件的元素是什么。这些可以在QStyle类的枚举常量中找到,它们分为三个主要类别:
这些类别中的每个项目都由QStyle的不同方法绘制;在这种情况下,我们想要修改的是PE_FrameLineEdit元素,这是一个原始元素(由PE_前缀表示)。这种类型的元素由QStyle.drawPrimitive()绘制,因此我们需要在代理样式中覆盖该方法。
将此方法添加到StyleOverrides中,如下所示:
defdrawPrimitive(self,element,option,painter,widget):"""OutlineQLineEditsinGreen"""要控制元素的绘制,我们需要向其painter对象发出命令,如下所示:
self.green_pen=qtg.QPen(qtg.QColor('green'))self.green_pen.setWidth(4)ifelement==qtw.QStyle.PE_FrameLineEdit:painter.setPen(self.green_pen)painter.drawRoundedRect(widget.rect(),10,10)else:super().drawPrimitive(element,option,painter,widget)绘图对象和绘图将在第十二章中完全介绍,使用QPainter创建2D图形,但是,现在要理解的是,如果element参数匹配QStyle.PE_FrameLineEdit,则前面的代码将绘制一个绿色的圆角矩形。否则,它将将参数传递给超类的drawPrimitive()方法。
请注意,在绘制矩形后,我们不调用超类方法。如果我们这样做了,那么超类将在我们的绿色矩形上方绘制其样式定义的小部件元素。
正如你在这个例子中看到的,使用QProxyStyle比使用调色板或样式表要复杂得多,但它确实让我们几乎无限地控制我们的小部件的外观。
无论你使用QSS还是样式和调色板来重新设计应用程序都没有关系;然而,强烈建议你坚持使用其中一种。否则,你的样式修改可能会相互冲突,并在不同平台和桌面设置上产生不可预测的结果。
没有什么比动画的巧妙使用更能为GUI增添精致的边缘。在颜色、大小或位置的变化之间平滑地淡入淡出的动态GUI元素可以为任何界面增添现代感。
Qt的动画框架允许我们使用QPropertyAnimation类在我们的小部件上创建简单的动画。在本节中,我们将探讨如何使用这个类来为我们的游戏大厅增添一些动画效果。
因为Qt样式表会覆盖另一个基于小部件和调色板的样式,所以你需要注释掉所有这些动画的样式表代码才能正常工作。
例如,让我们动画我们的标志,让它从左向右滚动。你可以通过添加一个属性动画对象来开始,如下所示:
self.heading_animation=qtc.QPropertyAnimation(heading,b'maximumSize')QPropertyAnimation需要两个参数:一个要被动画化的小部件(或其他类型的QObject类),以及一个指示要被动画化的属性的bytes对象(请注意,这是一个bytes对象,而不是一个字符串)。
接下来,我们需要配置我们的动画对象如下:
self.heading_animation.setStartValue(qtc.QSize(10,logo.height()))self.heading_animation.setEndValue(qtc.QSize(400,logo.height()))self.heading_animation.setDuration(2000)至少,我们需要为属性设置一个startValue值和一个endValue值。当然,这些值必须是属性所需的数据类型。我们还可以设置毫秒为单位的duration(默认值为250)。
配置好后,我们只需要告诉动画开始,如下所示:
self.heading_animation.start()有一些要求限制了QPropertyAnimation对象的功能:
不幸的是,对于大多数小部件,这些限制排除了我们可能想要动画的许多方面,特别是颜色。幸运的是,我们可以解决这个问题。
正如你在本章前面学到的,小部件颜色不是小部件的属性,而是调色板的属性。调色板不能被动画化,因为QPalette不是QObject的子类,而且setColor()需要的不仅仅是一个单一的值。
颜色是我们想要动画的东西,为了实现这一点,我们需要对小部件进行子类化,并将其颜色设置为Qt属性。
让我们用一个按钮来做到这一点;在脚本的顶部开始一个新的类,如下所示:
classColorButton(qtw.QPushButton):def_color(self):returnself.palette().color(qtg.QPalette.ButtonText)def_setColor(self,qcolor):palette=self.palette()palette.setColor(qtg.QPalette.ButtonText,qcolor)self.setPalette(palette)在这里,我们有一个QPushButton子类,其中包含用于调色板ButtonText颜色的访问器方法。但是,请注意这些是Python方法;为了对此属性进行动画处理,我们需要color成为一个实际的Qt属性。为了纠正这一点,我们将使用QtCore.pyqtProperty()函数来包装我们的访问器方法,并在底层Qt对象上创建一个属性。
您可以按照以下方式操作:
color=qtc.pyqtProperty(qtg.QColor,_color,_setColor)我们使用的属性名称将是Qt属性的名称。传递的第一个参数是属性所需的数据类型,接下来的两个参数是getter和setter方法。
pyqtProperty()也可以用作装饰器,如下所示:
@qtc.pyqtProperty(qtg.QColor)defbackgroundColor(self):returnself.palette().color(qtg.QPalette.Button)@backgroundColor.setterdefbackgroundColor(self,qcolor):palette=self.palette()palette.setColor(qtg.QPalette.Button,qcolor)self.setPalette(palette)请注意,在这种方法中,两个方法必须使用我们打算创建的属性名称相同的名称。
现在我们的属性已经就位,我们需要用ColorButton对象替换我们的常规QPushButton对象:
#Replacethesedefinitions#atthetopoftheMainWindowconstructorself.submit=ColorButton('Connect',clicked=lambda:qtw.QMessageBox.information(None,'Connecting','PrepareforBattle!'))self.cancel=ColorButton('Cancel',clicked=self.close)经过这些更改,我们可以如下地对颜色值进行动画处理:
self.text_color_animation=qtc.QPropertyAnimation(self.submit,b'color')self.text_color_animation.setStartValue(qtg.QColor('#FFF'))self.text_color_animation.setEndValue(qtg.QColor('#888'))self.text_color_animation.setLoopCount(-1)self.text_color_animation.setEasingCurve(qtc.QEasingCurve.InOutQuad)self.text_color_animation.setDuration(2000)self.text_color_animation.start()这个方法非常有效。我们还在这里添加了一些额外的配置设置:
现在,当您运行脚本时,请注意颜色从白色渐变到灰色,然后立即循环回白色。如果我们希望动画从一个值移动到另一个值,然后再平稳地返回,我们可以使用setKeyValue()方法在动画的中间放置一个值:
self.bg_color_animation=qtc.QPropertyAnimation(self.submit,b'backgroundColor')self.bg_color_animation.setStartValue(qtg.QColor('#000'))self.bg_color_animation.setKeyValueAt(0.5,qtg.QColor('darkred'))self.bg_color_animation.setEndValue(qtg.QColor('#000'))self.bg_color_animation.setLoopCount(-1)self.bg_color_animation.setDuration(1500)在这种情况下,我们的起始值和结束值是相同的,并且我们在动画的中间添加了一个值为0.5(动画进行到一半时)设置为第二个颜色。这个动画将从黑色渐变到深红色,然后再返回。您可以添加任意多个关键值并创建相当复杂的动画。
随着我们向GUI添加越来越多的动画,我们可能会发现有必要将它们组合在一起,以便我们可以将动画作为一个组来控制。这可以使用动画组类QParallelAnimationGroup和QSequentialAnimationGroup来实现。
这两个类都允许我们向组中添加多个动画,并作为一个组开始、停止、暂停和恢复动画。
例如,让我们将按钮动画分组如下:
self.button_animations=qtc.QParallelAnimationGroup()self.button_animations.addAnimation(self.text_color_animation)self.button_animations.addAnimation(self.bg_color_animation)QParallelAnimationGroup在调用其start()方法时会同时播放所有动画。相反,QSequentialAnimationGroup将按添加的顺序依次播放其动画,如下面的代码块所示:
self.all_animations=qtc.QSequentialAnimationGroup()self.all_animations.addAnimation(self.heading_animation)self.all_animations.addAnimation(self.button_animations)self.all_animations.start()通过像我们在这里所做的那样将动画组添加到其他动画组中,我们可以将复杂的动画安排成一个对象,可以一起启动、停止、暂停和恢复。
注释掉所有其他动画的start()调用并启动脚本。请注意,按钮动画仅在标题动画完成后开始。
我们将在第十二章使用QPainter进行2D图形中探索更多QPropertyAnimation的用法。
在本章中,我们学习了如何自定义PyQt应用程序的外观和感觉。我们还学习了如何操纵屏幕字体并添加图像。此外,我们还学习了如何以对路径更改具有弹性的方式打包图像和字体资源。我们还探讨了如何使用调色板和样式表改变应用程序的颜色和外观,以及如何覆盖样式方法来实现几乎无限的样式更改。最后,我们探索了使用Qt的动画框架进行小部件动画,并学习了如何向我们的类添加自定义Qt属性,以便我们可以对其进行动画处理。
在下一章中,我们将使用QtMultimedia库探索多媒体应用程序的世界。您将学习如何使用摄像头拍照和录制视频,如何显示视频内容,以及如何录制和播放音频。
尝试这些问题来测试您从本章学到的知识: