第七章:QtQuick控件
第七章:QtQuick控件
UI 控件
本章介绍如何使用 Qt Quick Controls 模块。 Qt Quick Controls 用于创建由标准组件(如按钮、标签、滑块等)构建的高级用户界面。
Qt Quick Controls 可以使用 布局模块 进行排列,并且易于设置样式。在深入定制样式之前,我们还将研究不同平台的各种样式。
控件简介
Qt Quick 可以提供原始的图形和交互元素让你从头开始构建用户界面。使用 Qt Quick Controls,可以从一组稍微结构化的控件开始构建。
控件种类丰富,从简单的文本标签、按钮到复杂的滑块和软键盘。如果你想创建基于经典用户交互模式下的用户界面,这些控件非常好用,它们提供了很丰富的基础功能。
Qt Quick控件自带开箱即用的丰富的样式,展示如下。Basic 是基础的平面样式,Universal(通用)样式基于微软通用设计规范,而 Material (材质)是基于谷歌设计规范,Fusion (混合)是桌面导向的样式。
有些样式可以通过修改调色板来调整。Imagine风格是基于图像文件,这允许图形设计师创建一个新的风格,而不需要编写任何代码,甚至不需要调整调色板颜色代码。
- Basic
- Fusion
- macOS
- Material
- Imagine
- Windows
- Universal
导入QtQuick.Controls
就可以使用Qt Quick Controls 2 控件了。下面模块也很有意思: QtQuick.Controls
- 基础控件QtQuick.Templates
- 影响控件行为的非可视化基础类型QtQuick.Controls.Imagine
- 支持Imagine样式QtQuick.Controls.Material
- 支持Material样式QtQuick.Controls.Universal
- 支持Universal样式Qt.labs.platform
- 支持平台原生的常见对话框,如文件选择框,颜色选择框等,以及系统托盘图标和标准路径。
Qt.Labs
注意Qt.labs
模块是实验性的,这意味着模块内的API在各版本间可能会有比较大的差异。
图片查看器
一起来看下稍大点的例子里,Qt Quick控件是如何使用的。我们将创建一个简单的图片查看器。
首先,我们使用Fusion样式创建一个桌面程序。之后在最终代码完成前,将对其进行重构使家具有移动设备的操作体验。
桌面版
桌面版基于经典的Windows桌面应用的样式,有一个菜单栏、一个工具栏以及一个文档区域。程序运行界面如下:
先使用Qt Creator工程模板创建一个空的Qt Quick应用,但需要在模板中将默认的Window
元素替换为QtQuick.Controls
模块里的ApplicationWindow
元素。下面的main.qml
中的代码将以默认的尺寸和标题生成一个Windows窗体。
- import QtQuick
- import QtQuick.Controls
- import Qt.labs.platform
- ApplicationWindow {
- visible: true
- width: 640
- height: 480
- // ...
- }
ApplicationWindow
由以下4个主要区域组成。由MenuBar
,ToolBar
,TabBar
控件生成的菜单栏、工具栏、状态栏,而内容区域则由子窗体承担。注意图片查看器一般没有状态栏,所以下面的代码里也没有状态栏,如上图所示。
编写桌面程序,我们使用Fusion 样式。通过配置文件、环境变量、命令行参数或程序中的C++代码,都可以配置样式。这里使用代码的方法,如下所示:
QQuickStyle::setStyle("Fusion");
接下来就在main.qml
中添加Image
元素来展示内容,以及其它用户界面。当用户点击这个元素时,它被用来承载图片,而目前它还只是占位符。background
属性用来替换窗体内容后面的背景的一个元素,当没有图片加载,或者图片边框宽高比例不足以填充满窗体内容区域时,背景元素将被显示。
- ApplicationWindow {
-
- // ...
-
- background: Rectangle {
- color: "darkGray"
- }
- Image {
- id: image
- anchors.fill: parent
- fillMode: Image.PreserveAspectFit
- asynchronous: true
- }
- // ...
- }
接下来增加工具栏ToolBar
。这要用到Window的toolBar
属性。在工具栏里要增加一个Flow
元素,以确保工具按钮的适当宽度,当工具按钮足够多时会在适当的位置换到下一行。在这个flow元素中,放一个ToolButton
工具按钮。
ToolButton
有一些有意思的属性。Text
属性是字符串型的,而icon.name
的值取自freedesktop.org Icon Naming Specification (opens new window)。在本文档中,按名字列出了标准图标的列表。通过引用图标名,Qt将为当前桌面样式选择恰当的图标。
在ToolButton
的onClicked
信号处理函数中编写一段代码,它调用了fileOpenDialog
元素的open
函数。
- ApplicationWindow {
-
- // ...
-
- header: ToolBar {
- Flow {
- anchors.fill: parent
- ToolButton {
- text: qsTr("Open")
- icon.name: "document-open"
- onClicked: fileOpenDialog.open()
- }
- }
- }
- // ...
- }
fileOpenDialog
元素是来自 Qt.labs.platform
模块的 FileDialog
控件。文件对话框可用于打开或保存文件。
首先在代码指定一个标题title
。然后我们使用 StandardsPaths
类设置起始文件夹。 StandardsPaths
类包含常用文件夹的指向链接,例如用户的主页、文档等。之后,我们设置一个文件类型过滤器来控制用户可以使用对话框查看和选择哪些文件。
最后,轮到 onAccepted
信号处理函数了,其中的 Image
元素被设置为承载并显示所选文件。还有一个 onRejected
信号,但这里不需要处理它。
ApplicationWindow {
// ...
FileDialog {
id: fileOpenDialog
title: "Select an image file"
folder: StandardPaths.writableLocation(StandardPaths.DocumentsLocation)
nameFilters: [
"Image files (*.png *.jpeg *.jpg)",
]
onAccepted: {
image.source = fileOpenDialog.fileUrl
}
}
// ...
}
接下来处理菜单栏MenuBar
。创建菜单,要将Menu
元素放到菜单栏中,然后在每个菜单Menu
中弹出菜单项MenuItem
元素。
以下代码创建了两个菜单:File
和 Help
。在File
下放一个Open
菜单项,将图标和动作设置得与工具栏上的 打开 按钮一致。在Help
下会看到一个About
菜单,它会调用 aboutDialog
的 Open
方法。
请注意,Menu
的 title
属性和 MenuItem
的 text
属性中的逻辑与符号 (“&”) 将其后字符转换为键盘快捷键;例如按 Alt+F 进入文件菜单,然后按 Alt+O 触发打开项目。
ApplicationWindow {
// ...
menuBar: MenuBar {
Menu {
title: qsTr("&File")
MenuItem {
text: qsTr("&Open...")
icon.name: "document-open"
onTriggered: fileOpenDialog.open()
}
}
Menu {
title: qsTr("&Help")
MenuItem {
text: qsTr("&About...")
onTriggered: aboutDialog.open()
}
}
}
// ...
}
aboutDialog
元素基于 QtQuick.Controls
模块中的 Dialog
控件,而它(Dialog)是自定义对话框的基础。我们即将创建的对话框如下图所示。
aboutDialog
的代码可以分为三个部分。首先,我们设置带有标题的对话窗口。然后,我们为对话框提供一些内容——在本例中是一个标签控件。最后,我们选择使用标准的 Ok 按钮来关闭对话框。
ApplicationWindow {
// ...
Dialog {
id: aboutDialog
title: qsTr("About")
Label {
anchors.fill: parent
text: qsTr("QML Image Viewer\nA part of the QmlBook\nhttp://qmlbook.org")
horizontalAlignment: Text.AlignHCenter
}
standardButtons: StandardButton.Ok
}
// ...
}
以上便完成一个用于查看图像的简单可用的桌面应用程序。
迁移到移动端
用户对于应用在移动终端上和桌面上的运行方式及界面样式有很多不同的期待。最大的不同在于功能如何被触达到。不同于菜单栏和工具栏,这里将使用抽屉式功能访问方式。抽屉可以推进侧边隐藏,同时在标题栏上提供了关闭按扭。下图是抽屉菜单被打开的样子。
首先,需要在main.cpp
里将样式从Fution
改为Material
。
QQuickStyle::setStyle("Material");
然后开始适配用户界面。先把菜单替换为抽屉。下面的代码,把Drawer
控件添加到ApplicationWindow
下作为子元素。在抽屉元素中,添加包含ItemDelegate
的ListView
。也包含一个ScrollIndicator
滚动条,以便当内容过长时方便拖动显示。我们的列表中只有两个项目,所在本例中见不到这个滚动条。
抽屉菜单的ListView
是由ListModel
填充的,每个ListItem
对应一个菜单项。每当一个项目被点击,会调用onClicked
方法,进而会调用相应的ListItem
的triggered
方法。这样,我们就可以用一个委托来触发不同的动作,具体代码如下:
ApplicationWindow {
// ...
id: window
Drawer {
id: drawer
width: Math.min(window.width, window.height) / 3 * 2
height: window.height
ListView {
focus: true
currentIndex: -1
anchors.fill: parent
delegate: ItemDelegate {
width: parent.width
text: model.text
highlighted: ListView.isCurrentItem
onClicked: {
drawer.close()
model.triggered()
}
}
model: ListModel {
ListElement {
text: qsTr("Open...")
triggered: function() { fileOpenDialog.open(); }
}
ListElement {
text: qsTr("About...")
triggered: function() { aboutDialog.open(); }
}
}
ScrollIndicator.vertical: ScrollIndicator { }
}
}
// ...
}
下一个更改是在 ApplicationWindow
的标题header
中。我们添加了一个用于打开抽屉的按钮和一个用于应用程序标题的标签,而不是桌面样式的工具栏。
ToolBar
包含两个子项:ToolButton
和Label
。
ToolButton
用于打开抽屉,相应的关闭close
函数,可以在ListView
的代理函数中找到。当菜单项被选中,抽屉就关掉了。ToolButton
的图标来自材质设计图标页
ApplicationWindow {
// ...
header: ToolBar {
ToolButton {
id: menuButton
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
icon.source: "images/baseline-menu-24px.svg"
onClicked: drawer.open()
}
Label {
anchors.centerIn: parent
text: "Image Viewer"
font.pixelSize: 20
elide: Label.ElideRight
}
}
// ...
}
最后,我们使工具栏的背景漂亮些—至少换成橙色的。为此,我们更改 Material.background
附加属性。这是 QtQuick.Controls.Material
里的模块,仅影响 Material 样式。
import QtQuick.Controls.Material
ApplicationWindow {
// ...
header: ToolBar {
Material.background: Material.Orange
// ...
}
通过这些代码更改,我们将桌面版的图片查看器转化成了适合移动设备的版本。
共享代码
在以上两部分代码中,我们看到一个桌面版的图片查看器改造与移动版的过程。
看下代码,大部分代码仍然是共享的。相同的部分多是跟文档区域相关的,如,图片。不同这处主要在于桌面与移动端各自不同的操作方式。我们当然想将这些代码统一起来。QML通过文件选择器***file selectors***可以实现。
文件选择器允许替换被标记为活动的个性化文件。Qt文档中维护了一个选择器QFileSelector
类列表。本例中,我们将桌面版文件设为默认,当遇到Android选择器时,再替换成别的。开发时,可以把环境变量QT_FILE_SELECTORS
设置为android
来模拟适配。
文件选择器
通过selector
,文件选择器可以将文件替换为备选文件。
通过在你想替换的文件的同一目录下创建一个名为+selector
的目录(其中selector
代表一个选择器的名称),然后你可以在该目录内放置与你想替换的文件同名的文件。当选择器出现时,该目录中的文件将被选中替换掉原始文件。
选择器是基于平台的:如安卓、ios、osx、linux、qnx等。它们还可以包括所使用的Linux发行版的名称(如果能确定的话),例如:Debian、ubuntu、Fedora。最后,它们还包括地区设置,如en_US、sv_SE,等等。
也可以添加你自定义选择器。
第一步是分离出共享代码。创建ImageViewerWindow
元素来代替ApplicationWindow
用于我们的两个版本中。这将包括对话框、Image
元素和背景。为了在特定于平台都可以正常打开对话框,我们需要使用函数 openFileDialog
和 openAboutDialog
。
import QtQuick
import QtQuick.Controls
import Qt.labs.platform
ApplicationWindow {
function openFileDialog() { fileOpenDialog.open(); }
function openAboutDialog() { aboutDialog.open(); }
visible: true
title: qsTr("Image Viewer")
background: Rectangle {
color: "darkGray"
}
Image {
id: image
anchors.fill: parent
fillMode: Image.PreserveAspectFit
asynchronous: true
}
FileDialog {
id: fileOpenDialog
// ...
}
Dialog {
id: aboutDialog
// ...
}
}
接下来,我们为我们的默认样式 Fusion 创建一个新的 main.qml
,即用户界面的桌面版本。
这里,我们围绕 ImageViewerWindow
而不是 ApplicationWindow
建立用户界面。然后我们将平台特定的部分添加进来,例如菜单栏MenuBar
和工具栏ToolBar
。对这些的唯一更改是打开相应对话框的调用是针对新功能而不是直接针对对话框控件进行的。唯一变化的是,打开各自的对话框的函数调用是由新的函数来完成的,而不是直接调用对话框控件。
import QtQuick
import QtQuick.Controls
ImageViewerWindow {
id: window
width: 640
height: 480
menuBar: MenuBar {
Menu {
title: qsTr("&File")
MenuItem {
text: qsTr("&Open...")
icon.name: "document-open"
onTriggered: window.openFileDialog()
}
}
Menu {
title: qsTr("&Help")
MenuItem {
text: qsTr("&About...")
onTriggered: window.openAboutDialog()
}
}
}
header: ToolBar {
Flow {
anchors.fill: parent
ToolButton {
text: qsTr("Open")
icon.name: "document-open"
onClicked: window.openFileDialog()
}
}
}
}
接下来,我们必须创建一个适用于移动设备的main.qml
。这将基于 Material
主题。在这里,我们保留了 Drawer
和适配于移动设备的工具栏。同样,唯一的变化是对话框的打开方式。
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
ImageViewerWindow {
id: window
width: 360
height: 520
Drawer {
id: drawer
// ...
ListView {
// ...
model: ListModel {
ListElement {
text: qsTr("Open...")
triggered: function(){ window.openFileDialog(); }
}
ListElement {
text: qsTr("About...")
triggered: function(){ window.openAboutDialog(); }
}
}
// ...
}
}
header: ToolBar {
// ...
}
}
两个 main.qml
文件放在文件系统中,如下所示。这让 QML 引擎自动创建的文件选择器可以选择正确的文件。默认情况下,会加载 Fusion main.qml
。如果存在 android
选择器,则改为加载 Material main.qml
。
目前样式存在main.cpp
。我们可以继续在main.cpp中使用条件表达式#ifdef
来为不同的平台设置不同的样式。但我们要使用文件选择器通过选择配置文件来设置样式。下面你可以看到Material样式文件,而Fusion样式文件也同样简单。
[Controls]
Style=Material
通过这些变化,我们把所有可共享的代码整合起来,仅把用户交互有差异的代码单独处理。有多种实现方式,比如,将文档保存在包含特定平台接口的特定组件中,或者象本例那样,从不同平台中抽象出共同的代码。当你知道特定平台的样式且能够从特性中分离出共性时,就会做出最佳的路径来决定如何处理代码。
原生对话框
当使用图片查看器程序时,你会发现它使用了非标准文件选择窗口,看起来挺别扭。
Qt.labs.platform
模块有助于解决这个问题。它将QML绑定到原生窗口,如文件选择框、颜色选择框、字体选择框等。同时也提供API创建系统托盘图标,还能提供顶部的系统全局菜单(如OSX那样)。这样做的代价是对QtWidgets
模块的依赖,因为万一在缺少原生支持的情况时,会备份启用基于widget的对话框。
为了在图片查看器里集成原生对话框,我们需要引入Qt.labs.platform
模块。因为跟QtQuick.Dialogs
有命名空间的冲突,所以要删除旧的引入声明。
在实际的文件对话框元素中,我们必须更改文件夹folder
属性的设置方式,并确保 onAccepted
处理程序使用文件file
属性而不是fileUrl
属性。除了这些细节之外,用法与 QtQuick.Dialogs
中的 FileDialog
相同。
import QtQuick
import QtQuick.Controls
import Qt.labs.platform
ApplicationWindow {
// ...
FileDialog {
id: fileOpenDialog
title: "Select an image file"
folder: StandardPaths.writableLocation(StandardPaths.DocumentsLocation)
nameFilters: [
"Image files (*.png *.jpeg *.jpg)",
]
onAccepted: {
image.source = fileOpenDialog.file
}
}
// ...
}
除了 QML 更改之外,我们还需要更改图像查看器的项目文件以包含widgets
模块。
QT += quick quickcontrols2 widgets
还需要更新main.qml
来实例化QApplication
对象,用于取代QGuiApplication
对象。因为QGuiApplication
对于图形应用有最小化的环境依赖,而QApplication
继承自QGuiApplication
,且支持QtWidgets
的特征。
include <QApplication>
// ...
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
// ...
}
通过这些更改,图像查看器现在将在大多数平台上使用原生对话框。支持的平台是 iOS、Linux(带有 GTK+ 平台主题)、macOS、Windows 和 WinRT。对于 Android,它将使用 QtWidgets
模块提供的默认 Qt 对话框。
常见样式
使用Qt Quick Controls可以实现很多常见的用户界面样式。本部分,我们将演示一些常见样式如何创建。
嵌套界面
这个例子里,将创建一个层级界面,每页都可以从上级界面访问。结构如下:
这个用户界面的关键元素是StackView
。它可以在栈中放置一个界面,当用户想返回时,可以从栈中弹出。本例将展示如何实现。
程序的起始界面如下:
我们从mail.qml
开始,这里定义了一个ApplicationWindow
,它包含了一个ToolBar
,一个Drawer
,一个StackView
还有一个Home
元素。下面挨个组件看一下。
import QtQuick
import QtQuick.Controls
ApplicationWindow {
// ...
header: ToolBar {
// ...
}
Drawer {
// ...
}
StackView {
id: stackView
anchors.fill: parent
initialItem: Home {}
}
}
首页Home.qml
由page
组成,page
是一个支持页眉和页脚的控件元素。本例,我们只居中放置一个写有Home Screen 的标签。因为StackView
的内容自动填充界面,所以页面page
会有恰当的尺寸。
import QtQuick
import QtQuick.Controls
Page {
title: qsTr("Home")
Label {
anchors.centerIn: parent
text: qsTr("Home Screen")
}
}
回到main.qml
,看一下抽屉菜单的部分。这是导航栏可以导到起始页面。当前用户界面是ItemDelegate
项。在onClicked
函数中,下一页被压入栈stackView
中。
正如下面的代码所示,它可以推送一个Component
或一个特定QML文件的引用。无论哪种方式都会导致一个新的实例被创建并推送到堆栈。
ApplicationWindow {
// ...
Drawer {
id: drawer
width: window.width * 0.66
height: window.height
Column {
anchors.fill: parent
ItemDelegate {
text: qsTr("Profile")
width: parent.width
onClicked: {
stackView.push("Profile.qml")
drawer.close()
}
}
ItemDelegate {
text: qsTr("About")
width: parent.width
onClicked: {
stackView.push(aboutPage)
drawer.close()
}
}
}
}
// ...
Component {
id: aboutPage
About {}
}
// ...
}
另一个难题是工具栏。思路是,当stackView
中包含多于一个页面时,工具栏上显示一个后退按钮,否则显示一个菜单按钮。这个逻辑可以在text
属性中看到,其中\\u...
字符串表示一个unicode图标。
在(工具栏上的按钮)onClicked
处理函数中,可以看到当栈中超过一个页面时,栈中最顶层的页面会被弹出栈。如果栈中仅有一个项,比如只有首页,抽屉菜单就会弹出显示。
在ToolBar
下面,有一个标签Label
。这个元素在页眉中间显示一个中心界面页的标题。
ApplicationWindow {
// ...
header: ToolBar {
contentHeight: toolButton.implicitHeight
ToolButton {
id: toolButton
text: stackView.depth > 1 ? "\u25C0" : "\u2630"
font.pixelSize: Qt.application.font.pixelSize * 1.6
onClicked: {
if (stackView.depth > 1) {
stackView.pop()
} else {
drawer.open()
}
}
}
Label {
text: stackView.currentItem.title
anchors.centerIn: parent
}
}
// ...
}
现在,一起来看看如何操作 About 和 Profile页面,同时也要能够从 Profile 页面访问 Edit Profile 页面。这可通过在 Profile 页面上的 Button 来实现。点击按钮时,EditProfile.qml
被推入栈StackView
。
import QtQuick
import QtQuick.Controls
Page {
title: qsTr("Profile")
Column {
anchors.centerIn: parent
spacing: 10
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Profile")
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Edit");
onClicked: stackView.push("EditProfile.qml")
}
}
}
并行屏幕
本例中将创建一个界面由用户可切换的三个页面组成。页面关系如下图所示。这可以作为健康追踪应用的界面,追踪用户当前的状态、指标统计、统计总览。
以下证明当前页Current
在程序中如何显示。屏幕的主要部分由SwipeView
负责管理,就是它实现了并行屏幕的交互模式。图中的标题与文本来自于SwipeView
中的页面。而PageIndactor
(页面指示器,屏幕下的三个小点)来自于main.qml
,位于SwipeView
下面。页面指示器向用户指明当前的活动页,以帮助用户选择页面。
来看main.qml
,它是由内部嵌套SwipeView
的ApplicationWindow
构成。
import QtQuick
import QtQuick.Controls
ApplicationWindow {
visible: true
width: 640
height: 480
title: qsTr("Side-by-side")
SwipeView {
// ...
}
// ...
}
在SwipeView
里,子页面将按照声明的顺序进行初始化显示。子页面分别是Current
,UserStats
,TotalStats
。
ApplicationWindow {
// ...
SwipeView {
id: swipeView
anchors.fill: parent
Current {
}
UserStats {
}
TotalStats {
}
}
// ...
}
最后,把SwipeView
的count
和currentIndex
属性绑到PageIndactor
元素。这样就完成了组织这些页面的架构。
ApplicationWindow {
// ...
SwipeView {
id: swipeView
// ...
}
PageIndicator {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentIndex: swipeView.currentIndex
count: swipeView.count
}
}
每个界面都有一个page
页面,页面上有header
标题,标题上有Label
标签,页面还有其它内容。对于Current
和User Stats
页面,其内容仅有一个Label
标签,但对于Community Stats
页面,还包括一个退回按钮。
import QtQuick
import QtQuick.Controls
Page {
header: Label {
text: qsTr("Community Stats")
font.pixelSize: Qt.application.font.pixelSize * 2
padding: 10
}
// ...
}
退回按钮单独调用了
SwipeView
的setCurrentIndex
方法,来将当前页设置为第0页,将用户直接导航到Current
页。每次页面切换时,SwipeView
都提供了过渡效果,所以,单独切换页面索引时,会有切换的方向感。
提示
当编程实现SwipeView
的页面切换时,一定不要使用JavaScript脚本赋值来指定currentIndex
。因为这样会破坏QML的绑定关系。正确的做法是使用setCurrentIndex
、incrementCurrentIndex
和decrementCurrentIndex
方法。这样就保留了QML的绑定关系。关于绑定与赋值的关系与区别,前面的第五章快速入门中的***Binding绑定***有详细介绍,点这里查看。
Page {
// ...
Column {
anchors.centerIn: parent
spacing: 10
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Community statistics")
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Back")
onClicked: swipeView.setCurrentIndex(0);
}
}
}
文档视图
本例展示如何实现一个面向桌面应用的,以文档为中心的用户界面。思路是:每个文档都有一个窗口,每打开一个新文档,同时开启一个新窗口。对用户来说,每个窗口都是包含一个文档。
/media/sammy/工作盘/forstudy/qt6book/docs/ch06-controls/assets/interface-document-window.png
代码从ApplicationWindow
开始,包含一个文件菜单File
,菜单中有一些常见操作:New,Open,Save,Save As。我们将这些放在DocumentWindow.qml
里。
使用原生对话框,要导入Qt.labs.platform
,还要针对原生对话框,把工程文件和main.cpp
做一系列的更改。
import QtQuick
import QtQuick.Controls
import Qt.labs.platform as NativeDialogs
ApplicationWindow {
id: root
// ...
menuBar: MenuBar {
Menu {
title: qsTr("&File")
MenuItem {
text: qsTr("&New")
icon.name: "document-new"
onTriggered: root.newDocument()
}
MenuSeparator {}
MenuItem {
text: qsTr("&Open")
icon.name: "document-open"
onTriggered: openDocument()
}
MenuItem {
text: qsTr("&Save")
icon.name: "document-save"
onTriggered: saveDocument()
}
MenuItem {
text: qsTr("Save &As...")
icon.name: "document-save-as"
onTriggered: saveAsDocument()
}
}
}
// ...
}
要启动程序,先从main.qml
创建一个DocumentWindow
实例,这也是程序的入口。
import QtQuick
DocumentWindow {
visible: true
}
在本章开头的例子中,每个MenuItem
被点击时,会调用一个相应的函数。先处理New
菜单项,它调用了newDocument
函数。
这个函数接着又调用了createNewDocument
,从而从DocumentWindow.qml
中动态创建一个新的实例,也就是新的DocumentWindow
实例。单独新拆分成一个函数的原因是,每当打开文档时,都要用到它。
注意,在使用createObject
创建新实例时,我们并没有指定父元素。这样,就创建了一个顶层的元素。如果在创新文档时,将当前元素指定为父元素,那么当父窗口销毁时,也会将其子窗口销毁。
ApplicationWindow {
// ...
function createNewDocument()
{
var component = Qt.createComponent("DocumentWindow.qml");
var window = component.createObject();
return window;
}
function newDocument()
{
var window = createNewDocument();
window.show();
}
// ...
}
看下Open
菜单,发现它调用了openDocument
函数。它只是调用了openDialog
,让用户选择要打开的文件。如果没有指定文档类型、文件扩展名等,对话框会将大多数的属性设置为默认值。在实际应用中,应当设置一下这些参数(文件类型、扩展名,以对要打开的文件进行过滤)。
在onAccepted
函数中,调用createdNewDocument
函数创建了一个新的文档窗口实例,在窗口显示前已经设置好了文件名。本例中,没有真的加载文件。
提示
我们将模块Qt.labs.platforms
引入为NativeDialogs
,因为其中的MenuItem
与QtQuick.Controls
模块中的MenuItem
有命名冲突。
ApplicationWindow {
// ...
function openDocument(fileName)
{
openDialog.open();
}
NativeDialogs.FileDialog {
id: openDialog
title: "Open"
folder: NativeDialogs.StandardPaths.writableLocation(NativeDialogs.StandardPaths.DocumentsLocation)
onAccepted: {
var window = root.createNewDocument();
window.fileName = openDialog.file;
window.show();
}
}
// ...
}
文件名包含一对描述文档的属性:fileName
和isDirty
。fileName
属性表示文档的名称,而isDirty
表示文档是否有未保存的变更。这个逻辑用于保存和另存为逻辑,下面会提到。
当未指定文件名做保存操作时,saveAsDocument
被激活。这导至saveAsDialog
窗体调用,这会指定一个文件名并尝试在onAccepted
函数中再次保存。
注意 ,saveAsDocument
和saveDocument
对应着Save as 和 Save菜单。
文档保存后,在saveDocument
函数中,tryingToClose
属性被选中。如果用户想要在关闭窗口时保存文档,这个标志就会被设置。相应的,对文档进行保存操作后,窗体也会被关掉。再次强调,本例中并没有实际地保存。
ApplicationWindow {
// ...
property bool isDirty: true // Has the document got unsaved changes?
property string fileName // The filename of the document
property bool tryingToClose: false // Is the window trying to close (but needs a file name first)?
// ...
function saveAsDocument()
{
saveAsDialog.open();
}
function saveDocument()
{
if (fileName.length === 0)
{
root.saveAsDocument();
}
else
{
// Save document here
console.log("Saving document")
root.isDirty = false;
if (root.tryingToClose)
root.close();
}
}
NativeDialogs.FileDialog {
id: saveAsDialog
title: "Save As"
folder: NativeDialogs.StandardPaths.writableLocation(NativeDialogs.StandardPaths.DocumentsLocation)
onAccepted: {
root.fileName = saveAsDialog.file
saveDocument();
}
onRejected: {
root.tryingToClose = false;
}
}
// ...
}
这指导我们如何关闭窗口。当窗口正被关闭时,onClosing
函数被调用。这里,代码选择可以不接受关闭请求。如果文档存在未保存的变动,就打开closeWaringDialog
并拒绝关闭请求。
closingWaringDialog
询问用户是否将变动保存,但用户也有取消关闭的选项。取消逻辑在onRejected
处理是最简的处理方式,不关闭文档窗口。
当用户不想保存变动,在onNoClicked
里,isDirty
标志被置为false
,且窗口被关闭。这次,onClosing
将允许关闭请求,因为isDirty
为false。
最后,当用户想要保存变动,我们在取消关闭前将tryingToClose
标志置为true。保存/另存为的逻辑如下:
ApplicationWindow {
// ...
onClosing: {
if (root.isDirty) {
closeWarningDialog.open();
close.accepted = false;
}
}
NativeDialogs.MessageDialog {
id: closeWarningDialog
title: "Closing document"
text: "You have unsaved changed. Do you want to save your changes?"
buttons: NativeDialogs.MessageDialog.Yes | NativeDialogs.MessageDialog.No | NativeDialogs.MessageDialog.Cancel
onYesClicked: {
// Attempt to save the document
root.tryingToClose = true;
root.saveDocument();
}
onNoClicked: {
// Close the window
root.isDirty = false;
root.close()
}
onRejected: {
// Do nothing, aborting the closing of the window
}
}
}
整个关闭流程及保存/另存为的逻辑如下图。系统在关闭状态时进入,而关闭与不关闭是结果。
相比于使用Qt Widgets
和 C++ 来实现,这种实现方式看起来更复杂。这是因为对话框对QML没有阻断作用。这意味着我们不能在一个switch
表达式中等待对话框结果。相应地,我们要记住状态然后继续在各自相应的函数onYesClicked
,onNoClicked
,onAccepted
及onRejected
中处理。
最后就是窗体标题。它是由两个属性组成的:fileName
和isDirty
ApplicationWindow {
// ...
title: (fileName.length===0?qsTr("Document"):fileName) + (isDirty?"*":"")
// ...
}
这个例子离实用还很远。比如,文档未加载或保存。另一块缺失的内容是处理一次性关闭所有窗口的逻辑,比如,当程序退出时。实现这个功能,需要一个保存所有当前DocumentWindow
实例列表的单例。但这属于另一种去触发窗口关闭的方式,所以这里展示的逻辑图仍然是有意义的。
想象风格
Qt Quick Controls 的一个设计目标就是将控件的界面与逻辑分离。对于大多数的样式来说,界面样式的实现是由QML代码和图形附件混合组成的。然而,使用Imagine风格,可以仅使用图形附件来定制基于Qt Quick Controls 的应用程序。
想象风格是基于9-patch 图象。这允许图像携带有关它们如何被拉伸以及哪些部分被视为元素的一部分以及外部哪些部分的信息;比如,影子。对于每个控件,样式里支持几个元素,每个元素中都有大量的状态可用。通过向这些元素和状态提供一定的素材,你可以控制控件的样式细节。
Imagine 样式文档中详细介绍了 9-path 图像的详细信息,以及如何设置每个控件的样式。这里,我们将为一个假想的设备界面自定义一个样式,来展示风格如何使用。
应用程序的风格决定了ApplicationWindow
和Button
控件。对这些按钮来说,其正常状态,以及按下
和选中
状态都已经被处理了。演示程序效果如下:
代码为可点击按钮创建了一个Column
,并为可选按钮创建了Grid
。可点击按钮为适应窗体宽度做了拉伸。
import QtQuick
import QtQuick.Controls
ApplicationWindow {
// ...
visible: true
width: 640
height: 480
title: qsTr("Hello World")
Column {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 10
width: parent.width/2
spacing: 10
// ...
Repeater {
model: 5
delegate: Button {
width: parent.width
height: 70
text: qsTr("Click me!")
}
}
}
Grid {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 10
columns: 2
spacing: 10
// ...
Repeater {
model: 10
delegate: Button {
height: 70
text: qsTr("Check me!")
checkable: true
}
}
}
}
当我们使用Imagine
风格时,所有被用到的控件都需要使用附件格式化。最简单的是ApplicationWindow
的背景,这是一个定义背景颜色的单像素纹理。通过命名文件applicationwindow-background.png
然后使用 qtquickcontrols2.conf
配置将样式指向它,该文件就被拾取了。
在下面展示的qtquickcontrols2.conf
文件,可以看到如何将Style
设置为Imagine
,然后为风格设置Path
以便能找到素材附件。最后还需要设置一些调色板属性。可用的调色析属性值可以在QML调色析基础样式找到。
[Controls]
Style=Imagine
[Imagine]
Path=:images/imagine
[Imagine\Palette]
Text=#ffffff
ButtonText=#ffffff
BrightText=#ffffff
Button
控件的素材附件是button-background.9.png, button-background-pressed.9.png
和button-background-checked.9.png
。遵循control-element-state
(控件-元素-状态)的规范模式。无状态的文件,象button-background.9.png
用于没有素材附件的所有状态。根据想象风格元素引用表,按钮可以有如下状态:
- disabled
- pressed
- checked
- checkable
- focused
- highlighted
- flat
- mirrored
- hovered
是否需要这些状态有赖于你的用户界面。比如,悬空(hovered)样式在触摸交互型的用户界面里,永远不会用到。
看下上面放大版的button-background-checked.9.png
,可以看到两侧的指导线。出于视觉效果,添加了紫色背景。而本例所用到的素材附件实际上是透明的。
图片边上的象素也可以是白的/透明的、黑的或红的,有着不同的意义,以下逐一说明: - 黑色 线在左边和上边标记图象的可拉伸部分。这意味着当按钮被拉伸时,示例中的圆角和白色标记不受影响。
- 黑色 线在右边和下边,标记了控件的内容区域。这意味着在示例中用于文本的按钮部分。
- 红色 线在右边和下边,标记了嵌入区域。这些区域是图像的一部分,但不被认为是控件的一部分。对于上面的可选图像,这是用在延伸到按钮外面的柔和光晕。
内嵌(inset)区域的使用演示如下button-background.9.png
,而上面的button-background-checked.9.png
:看起来象点亮,但没移动。
小结
本章介绍了Qt Quick Controls 2,涉及到比基础QML元素更高级的概念的一系列元素。多数场景下,会用到Qt Quick Controls 2以节省内存消耗,提高性能,因为它们是基于优化的 C++ 逻辑实现的,而非Javascript和QML。
我们已经演示了不同风格如何应用,以及一段可共用的代码如何通过文件选择器使用。这种方式,一段代码可以在不同的平台,以不同的交互方式和界面风格部署。
最后,我们一起学习了想象风格,它允许你使用图形素材来定制化一个基于QML的应用程序外观。这种方法,可以让一个应用在不改动任何代码的条件下更换皮肤。