第七章:QtQuick控制元件
第七章:QtQuick控制元件
Qt,控制元件UI 控制元件
本章介紹如何使用 Qt Quick Controls 模組。 Qt Quick Controls 用於建立由標準組件(如按鈕、標籤、滑塊等)構建的高階使用者介面。
Qt Quick Controls 可以使用 佈局模組 進行排列,並且易於設定樣式。在深入定製樣式之前,我們還將研究不同平臺的各種樣式。
控制元件簡介
Qt Quick 可以提供原始的圖形和互動元素讓你從頭開始構建使用者介面。使用 Qt Quick Controls,可以從一組稍微結構化的控制元件開始構建。
控制元件種類豐富,從簡單的文字標籤、按鈕到複雜的滑塊和軟鍵盤。如果你想建立基於經典使用者互動模式下的使用者介面,這些控制元件非常好用,它們提供了很豐富的基礎功能。
Qt Quick控制元件自帶開箱即用的豐富的樣式,展示如下。Basic
有些樣式可以通過修改調色盤來調整。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的應用程式外觀。這種方法,可以讓一個應用在不改動任何程式碼的條件下更換面板。