第七章: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的應用程序外觀。這種方法,可以讓一個應用在不改動任何代碼的條件下更換皮膚。