There a number of common user interface patterns that can be implemented using Qt Quick Controls. In this section, we try to demonstrate how some of the more common ones can be built.
使用Qt Quick Controls可以实现很多常见的用户界面模式。在本节中,我们将尝试演示如何构建一些更常见的模式。
For this example we will create a tree of pages that can be reached from the previous level of screens. The structure is pictured below.
在本例中,我们将创建一个页面树,可以从上一级屏幕访问这些页面。结构如下图所示。
The key component in this type of user interface is the StackView
. It allows us to place pages on a stack which then can be popped when the user wants to go back. In the example here, we will show how this can be implemented.
这种用户界面的关键组件是StackView。它允许我们将页面放在一个栈上,然后当用户想要返回时可以弹出该栈。在这个的示例中,我们将展示如何实现这一点。
The initial home screen of the application is shown in the figure below.
应用程序的初始主屏幕如下图所示。
The application starts in main.qml
, where we have an ApplicationWindow
containing a ToolBar
, a Drawer
, a StackView
and a home page element, Home
. We will look into each of the components below.
应用程序从main.qml
中启动,其中我们有一个包含工具栏ToolBar
、抽屉Drawer
、StackView和主页元素Home
的应用程序窗口ApplicationWindow
。我们将研究下每个组件。
import QtQuick
import QtQuick.Controls
ApplicationWindow {
// ...
header: ToolBar {
// ...
}
Drawer {
// ...
}
StackView {
id: stackView
anchors.fill: parent
initialItem: Home {}
}
}
The home page, Home.qml
consists of a Page
, which is n control element that support headers and footers. In this example we simply center a Label
with the text Home Screen on the page. This works because the contents of a StackView
automatically fill the stack view, so the page will have the right size for this to work.
主页Home.qml
由一个Page元素类型构成
,该元素类型是一个支持页眉和页脚的控件元素类型。在本例中,我们只放一个文本居中的标签Label
。因为StackView
的内容会自动填充堆栈视图,因此页面的大小将自动适配。
import QtQuick
import QtQuick.Controls
Page {
title: qsTr("Home")
Label {
anchors.centerIn: parent
text: qsTr("Home Screen")
}
}
Returning back to main.qml
, we now look at the drawer part. This is where the navigation to the pages begin. The active parts of the user interface are the ÌtemDelegate
items. In the onClicked
handler, the next page is pushed onto the stackView
.
回到main.qml
,我们现在来看看抽屉部分。这是页面导航的开始。用户界面的活动部分是ÌtemDelegate项。在onClicked处理器中,下一页被推送到stackView上。
As shown in the code below, it possible to push either a Component
or a reference to a specific QML file. Either way results in a new instance being created and pushed onto the stack.
如下面的代码所示,可以将组件或指定的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 {}
}
// ...
}
The other half of the puzzle is the toolbar. The idea is that a back button is shown when the stackView
contains more than one page, otherwise a menu button is shown. The logic for this can be seen in the text
property where the "\\u..."
strings represents the unicode symbols that we need.
拼图的另一半是工具栏。其好是,当stackView包含多个页面时,会显示后退按钮,否则会显示菜单按钮。此操作的逻辑可以在文本属性text
中看到,其中“\\u…”字符串表示我们需要的unicode符号。
In the onClicked
handler, we can see that when there is more than one page on the stack, the stack is popped, i.e. the top page is removed. If the stack contains only one item, i.e. the home screen, the drawer is opened.
在onClicked处理器中,我们可以看到,当堆栈上有多个页面时,堆栈被弹出,即顶部页面被删除。如果堆栈仅包含主屏幕一项时,则抽屉打开。
Below the ToolBar
, there is a Label
. This element shows the title of each page in the center of the header.
工具栏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
}
}
// ...
}
Now we’ve seen how to reach the About and Profile pages, but we also want to make it possible to reach the Edit Profile page from the Profile page. This is done via the Button
on the Profile page. When the button is clicked, the EditProfile.qml
file is pushed onto the StackView
.
现在我们已经了解了如何访问About和Profile页面,但是我们还希望能够从Profile页面访问Edit Profile页面。这是通过Profile页面上的按钮完成的。单击按钮时,EditProfile.qml
文件将显示。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")
}
}
}
For this example we create a user interface consisting of three pages that the user can shift through. The pages are shown in the diagram below. This could be the interface of a health tracking app, tracking the current state, the user’s statistics and the overall statistics.
对于本例,我们创建了一个用户界面,该界面由三个页面组成,用户可以在其中切换。页面如下图所示。这可能是健康跟踪应用程序的界面,跟踪当前状态、用户统计数据和总体统计数据。
The illustration below shows how the Current page looks in the application. The main part of the screen is managed by a SwipeView
, which is what enables the side by side screen interaction pattern. The title and text shown in the figure come from the page inside the SwipeView
, while the PageIndicator
(the three dots at the bottom) comes from main.qml
and sits under the SwipeView
. The page indicator shows the user which page is currently active, which helps when navigating.
下图显示了Current页面在应用程序中的外观。屏幕的主要部分由SwipeView管理,它支持并排屏幕交互模式。图中显示的标题和文本来自SwipeView中的页面,而PageIndicator(底部的三个点)来自main.qml,位于SwipeView下侧。页面指示器向用户显示当前处于活动状态的页面,这用于辅助导航显示。
Diving into main.qml
, it consists of an ApplicationWindow
with the SwipeView
.
进入到main.qml
。它由一个带有SwipeView的ApplicationWindow构
成。
import QtQuick
import QtQuick.Controls
ApplicationWindow {
visible: true
width: 640
height: 480
title: qsTr("Side-by-side")
SwipeView {
// ...
}
// ...
}
Inside the SwipeView
each of the child pages are instantiated in the order they are to appear. They are Current
, UserStats
and TotalStats
.
在SwipeView中,每个子页面都按其顺序实例化显示。它们是Current、UserStats和TotalStats。
ApplicationWindow {
// ...
SwipeView {
id: swipeView
anchors.fill: parent
Current {
}
UserStats {
}
TotalStats {
}
}
// ...
}
Finally, the count
and currentIndex
properties of the SwipeView
are bound to the PageIndicator
element. This completes the structure around the pages.
最后,SwipeView的count和currentIndex属性被绑定到PageIndicator元素对象。这就完成了页面结构。
ApplicationWindow {
// ...
SwipeView {
id: swipeView
// ...
}
PageIndicator {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentIndex: swipeView.currentIndex
count: swipeView.count
}
}
Each page consists of a Page
with a header
consisting of a Label
and some contents. For the Current and User Stats pages the contents consist of a simple Label
, but for the Community Stats page, a back button is included.
每个页面由一个Page
组成,页面的标题由一个标签Label
和一些内容组成。对于Current和User Stats页面,内容由一个简单的标签Label
组成,但是对于Community Stats页面,包含一个back按钮。
import QtQuick
import QtQuick.Controls
Page {
header: Label {
text: qsTr("Community Stats")
font.pixelSize: Qt.application.font.pixelSize * 2
padding: 10
}
// ...
}
The back button explicitly calls the setCurrentIndex
of the SwipeView
to set the index to zero, returning the user directly to the Current page. During each transition between pages the SwipeView
provides a transition, so even when explicitly changing the index the user is given a sense of direction.
后退按钮显式调用SwipeView的setCurrentIndex,将索引设置为零,直接将用户返回到当前页面。在页面之间的每次转换过程中,SwipeView都会提供一个转换,因此即使在显式更改索引时,用户也会获得方向感。
TIP
注
When navigating in a SwipeView
programatically it is important not to set the currentIndex
by assignment in JavaScript. This is because doing so will break any QML bindings it overrides. Instead use the methods setCurrentIndex
, incrementCurrentIndex
, and decrementCurrentIndex
. This preserves the QML bindings.
以编程方式在SwipeView中导航时,注意不要在JavaScript中通过赋值设置currentIndex。这是因为这样做会破坏它的QML绑定。而应该使用setCurrentIndex、incrementCurrentIndex和decrementCurrentIndex方法。这将保留QML绑定。
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);
}
}
}
This example shows how to implement a desktop-oriented, document-centric user interface. The idea is to have one window per document. When opening a new document, a new window is opened. To the user, each window is a self contained world with a single document.
此示例演示如何实现面向桌面、以文档为中心的用户界面。想法是每个文档有一个窗口。打开新文档时,将打开一个新窗口。对于用户来说,每个窗口都是一个独立的世界,只有一个文档。
The code starts from an ApplicationWindow
with a File menu with the standard operations: New, Open, Save and Save As. We put this in the DocumentWindow.qml
.
代码从一个带有File菜单的ApplicationWindow
开始,其中包含标准操作:新建New、打开Open、保存Save和另存为Save As。我们把这些放在DocumentWindow.qml中实现
。
We import Qt.labs.platform
for native dialogs, and have made the subsequent changes to the project file and main.cpp
as described in the section on native dialogs above.
我们载入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()
}
}
}
// ...
}
To bootstrap the program, we create the first DocumentWindow
instance from main.qml
, which is the entry point of the application.
为了引导程序,我们首先在main.qml实例化
DocumentWindow,它是应用程序的入口点。
import QtQuick
DocumentWindow {
visible: true
}
In the example at the beginning of this chapter, each MenuItem
calls a corresponding function when triggered. Let’s start with the New item, which calls the newDocument
function.
在本章开头的示例中,每个菜单项MenuItem
在触发时调用相应的函数。让我们从New项开始,它调用newDocument函数。
The function, in turn, relies on the createNewDocument
function, which dynamically creates a new element instance from the DocumentWindow.qml
file, i.e. a new DocumentWindow
instance. The reason for breaking out this part of the new function is that we use it when opening documents as well.
该函数又依赖于createNewDocument函数,该函数从根据DocumentWindow.qml文件
动态创建新元素实例。即新的DocumentWindow实例。这一部分之所以作为新函数被分离出来,是因为我们在打开文档时也使用了它。
Notice that we do not provide a parent element when creating the new instance using createObject
. This way, we create new top level elements. If we would have provided the current document as parent to the next, the destruction of the parent window would lead to the destruction of the child windows.
请注意,在使用createObject创建新实例时,我们不提供父元素。这样,我们就可以创建新的顶层元素对象。如果我们将当前文档作为父文档提供给下一个文档,则父窗口的析构将导致子窗口的析构。
ApplicationWindow {
// ...
function createNewDocument()
{
var component = Qt.createComponent("DocumentWindow.qml");
var window = component.createObject();
return window;
}
function newDocument()
{
var window = createNewDocument();
window.show();
}
// ...
}
Looking at the Open item, we see that it calls the openDocument
function. The function simply opens the openDialog
, which lets the user pick a file to open. As we don’t have a document format, file extension or anything like that, the dialog has most properties set to their default value. In a real world application, this would be better configured.
查看Open项,我们看到它调用openDocument函数。该函数只打开openDialog,用户可以在其中选择要打开的文件。由于我们没有设置文档格式、文件扩展名或类似的内容,对话框的大部分属性都为默认值。在实际应用程序中,最好的配置一下。
In the onAccepted
handler a new document window is instantiated using the createNewDocument
method, and a file name is set before the window is shown. In this case, no real loading takes place.
在onAccepted信号处理器中,
使用createNewDocument
方法实例化一个文档窗口,在文档窗口显示前,设置好文件名。在当前情况下,不会发生实际文件加载。
TIP
注
We imported the Qt.labs.platform
module as NativeDialogs
. This is because it provides a MenuItem
that clashes with the MenuItem
provided by the QtQuick.Controls
module.
我们载入Qt.labs.platform
模块,并重命名为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();
}
}
// ...
}
The file name belongs to a pair of properties describing the document: fileName
and isDirty
. The fileName
holds the file name of the document name and isDirty
is set when the document has unsaved changes. This is used by the save and save as logic, which is shown below.
文件名属于描述文档的一对属性:fileName和isDirty。文件名fileName
保存文档的文件名,当文档有未保存的更改时,设置isDirty。下面使用使用此属性完成“保存”和“另存为”逻辑。
When trying to save a document without a name, the saveAsDocument
is invoked. This results in a round-trip over the saveAsDialog
, which sets a file name and then tries to save again in the onAccepted
handler.
尝试保存没有名称的文档时,会调用saveAsDocument。这将导致saveAsDialog的往返,打开对话框获取文件名,然后再次尝试在onAccepted处理器中保存。
Notice that the saveAsDocument
and saveDocument
functions correspond to the Save As and Save menu items.
请注意,saveAsDocument和saveDocument函数对应于“另存为Save As”和“保存Save”菜单项。
After having saved the document, in the saveDocument
function, the tryingToClose
property is checked. This flag is set if the save is the result of the user wanting to save a document when the window is being closed. As a consequence, the window is closed after the save operation has been performed. Again, no actual saving takes place in this example.
保存文档后,在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;
}
}
// ...
}
This leads us to the closing of windows. When a window is being closed, the onClosing
handler is invoked. Here, the code can choose not to accept the request to close. If the document has unsaved changes, we open the closeWarningDialog
and reject the request to close.
这就完成了窗户的关闭。关闭窗口时,将调用onClosing处理器。在这里,代码可以选择不接受关闭请求。如果文档有未保存的更改,我们将打开closeWarningDialog并拒绝关闭请求。
The closeWarningDialog
asks the user if the changes should be saved, but the user also has the option to cancel the close operation. The cancelling, handled in onRejected
, is the easiest case, as we rejected the closing when the dialog was opened.
closeWarningDialog询问用户是否应保存更改,但用户也可以选择取消关闭操作。在onRejected中处理的取消操作,是最简单的情况,因为我们在对话框打开时拒绝关闭。
When the user does not want to save the changes, i.e. in onNoClicked
, the isDirty
flag is set to false
and the window is closed again. This time around, the onClosing
will accept the closure, as isDirty
is false.
当用户不想保存更改时,即在onNoClicked中,isDirty标志设置为false,窗口再次关闭。这一次,onClosing将接受关闭,因为isDirty是false。
Finally, when the user wants to save the changes, we set the tryingToClose
flag to true before calling save. This leads us to the save/save as logic.
最后,当用户想要保存更改时,我们在调用save之前将tryingToClose标志设置为true。这就完成了save/save-as逻辑。
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 不执行任何操作,中止关闭窗口
}
}
}
The entire flow for the close and save/save as logic is shown below. The system is entered at the close state, while the closed and not closed states are outcomes.
关闭和保存/另存为逻辑的整个流程如下所示。系统以关闭状态进入,以关闭或未关闭状态结束。
This looks complicated compared to implementing this using Qt Widgets
and C++. This is because the dialogs are not blocking to QML. This means that we cannot wait for the outcome of a dialog in a switch
statement. Instead we need to remember the state and continue the operation in the respective onYesClicked
, onNoClicked
, onAccepted
, and onRejected
handlers.
与使用Qt Widgets
和C++实现这一点相比,这看起来有点复杂。这是因为QML对话框没有进行阻塞。这意味着我们不能等待switch语句中,对话框的执行结果。相反,我们需要记住状态,并在对应的onYesClicked、onNoClicked、onAccepted和onRejected处理器中处理操作结果。
The final piece of the puzzle is the window title. It is composed from the fileName
and isDirty
properties.
最后一部分是窗口标题。它由fileName和isDirty属性组成。
ApplicationWindow {
// ...
title: (fileName.length===0?qsTr("Document"):fileName) + (isDirty?"*":"")
// ...
}
This example is far from complete. For instance, the document is never loaded or saved. Another missing piece is handling the case of closing all the windows in one go, i.e. exiting the application. For this function, a singleton maintaining a list of all current DocumentWindow
instances is needed. However, this would only be another way to trigger the closing of a window, so the logic flow shown here is still valid.
这个例子还远远不够完整。例如,从未加载或保存文档。另一个缺失部分是处理一次性关闭所有窗口的情况,即退出应用程序。对于此函数,需要一个维护所有当前DocumentWindow实例列表的单例。这只是触发关闭窗口的另一种方式,因此此处显示的逻辑流仍然有效。
示例源码下载