第七章: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窗体。

  1. import QtQuick 
  2. import QtQuick.Controls 
  3. import Qt.labs.platform 
  4.  
  5. ApplicationWindow { 
  6. visible: true 
  7. width: 640 
  8. height: 480 
  9.  
  10. // ... 
  11.  
  12. } 

ApplicationWindow由以下4个主要区域组成。由MenuBarToolBarTabBar控件生成的菜单栏、工具栏、状态栏,而内容区域则由子窗体承担。注意图片查看器一般没有状态栏,所以下面的代码里也没有状态栏,如上图所示。

编写桌面程序,我们使用Fusion 样式。通过配置文件、环境变量、命令行参数或程序中的C++代码,都可以配置样式。这里使用代码的方法,如下所示:

QQuickStyle::setStyle("Fusion");

接下来就在main.qml中添加Image元素来展示内容,以及其它用户界面。当用户点击这个元素时,它被用来承载图片,而目前它还只是占位符。background属性用来替换窗体内容后面的背景的一个元素,当没有图片加载,或者图片边框宽高比例不足以填充满窗体内容区域时,背景元素将被显示。

  1. ApplicationWindow { 
  2.  
  3. // ... 
  4.  
  5. background: Rectangle { 
  6. color: "darkGray" 
  7. } 
  8.  
  9. Image { 
  10. id: image 
  11. anchors.fill: parent 
  12. fillMode: Image.PreserveAspectFit 
  13. asynchronous: true 
  14. } 
  15.  
  16. // ... 
  17.  
  18. } 

接下来增加工具栏ToolBar。这要用到Window的toolBar属性。在工具栏里要增加一个Flow元素,以确保工具按钮的适当宽度,当工具按钮足够多时会在适当的位置换到下一行。在这个flow元素中,放一个ToolButton工具按钮。
ToolButton有一些有意思的属性。Text属性是字符串型的,而icon.name的值取自freedesktop.org Icon Naming Specification (opens new window)。在本文档中,按名字列出了标准图标的列表。通过引用图标名,Qt将为当前桌面样式选择恰当的图标。
ToolButtononClicked信号处理函数中编写一段代码,它调用了fileOpenDialog元素的open函数。

  1. ApplicationWindow { 
  2.  
  3. // ... 
  4.  
  5. header: ToolBar { 
  6. Flow { 
  7. anchors.fill: parent 
  8. ToolButton { 
  9. text: qsTr("Open") 
  10. icon.name: "document-open" 
  11. onClicked: fileOpenDialog.open() 
  12. } 
  13. } 
  14. } 
  15.  
  16. // ... 
  17.  
  18. } 

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元素。
以下代码创建了两个菜单:FileHelp。在File下放一个Open菜单项,将图标和动作设置得与工具栏上的 打开 按钮一致。在Help下会看到一个About菜单,它会调用 aboutDialogOpen 方法。
请注意,Menutitle 属性和 MenuItemtext 属性中的逻辑与符号 (“&”) 将其后字符转换为键盘快捷键;例如按 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下作为子元素。在抽屉元素中,添加包含ItemDelegateListView。也包含一个ScrollIndicator滚动条,以便当内容过长时方便拖动显示。我们的列表中只有两个项目,所在本例中见不到这个滚动条。
抽屉菜单的ListView是由ListModel填充的,每个ListItem对应一个菜单项。每当一个项目被点击,会调用onClicked方法,进而会调用相应的ListItemtriggered方法。这样,我们就可以用一个委托来触发不同的动作,具体代码如下:

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包含两个子项:ToolButtonLabel
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元素和背景。为了在特定于平台都可以正常打开对话框,我们需要使用函数 openFileDialogopenAboutDialog

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.qmlpage组成,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
        }
    }

    // ...

}

现在,一起来看看如何操作 AboutProfile页面,同时也要能够从 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,它是由内部嵌套SwipeViewApplicationWindow构成。

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    visible: true
    width: 640
    height: 480

    title: qsTr("Side-by-side")

    SwipeView {

        // ...

    }

    // ...

}

SwipeView里,子页面将按照声明的顺序进行初始化显示。子页面分别是CurrentUserStatsTotalStats

ApplicationWindow {

    // ...

    SwipeView {
        id: swipeView
        anchors.fill: parent

        Current {
        }

        UserStats {
        }

        TotalStats {
        }
    }

    // ...

}

最后,把SwipeViewcountcurrentIndex属性绑到PageIndactor元素。这样就完成了组织这些页面的架构。

ApplicationWindow {

    // ...

    SwipeView {
        id: swipeView

        // ...

    }

    PageIndicator {
        anchors.bottom: parent.bottom
        anchors.horizontalCenter: parent.horizontalCenter

        currentIndex: swipeView.currentIndex
        count: swipeView.count
    }
}

每个界面都有一个page页面,页面上有header标题,标题上有Label标签,页面还有其它内容。对于CurrentUser 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
    }

    // ...

}



退回按钮单独调用了SwipeViewsetCurrentIndex方法,来将当前页设置为第0页,将用户直接导航到Current页。每次页面切换时,SwipeView都提供了过渡效果,所以,单独切换页面索引时,会有切换的方向感。

提示
当编程实现SwipeView的页面切换时,一定不要使用JavaScript脚本赋值来指定currentIndex。因为这样会破坏QML的绑定关系。正确的做法是使用setCurrentIndexincrementCurrentIndexdecrementCurrentIndex方法。这样就保留了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.pngenter description here
代码从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,因为其中的MenuItemQtQuick.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();
        }
    }

    // ...

}

文件名包含一对描述文档的属性:fileNameisDirtyfileName属性表示文档的名称,而isDirty表示文档是否有未保存的变更。这个逻辑用于保存另存为逻辑,下面会提到。
当未指定文件名做保存操作时,saveAsDocument被激活。这导至saveAsDialog窗体调用,这会指定一个文件名并尝试在onAccepted函数中再次保存。
注意 ,saveAsDocumentsaveDocument对应着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表达式中等待对话框结果。相应地,我们要记住状态然后继续在各自相应的函数onYesClickedonNoClickedonAcceptedonRejected中处理。

最后就是窗体标题。它是由两个属性组成的:fileNameisDirty

ApplicationWindow {

    // ...

    title: (fileName.length===0?qsTr("Document"):fileName) + (isDirty?"*":"")

    // ...

}

这个例子离实用还很远。比如,文档未加载或保存。另一块缺失的内容是处理一次性关闭所有窗口的逻辑,比如,当程序退出时。实现这个功能,需要一个保存所有当前DocumentWindow实例列表的单例。但这属于另一种去触发窗口关闭的方式,所以这里展示的逻辑图仍然是有意义的。

想象风格

Qt Quick Controls 的一个设计目标就是将控件的界面与逻辑分离。对于大多数的样式来说,界面样式的实现是由QML代码和图形附件混合组成的。然而,使用Imagine风格,可以仅使用图形附件来定制基于Qt Quick Controls 的应用程序。
想象风格是基于9-patch 图象。这允许图像携带有关它们如何被拉伸以及哪些部分被视为元素的一部分以及外部哪些部分的信息;比如,影子。对于每个控件,样式里支持几个元素,每个元素中都有大量的状态可用。通过向这些元素和状态提供一定的素材,你可以控制控件的样式细节。
Imagine 样式文档中详细介绍了 9-path 图像的详细信息,以及如何设置每个控件的样式。这里,我们将为一个假想的设备界面自定义一个样式,来展示风格如何使用。
应用程序的风格决定了ApplicationWindowButton控件。对这些按钮来说,其正常状态,以及按下选中状态都已经被处理了。演示程序效果如下:

代码为可点击按钮创建了一个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.pngbutton-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的应用程序外观。这种方法,可以让一个应用在不改动任何代码的条件下更换皮肤。

posted @ 2022-03-05 16:26  sammy621  阅读(1406)  评论(0编辑  收藏  举报