Qt快速控件 - 聊天教程

本教程演示了如何使用Qt快速控件编写一个基本的聊天应用程序。它还将解释如何将SQL数据库集成到Qt应用程序中。

第1章:设置

设置新项目时,使用Qt Creator最简单。对于本项目,我们选择了Qt Quick应用程序模板,它创建了一个基本的“你好世界”应用程序,包含以下文件:

  • MainForm.ui.qml - 定义默认UI
  • main.qml - 将默认UI嵌入到一个窗口中
  • qml.qrc - 列出构建到二进制中的.qml文件
  • main.cpp - 加载main.qml
  • chattutorial.pro - 提供 qmake 配置

注意:请从项目中删除MainForm.ui.qmlqml.qrc文件,因为在本教程中我们将不使用它们。

main.cpp

main.cpp中的默认代码有两个包含:

#include <QGuiApplication>
#include <QQmlApplicationEngine>

第一个为我们提供了访问QGuiApplication的方法。所有Qt应用程序都需要一个应用程序对象,但其精确类型取决于应用程序做什么。《a href="qcoreapplication.html" translate="no">QCoreApplication足以用于非图形应用程序。对于不使用Qt小部件的图形应用程序,可以使用QGuiApplication,而对于需要使用指针的小部件的应用程序,则需要QApplication

第二个包含使QQmlApplicationEngine可用,以及一些使C++类型可用于QML的必需函数。

main()中,我们设置应用程序对象和QML引擎

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;
    engine.load(QUrl(QStringLiteral("qrc:/qt/qml/chapter1/main.qml")));

    return app.exec();
}

它首先启用高DPI缩放,这并非默认代码的一部分。在构造应用程序对象之前必须这样做。

完成之后,我们构造应用程序对象,传入用户提供的任何应用程序参数。

接下来,创建QML引擎。QQmlApplicationEngineQQmlEngine的便捷包装器,提供了load()函数,以轻松加载应用程序的QML。它还添加了一些使用文件选择器的便利功能。

在我们在C++中设置好了事情之后,我们可以转到QML的用户界面。

main.qml

让我们修改默认的QML代码来满足我们的需求。

import QtQuick
import QtQuick.Controls

首先,导入 Qt Quick 模块。这使我们能够访问图形原语,如 ItemRectangleText 等。有关完整类型列表,请参阅 Qt Quick QML 类型 文档。

接着,导入 Qt Quick Controls 模块。除了其他功能外,这还提供了访问 ApplicationWindow 的权限,它将替换现有的根类型 Window

ApplicationWindow {
    width: 540
    height: 960
    visible: true
    ...
    }

ApplicationWindow 是一个 Window,增加了一些创建 标题栏页脚 的便利性。它还为 弹出框 提供了基础,并支持一些基本样式,如背景 颜色

使用 ApplicationWindow 时,几乎总是需要设置三个属性:宽度高度可见性。设置这些属性后,我们将拥有一个正确尺寸且空白的窗口,可以填充内容。

注意:默认代码中的 title 属性已被移除。

我们应用程序中的第一个 "screen" 将是联系人列表。在每个屏幕顶部添加一些描述其功能的文本会很好。在这种情况下,ApplicationWindow 的标题和页脚属性可以工作。它们有一些特性,使它们非常适合显示在应用程序每个屏幕上的项

  • 它们分别锚定在窗口的顶部和底部。
  • 它们填充窗口的宽度。

然而,当标题和页脚的内容根据用户观看的屏幕而变化时,使用 Page 会容易得多。现在,我们只添加一个页面,但在下一章中,我们将展示如何在几个页面之间导航。

    Page {
        anchors.fill: parent
        header: Label {
            padding: 10
            text: qsTr("Contacts")
            font.pixelSize: 20
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }
    }

我们用 Page 替换默认的 MainForm {...} 代码块,Page 使用 anchors.fill 属性来适应窗口的所有空间。

然后,我们将一个 Label 分配给它 标题 属性。Label 通过添加 样式字体 继承扩展了来自 Qt Quick 模块的原始 Text 项。这意味着 Label 的外观会根据所使用的样式而不同,并且还可以将其像素大小传递给其子项。

我们需要在应用程序窗口顶部和文本之间有一定距离,所以我们设置了 填充 属性。这为标签的每一边(在其边界内)分配了额外空间。我们也可以明确地设置 topPaddingbottomPadding 属性。

我们使用 qsTr() 函数设置标签的文本,该函数确保文本可以通过 Qt 的翻译系统 进行翻译。这是一个遵守的好习惯,因为应用程序的最终用户可以看到的文本。

默认情况下,文本垂直对齐到其边界顶部,而水平对齐取决于文本的自然方向;例如,从左到右阅读的文本将左对齐。如果我们使用这些默认值,文本将位于窗口的左上角。这对于标题来说不是期望的,因此我们将文本对齐到其边界的中心,水平和垂直方向都如此。

项目文件

`.pro` 或 project 文件包含 qmake 生成 Makefile 所需的所有信息,而 Makefile 用于编译和链接应用程序。

TEMPLATE = app

第一行告诉 qmake 这是一个什么类型的项目。我们正在构建一个应用程序,所以我们使用 app 模板。

QT += qml quick

下一行声明了我们要从 C++ 中使用的 Qt 库。

CONFIG += c++11

这一行声明构建该项目需要 C++11 兼容的编译器。

SOURCES += main.cpp

SOURCES 变量列出了应该编译的所有源文件。还有一个类似的变量 HEADERS,用于头文件。

resources.files = main.qml
resources.prefix = qt/qml/chapter1/
RESOURCES += resources \
    qtquickcontrols2.conf

下一行告诉 qmake 我们有一个集合的 资源 应该编译到可执行文件中。

这一行替换了项目文件随附的部署设置。它确定在运行 "make install" 时示例的复制位置。

现在我们可以构建和运行应用程序

第2章:列表

在本章中,我们将解释如何使用 ListViewItemDelegate 创建交互项列表。

ListView 来自 Qt Quick 模块,并从 model 中填充项来显示列表。ItemDelegate 来自 Qt Quick Controls 模块,提供用于视图和控件(如 ListViewComboBox)的标准视图项。例如,每个 ItemDelegate 可以显示文本,可以打开和关闭,并且可以响应鼠标点击。

这是我们ListView

        ...

        ListView {
            id: listView
            anchors.fill: parent
            topMargin: 48
            leftMargin: 48
            bottomMargin: 48
            rightMargin: 48
            spacing: 20
            model: ["Albert Einstein", "Ernest Hemingway", "Hans Gude"]
            delegate: ItemDelegate {
                text: modelData
                width: listView.width - listView.leftMargin - listView.rightMargin
                height: avatar.implicitHeight
                leftPadding: avatar.implicitWidth + 32

                Image {
                    id: avatar
                    source: "images/" + modelData.replace(" ", "_") + ".png"
                }
            }
        }
        ...

尺寸和定位

我们首先为视图设置一个尺寸。它应该填充页面上可用的空间,因此我们使用 anchors.fill。请注意,Page 确保为其页眉和页脚预留足够的空间,因此在此情况下视图将位于页眉下方,例如。

接下来,我们设置 margins,将 ListView 与窗口边缘之间放置一定距离。边距属性在视图内预留空间,这意味着用户仍然可以 “滑动” 空白区域。

项应在视图中间距适中,因此将 spacing 属性设置为 20

模型

为了快速使用一些项目填充视图,我们使用了JavaScript数组作为模型。QML最大的优势之一是能够让应用原型设计变得极其快速,这是其中的一个例子。还可以简单地将一个数字分配给模型属性以指示所需的项数。例如,如果您将10分配给model属性,则每个项目的显示文本都将是从09的数字。

然而,一旦应用程序过了原型阶段,很快就需要使用一些真实的数据。为此,最好使用QAbstractItemModel的子类创建合适的C++模型。

代理

接下来讨论代理。我们将模型中的对应文本分配给text属性ItemDelegate。模型中的数据如何应用到每个代理上取决于所使用的模型类型。更多信息请参阅Qt Quick中的模型和视图

在我们的应用程序中,视图中的每个项目的宽度应与视图的宽度相同。这确保用户在列表中选择联系人时有很多空间,这对于小触摸屏设备(如手机)来说是个重要因素。然而,视图的宽度包括48像素的边距,所以我们需要在分配宽度属性时考虑到这一点。

接下来,我们定义一个Image。这将显示用户的联系图片。图片的宽度为40像素,高度为40像素。我们将委托的高度基于图片的高度,这样就不会有空白的空间。

第3章:导航

在本章中,您将学习如何使用StackView在应用程序的不同页面之间进行导航。以下是修正后的main.qml

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    id: window
    width: 540
    height: 960
    visible: true

    StackView {
        id: stackView
        anchors.fill: parent
        initialItem: ContactPage {}
    }
}

正如其名所示,StackView提供了基于堆栈的导航。最后被"推入"堆栈的项目是第一个被移除的,而最顶部的项目总是可见的。

与我们之前对Page的处理方式一样,我们告诉StackView填充应用程序窗口。在那之后,剩下的只是通过initialItem提供一个项目进行显示。StackView接受itemscomponentsURLs

您会注意到,我们已经将联系人列表的代码移动到了ContactPage.qml。如您所想,越早将应用程序将包含的屏幕有大致了解,就越早做这件事。这样做不仅能让你更容易阅读代码,而且确保只有在完全必要时才从给定组件实例化项目,从而减少内存使用。

注意:Qt Creator提供了一些方便的代码重构选项,其中之一让您可以将一段代码块移入到一个单独的文件中(Alt + Enter > 将组件移动到单独的文件)。

使用ListView时,需要考虑的一个问题是,是使用id引用它,还是使用附加的ListView.view属性。最佳方法取决于几个不同的因素。为视图分配id将导致绑定表达式更短且更高效,因为附加属性有非常小的开销。然而,如果您计划在其他视图中重用代理,那么使用附加属性以避免将代理绑定到特定视图会更好。例如,使用附加属性,我们委托中的width赋值变为

width: ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin

在第2章中,我们在标题下方添加了一个ListView。如果您运行该章节的应用程序,您会发现视图的内容可以滚动覆盖标题

这并不那么好,尤其是当委托中的文本足够长以至于达到标题文本时。我们理想的做法是在标题文本下方但视图上方的颜色块。这确保了列表视图内容在视觉上不会干扰标题内容。请注意,也可以通过设置视图的clip属性为true来实现这一点,但这样做可能会影响性能

ToolBar是完成这项任务的正确工具。它是一个包含应用程序范围和上下文敏感操作和控件(如导航按钮和搜索字段)的容器。最好的是,它有一个背景颜色,就像通常一样,来自应用程序样式。以下是它的作用

    header: ToolBar {
        Label {
            text: qsTr("Contacts")
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

它没有自己的布局,所以我们自己在其内部居中标签。

其余的代码与第2章相同,只是我们利用了clicked信号将下一页推送到stackview

            onClicked: root.StackView.view.push("ConversationPage.qml", { inConversationWith: modelData })

当将Componenturl推送到StackView时,通常会初始化最终实例化的项来设置某些变量。StackView

push

函数考虑到这一点,它将JavaScript对象作为第二个参数。我们使用此方法向下一页提供一个联系人的姓名,然后使用该姓名显示相关的对话。注意root.StackView.view.push语法;这是由于附加属性的工作方式所致。

让我们逐步审查ConversationPage.qml,从导入开始。

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls

它们与之前相同,只是增加了QtQuick.Layouts导入,我们将在稍后进行讨论。

Page {
    id: root

    property string inConversationWith

    header: ToolBar {
        ToolButton {
            text: qsTr("Back")
            anchors.left: parent.left
            anchors.leftMargin: 10
            anchors.verticalCenter: parent.verticalCenter
            onClicked: root.StackView.view.pop()
        }

        Label {
            id: pageTitle
            text: inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }
    ...

本组件的根元素是另一个Page,它有一个名为inConversationWith的自定义属性。目前,这个属性将简单决定标题中的标签显示什么。稍后我们将在填充对话中的消息列表的SQL查询中使用它。

为了允许用户返回到联系人页面,我们添加了一个ToolButton,当点击时调用

pop

()。一个ToolButton在功能上与Button相似,但提供更适合在ToolBar中的外观。

在 QML 中排列项目的两种方法:项目定位器Qt Quick 布局。项目定位器(如RowColumn等)适用于项目大小已知或固定的情况,只需将它们整齐地排列在一定形式中。Qt Quick 布局中的布局可以定位和调整项目大小,非常适合可调整大小的用户界面。下面,我们使用ColumnLayout垂直排列ListViewPane

    ColumnLayout {
        anchors.fill: parent

        ListView {
            id: listView
            Layout.fillWidth: true
            Layout.fillHeight: true
            ...

        }
        ...

        Pane {
            id: pane
            Layout.fillWidth: true
            ...
    }

Pane 实质上是一个颜色来自应用程序样式的矩形。它与Frame类似,唯一的区别是它没有围绕其边界的轮廓。

位于布局中直接子项的各项均有各种可用的附加属性。我们在ListView上使用Layout.fillWidthLayout.fillHeight确保在ColumnLayout内尽可能占据更多的空间。对于Pane也同样处理。因为ColumnLayout是垂直布局,每个子项的左右没有其他项目,因此每个子项将消耗整个布局的宽度。

另一方面,在ListView中的Layout.fillHeight语句将使其能够占据在安排了Pane后剩余的空间。

让我们详细看看 listview

        ListView {
            id: listView
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.margins: pane.leftPadding + messageField.leftPadding
            displayMarginBeginning: 40
            displayMarginEnd: 40
            verticalLayoutDirection: ListView.BottomToTop
            spacing: 12
            model: 10
            delegate: Row {
                readonly property bool sentByMe: index % 2 == 0

                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                Rectangle {
                    id: avatar
                    width: height
                    height: parent.height
                    color: "grey"
                    visible: !sentByMe
                }

                Rectangle {
                    width: 80
                    height: 40
                    color: sentByMe ? "lightgrey" : "steelblue"

                    Label {
                        anchors.centerIn: parent
                        text: index
                        color: sentByMe ? "black" : "white"
                    }
                }
            }

            ScrollBar.vertical: ScrollBar {}
        }

在填充其父元素的宽度和高度后,我们在视图上设置了一些边距。这使我们与“ composition message”字段中的占位符文本对齐良好。

接下来,我们设置displayMarginBeginningdisplayMarginEnd。这些属性确保在滚动到视图边缘时,视图边界外的委托不会消失。通过取消注释属性并观察滚动视图时发生了什么,可以最轻松地理解这一点。

然后我们翻转视图的垂直方向,以便第一项位于底部。委托之间的间距为12像素,并在测试目的下使用了"dummy"模型,直到我们在第四章中实现真实的模型。

在委托内部,我们声明一个Row作为根项,因为我们希望头像后面跟着消息内容,就像上面的图片所示。

用户发送的消息应与联系人发送的消息区分开来。目前,我们设置了一个dummy属性sentByMe,它简单地使用委托的索引在不同作者之间交替。使用此属性,我们以三种方式区分不同的作者

  • 通过将anchors.right设置为listView.contentItem.right,用户发送的消息将对齐到屏幕的右侧。
  • 通过根据sentByMe设置头像的可见性属性(目前简单地是一个矩形),我们只在消息由联系人发送时显示它。
  • 我们根据作者不同而改变矩形的颜色。由于我们不希望显示暗色背景上的暗色文字,反之亦然,因此我们也根据作者是谁来设置文本颜色。在第5章中,我们将看到样式是如何处理此类问题的。

在屏幕底部,我们放置了一个TextArea项目以允许多行文本输入,以及一个发送信息的按钮。我们使用Pane来覆盖这两个项目以下的区域,就像我们使用ToolBar来防止列表视图的内容干扰页面标题。

        Pane {
            id: pane
            Layout.fillWidth: true
            Layout.fillHeight: false

            RowLayout {
                width: parent.width

                TextArea {
                    id: messageField
                    Layout.fillWidth: true
                    placeholderText: qsTr("Compose message")
                    wrapMode: TextArea.Wrap
                }

                Button {
                    id: sendButton
                    text: qsTr("Send")
                    enabled: messageField.length > 0
                    Layout.fillWidth: false
                }
            }
        }

TextArea应为屏幕可用宽度填充。我们分配了一些提示文本,以便向用户提供视觉提示,告诉他们应该在哪里开始输入。输入区域内的文本将进行换行,以防止其超出屏幕。

最后,按钮只有在实际上有信息要发送时才启用。

第4章:模式

在第4章中,我们将带你完成在C++中创建只读和可读写SQL模式的过程,并将它们暴露给QML来填充视图。

QSqlQueryModel

为了使教程简单,我们选择使用户联系名单不可编辑。QSqlQueryModel是此目的的合理选择,因为它为SQL结果集提供了只读数据模型。

让我们来查看我们的SqlContactModel类,它是由QSqlQueryModel派生的。

#include <QSqlQueryModel>

class SqlContactModel : public QSqlQueryModel
{
public:
    SqlContactModel(QObject *parent = nullptr);
};

这里没有什么事情发生,所以让我们转到.cpp文件。

#include "sqlcontactmodel.h"

#include <QDebug>
#include <QSqlError>
#include <QSqlQuery>

static void createTable()
{
    if (QSqlDatabase::database().tables().contains(QStringLiteral("Contacts"))) {
        // The table already exists; we don't need to do anything.
        return;
    }

    QSqlQuery query;
    if (!query.exec(
        "CREATE TABLE IF NOT EXISTS 'Contacts' ("
        "   'name' TEXT NOT NULL,"
        "   PRIMARY KEY(name)"
        ")")) {
        qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
    }

    query.exec("INSERT INTO Contacts VALUES('Albert Einstein')");
    query.exec("INSERT INTO Contacts VALUES('Ernest Hemingway')");
    query.exec("INSERT INTO Contacts VALUES('Hans Gude')");
}

我们包含了我们自己的类头文件以及来自Qt的我们所需的那些。然后我们定义了一个名为createTable()的静态函数,我们将使用它来创建SQL表(如果它已经存在),并在其中填充一些虚拟联系人。

database()的调用可能会有些困惑,因为我们还没有设置特定的数据库。如果没有传递连接名称给此函数,它将返回一个"默认连接",我们将在不久之后介绍其创建。

SqlContactModel::SqlContactModel(QObject *parent) :
    QSqlQueryModel(parent)
{
    createTable();

    QSqlQuery query;
    if (!query.exec("SELECT * FROM Contacts"))
        qFatal("Contacts SELECT query failed: %s", qPrintable(query.lastError().text()));

    setQuery(std::move(query));
    if (lastError().isValid())
        qFatal("Cannot set query on SqlContactModel: %s", qPrintable(lastError().text()));
}

在构造函数中,我们调用createTable()。然后我们构建一个用于填充模型的查询。在这种情况下,我们只是对Contacts表的所有行感兴趣。

QSqlTableModel

SqlConversationModel更为复杂。

#include <QSqlTableModel>

class SqlConversationModel : public QSqlTableModel
{
    Q_OBJECT
    Q_PROPERTY(QString recipient READ recipient WRITE setRecipient NOTIFY recipientChanged)

public:
    SqlConversationModel(QObject *parent = nullptr);

    QString recipient() const;
    void setRecipient(const QString &recipient);

    QVariant data(const QModelIndex &index, int role) const override;
    QHash<int, QByteArray> roleNames() const override;

    Q_INVOKABLE void sendMessage(const QString &recipient, const QString &message);

signals:
    void recipientChanged();

private:
    QString m_recipient;
};

我们既使用了Q_PROPERTY宏,又使用了Q_INVOKABLE宏,因此我们必须通过使用Q_OBJECT宏来让moc知道。

recipient属性将从QML设置,以让模型知道应检索哪条对话信息。

我们覆盖了data()和roleNames()函数,以便我们可以在QML中使用我们自定义的角色。

我们还定义了sendMessage()函数,我们希望在QML中调用它,因此使用了Q_INVOKABLE宏。

让我们来看看.cpp文件。

#include "sqlconversationmodel.h"

#include <QDateTime>
#include <QDebug>
#include <QSqlError>
#include <QSqlRecord>
#include <QSqlQuery>

static const char *conversationsTableName = "Conversations";

static void createTable()
{
    if (QSqlDatabase::database().tables().contains(conversationsTableName)) {
        // The table already exists; we don't need to do anything.
        return;
    }

    QSqlQuery query;
    if (!query.exec(
        "CREATE TABLE IF NOT EXISTS 'Conversations' ("
        "'author' TEXT NOT NULL,"
        "'recipient' TEXT NOT NULL,"
        "'timestamp' TEXT NOT NULL,"
        "'message' TEXT NOT NULL,"
        "FOREIGN KEY('author') REFERENCES Contacts ( name ),"
        "FOREIGN KEY('recipient') REFERENCES Contacts ( name )"
        ")")) {
        qFatal("Failed to query database: %s", qPrintable(query.lastError().text()));
    }

    query.exec("INSERT INTO Conversations VALUES('Me', 'Ernest Hemingway', '2016-01-07T14:36:06', 'Hello!')");
    query.exec("INSERT INTO Conversations VALUES('Ernest Hemingway', 'Me', '2016-01-07T14:36:16', 'Good afternoon.')");
    query.exec("INSERT INTO Conversations VALUES('Me', 'Albert Einstein', '2016-01-01T11:24:53', 'Hi!')");
    query.exec("INSERT INTO Conversations VALUES('Albert Einstein', 'Me', '2016-01-07T14:36:16', 'Good morning.')");
    query.exec("INSERT INTO Conversations VALUES('Hans Gude', 'Me', '2015-11-20T06:30:02', 'God morgen. Har du fått mitt maleri?')");
    query.exec("INSERT INTO Conversations VALUES('Me', 'Hans Gude', '2015-11-20T08:21:03', 'God morgen, Hans. Ja, det er veldig fint. Tusen takk! "
               "Hvor mange timer har du brukt på den?')");
}

这与sqlcontactmodel.cpp非常相似,不同的地方在于我们现在正在操作Conversations表。我们还将conversationsTableName定义为静态常量,因为我们在这段文件中的几处使用它。

SqlConversationModel::SqlConversationModel(QObject *parent) :
    QSqlTableModel(parent)
{
    createTable();
    setTable(conversationsTableName);
    setSort(2, Qt::DescendingOrder);
    // Ensures that the model is sorted correctly after submitting a new row.
    setEditStrategy(QSqlTableModel::OnManualSubmit);
}

SqlContactModel一样,我们在构造函数中首先创建表格。我们通过QSqlTableModelsetTable()函数指定将要使用的表格名称。为确保对话中显示最新的消息,我们按timestamp字段降序排序查询结果。这与将ListViewverticalLayoutDirection属性设置为ListView.BottomToTop(在第3章中介绍过)是一致的。

QString SqlConversationModel::recipient() const
{
    return m_recipient;
}

void SqlConversationModel::setRecipient(const QString &recipient)
{
    if (recipient == m_recipient)
        return;

    m_recipient = recipient;

    const QString filterString = QString::fromLatin1(
        "(recipient = '%1' AND author = 'Me') OR (recipient = 'Me' AND author='%1')").arg(m_recipient);
    setFilter(filterString);
    select();

    emit recipientChanged();
}

setRecipient()函数中,我们对数据库返回的结果设置过滤器。

QVariant SqlConversationModel::data(const QModelIndex &index, int role) const
{
    if (role < Qt::UserRole)
        return QSqlTableModel::data(index, role);

    const QSqlRecord sqlRecord = record(index.row());
    return sqlRecord.value(role - Qt::UserRole);
}

如果角色不是自定义用户角色,data()函数将回退到QSqlTableModel的实现。如果角色是用户角色,我们可以从其中减去Qt::UserRole以得到该字段的索引,然后使用该索引来查找需要返回的值。

QHash<int, QByteArray> SqlConversationModel::roleNames() const
{
    QHash<int, QByteArray> names;
    names[Qt::UserRole] = "author";
    names[Qt::UserRole + 1] = "recipient";
    names[Qt::UserRole + 2] = "timestamp";
    names[Qt::UserRole + 3] = "message";
    return names;
}

roleNames()中,我们返回一个将我们的自定义角色值映射到角色名称的映射。这使得我们可以在QML中使用这些角色。声明一个枚举来持有所有的角色值可能是有用的,但由于我们在这个函数之外没有引用任何特定的值,所以我们不必这么处理。

void SqlConversationModel::sendMessage(const QString &recipient, const QString &message)
{
    const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);

    QSqlRecord newRecord = record();
    newRecord.setValue("author", "Me");
    newRecord.setValue("recipient", recipient);
    newRecord.setValue("timestamp", timestamp);
    newRecord.setValue("message", message);
    if (!insertRecord(rowCount(), newRecord)) {
        qWarning() << "Failed to send message:" << lastError().text();
        return;
    }

sendMessage()函数使用提供的recipientmessage向数据库插入一条新记录。由于我们使用了QSqlTableModel::OnManualSubmit,我们必须手动调用submitAll

使用QML连接数据库并注册类型

现在我们已经建立了模型类,让我们来看看main.cpp

#include <QtCore>
#include <QGuiApplication>
#include <QSqlDatabase>
#include <QSqlError>
#include <QtQml>

#include "sqlcontactmodel.h"
#include "sqlconversationmodel.h"

static void connectToDatabase()
{
    QSqlDatabase database = QSqlDatabase::database();
    if (!database.isValid()) {
        database = QSqlDatabase::addDatabase("QSQLITE");
        if (!database.isValid())
            qFatal("Cannot add database: %s", qPrintable(database.lastError().text()));
    }

    const QDir writeDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    if (!writeDir.mkpath("."))
        qFatal("Failed to create writable directory at %s", qPrintable(writeDir.absolutePath()));

    // Ensure that we have a writable location on all devices.
    const QString fileName = writeDir.absolutePath() + "/chat-database.sqlite3";
    // When using the SQLite driver, open() will create the SQLite database if it doesn't exist.
    database.setDatabaseName(fileName);
    if (!database.open()) {
        qFatal("Cannot open database: %s", qPrintable(database.lastError().text()));
        QFile::remove(fileName);
    }
}

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    qmlRegisterType<SqlContactModel>("io.qt.examples.chattutorial", 1, 0, "SqlContactModel");
    qmlRegisterType<SqlConversationModel>("io.qt.examples.chattutorial", 1, 0, "SqlConversationModel");

    connectToDatabase();

    QQmlApplicationEngine engine;
    engine.load(QUrl(QStringLiteral("qrc:/qt/qml/chapter4/main.qml")));
    if (engine.rootObjects().isEmpty())
        return -1;

    return app.exec();
}

connectToDatabase()创建与SQLite数据库的连接,如果尚未存在,将创建实际文件。

main()函数中,我们调用qmlRegisterType()将我们的模型注册为QML中的类型。

在QML中使用模型

现在,模型的可用性作为QML类型,我们对ContactPage.qml有一些小的改动。要使用这些类型,我们必须首先使用在main.cpp中设置的URI导入它们。

import io.qt.examples.chattutorial

然后,我们将伪造的模型替换为正确的模型。

        model: SqlContactModel {}

在代理内,我们使用不同的语法来访问模型数据。

            text: model.display

ConversationPage.qml中,我们添加相同的chattutorial导入,并替换伪造的模型。

            model: SqlConversationModel {
                recipient: inConversationWith
            }

在模型中,我们将recipient属性设置为显示此页面的联系人的名称。

先将根代理项目从行更改为列,以适使我们想要在每条消息下方显示的时间戳。

            delegate: Column {
                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                readonly property bool sentByMe: model.recipient !== "Me"

                Row {
                    id: messageRow
                    spacing: 6
                    anchors.right: sentByMe ? parent.right : undefined

                    Image {
                        id: avatar
                        source: !sentByMe ? "images/" + model.author.replace(" ", "_") + ".png" : ""
                    }

                    Rectangle {
                        width: Math.min(messageText.implicitWidth + 24,
                            listView.width - (!sentByMe ? avatar.width + messageRow.spacing : 0))
                        height: messageText.implicitHeight + 24
                        color: sentByMe ? "lightgrey" : "steelblue"

                        Label {
                            id: messageText
                            text: model.message
                            color: sentByMe ? "black" : "white"
                            anchors.fill: parent
                            anchors.margins: 12
                            wrapMode: Label.Wrap
                        }
                    }
                }

                Label {
                    id: timestampText
                    text: Qt.formatDateTime(model.timestamp, "d MMM hh:mm")
                    color: "lightgrey"
                    anchors.right: sentByMe ? parent.right : undefined
                }
            }

现在,有了合适的模型,我们可以在sentByMe属性的表示中使用它的recipient角色。

用于头像的矩形已经转换为图像。图像有自己的隐式大小,所以我们不需要显式指定它。与之前一样,我们只在作者不是用户时显示头像,但这次我们还将图像的source设置为空URL,而不是使用visible属性。

我们希望每条消息的背景比文本略微宽一点(每边12像素),但如果太长了,我们希望将其宽度限制在listview的边缘,因此使用了Math.min()。当消息不是我们发送的时,头像总是位于它之前,所以我们考虑到了这一点,通过减去头像和行间距的宽度。

例如,在上面的图像中,消息文本的隐式宽度是较小的值。然而,在下面的图像中,消息文本非常长,因此选择较小的值(视图的宽度),确保文本停在屏幕的另一边。

为了显示我们之前讨论的每条消息的时间戳,我们使用一个标签。日期和时间使用 Qt.formatDateTime() 格式化,使用自定义格式。

"发送"按钮现在必须对点击做出反应。

                Button {
                    id: sendButton
                    text: qsTr("Send")
                    enabled: messageField.length > 0
                    Layout.fillWidth: false
                    onClicked: {
                        listView.model.sendMessage(inConversationWith, messageField.text);
                        messageField.text = "";
                    }
                }

首先,我们调用模型中可调用的 sendMessage() 函数,该函数在对话数据库表的 Converstions 表中插入一行新记录。然后,我们清除文本字段,为新输入腾出空间。

第五章:样式

Qt Quick Controls 中的样式设计为可以在任何平台上工作。在本章中,我们将进行一些小的视觉调整,以确保应用程序在使用 BasicMaterialUniversal 样式时看起来很好。

到目前为止,我们只是用 Basic 样式测试了应用程序。例如,如果我们使用 Material 样式运行它,我们会立即看到一些问题。这是联系人页面

标题文本在深蓝色背景上为黑色,这非常难以阅读。同样的事情也发生在对话页面

解决方案是告诉工具栏应使用 “暗色” 主题,这样信息就可以传递给子项,允许它们将文本颜色改为更浅的颜色。这样做最简单的方式是直接导入 Material 样式并使用 Material 附加属性。

import QtQuick.Controls.Material 2.12

// ...

header: ToolBar {
    Material.theme: Material.Dark

    // ...
}

然而,这引入了对 Material 样式的硬依赖;即使目标设备不使用它,Material 样式插件 也必须 部署到应用程序中,否则 QML 引擎将无法找到导入。

相反,最好依赖 Qt Quick Controls 内置的基于 样式文件选择器 的支持。要这样做,我们必须将 ToolBar 移动到它自己的文件。我们将称之为 ChatToolBar.qml。这将是文件的 “默认” 版本,这意味着当使用 Basic 样式(当未指定样式时使用的样式)时将使用它。以下是新文件

import QtQuick.Controls

ToolBar {
}

由于我们只在文件中使用 ToolBar 类型,所以我们只需要导入 Qt Quick Controls。代码本身没有从 ContactPage.qml 改变,这是应该发生的;对于文件的默认版本,不需要任何不同。

回到 ContactPage.qml,我们更新代码以使用新类型

    header: ChatToolBar {
        Label {
            text: qsTr("Contacts")
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

现在我们需要添加工具栏的 Material 版本。文件选择器期望文件的变体在默认文件版本存在的适当命名的目录中。这意味着我们需要在 ChatToolBar.qml 所在目录(根目录)中添加一个名为 "+Material" 的文件夹。"+" 由 QFileSelector 要求,作为一种确保选择特征不会意外触发的手段。

以下是 +Material/ChatToolBar.qml

import QtQuick.Controls
import QtQuick.Controls.Material

ToolBar {
    Material.theme: Material.Dark
}

我们将对 ConversationPage.qml 进行相同的更改

    header: ChatToolBar {
        ToolButton {
            text: qsTr("Back")
            anchors.left: parent.left
            anchors.leftMargin: 10
            anchors.verticalCenter: parent.verticalCenter
            onClicked: root.StackView.view.pop()
        }

        Label {
            id: pageTitle
            text: inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

现在两个页面看起来都是正确的

让我们试一试 Universal 样式

这里没有问题。对于一个相对简单的应用程序,切换样式时通常所需的调整非常少。

现在让我们尝试每个样式的深色主题。基本样式没有深色主题,因为这会给旨在尽可能高效执行的风格带来一些轻微的性能开销。我们先测试一下Material样式,所以向 qtquickcontrols2.conf 中添加一个条目,让它使用其深色主题

[Material]
Primary=Indigo
Accent=Indigo
Theme=Dark

完成这些后,构建并运行应用程序。你应该看到以下内容

两个页面看起来都很好。现在为通用样式添加一个条目

[universal]
Theme=Dark

在构建并运行应用程序之后,你应该看到这些结果

总结

在本教程中,我们带您了解了使用Qt Quick Controls编写基本应用程序的以下步骤

  • 使用Qt Creator创建一个新项目。
  • 设置一个基本的 ApplicationWindow
  • 使用Page定义页眉和页脚。
  • ListView中显示内容。
  • 将组件重构到它们自己的文件中。
  • 使用StackView在屏幕之间导航。
  • 使用布局让应用程序优雅地调整大小。
  • 实现自定义只读模型和可写模型,并将SQL数据库集成到应用程序中。
  • 通过Q_PROPERTYQ_INVOKABLEqmlRegisterType将C++与QML集成。
  • 测试和配置多个样式。

© 2024 Qt公司有限公司。本文档中包含的文档贡献归各自所有者所有。本提供的文档是根据自由软件基金会发布的GNU自由文档许可证版本1.3的条款授予许可的。Qt及其 respective 标志是芬兰的和/或其他国家的Qt公司的商标。所有其他商标均为各自所有者的财产。