Qt Quick Controls - 채팅 튜토리얼

이 튜토리얼은 Qt Quick Controls 을 사용하여 기본 채팅 애플리케이션을 작성하는 방법을 보여줍니다. 또한 SQL 데이터베이스를 Qt 애플리케이션에 통합하는 방법도 설명합니다.

1장: 설정하기

새 프로젝트를 설정할 때 가장 쉽게 사용하는 방법은 Qt Creator. 이 프로젝트에서는 다음 파일로 기본 "Hello World" 애플리케이션을 생성하는 Qt Quick 애플리케이션 템플릿을 선택했습니다:

  • CMakeLists.txt - 프로젝트를 어떻게 빌드해야 하는지 CMake에 지시합니다.
  • Main.qml - 빈 창이 포함된 기본 UI를 제공합니다.
  • main.cpp - Loads main.qml
  • qtquickcontrols2.conf - 애플리케이션이 어떤 스타일을 사용해야 하는지 알려줍니다.

main.cpp

main.cpp 의 기본 코드에는 두 가지가 포함되어 있습니다:

#include <QGuiApplication>
#include <QQmlApplicationEngine>

첫 번째는 QGuiApplication 에 대한 액세스를 제공합니다. 모든 Qt 응용 프로그램에는 응용 프로그램 객체가 필요하지만 정확한 유형은 응용 프로그램의 기능에 따라 다릅니다. 비그래픽 응용 프로그램에는 QCoreApplication 으로 충분하고, 그래픽 응용 프로그램에서는 QGuiApplication 으로 충분하며, 사용하지 않는 그래픽 응용 프로그램에서는 Qt Widgets를 사용하지 않는 그래픽 애플리케이션에는 QApplication 가 필요합니다.

두 번째 포함은 QQmlApplicationEngine 을 사용할 수 있게 하여 QML을 로드할 수 있게 합니다.

main() 내에서 애플리케이션 객체와 QML 엔진을 설정합니다:

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

    QQmlApplicationEngine engine;
    engine.loadFromModule("chattutorial", "Main");

    return app.exec();
}

QQmlApplicationEngineQQmlEngine 에 대한 편리한 래퍼로, loadFromModule 함수를 제공하여 애플리케이션의 QML을 쉽게 로드할 수 있습니다. 또한 파일 선택기를 사용할 때 편리함을 더합니다.

C++에서 설정을 마치면 QML의 사용자 인터페이스로 넘어갈 수 있습니다.

Main.qml

기본 QML 코드를 필요에 맞게 수정해 보겠습니다.

import QtQuick
import QtQuick.Controls

이미 Qt Quick 모듈이 이미 임포트되어 있음을 알 수 있습니다. 이렇게 하면 Item, Rectangle, Text 등과 같은 그래픽 프리미티브에 액세스할 수 있습니다. 전체 유형 목록은 Qt Quick QML Types 문서를 참조하세요.

Qt Quick Controls 모듈 가져오기를 추가합니다. 이렇게 하면 무엇보다도 기존 루트 유형인 Window 을 대체하는 ApplicationWindow 에 액세스할 수 있습니다:

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

ApplicationWindow headerfooter 를 만들기 위한 편의성이 추가된 Window 이며, popups 의 기초를 제공하고 배경 color 과 같은 몇 가지 기본 스타일링을 지원합니다.

ApplicationWindow 을 사용할 때 거의 항상 설정되는 세 가지 속성이 있습니다 : width, height, visible. 이 속성을 설정하면 적절한 크기의 빈 창을 콘텐츠로 채울 준비가 된 것입니다.

참고: 기본 코드의 title 속성은 제거되었습니다.

애플리케이션의 첫 번째 '화면' 은 연락처 목록이 될 것입니다. 각 화면의 상단에 그 목적을 설명하는 텍스트가 있으면 좋을 것입니다. ApplicationWindow 의 머리글 및 바닥글 속성이 이 상황에서 작동할 수 있습니다. 이 속성은 애플리케이션의 모든 화면에 표시되어야 하는 항목에 이상적인 몇 가지 특성을 가지고 있습니다:

  • 각각 창의 상단과 하단에 고정되어 있습니다.
  • 창의 너비를 채웁니다.

그러나 사용자가 보는 화면에 따라 머리글과 바닥글의 내용이 달라지는 경우 Page 을 사용하는 것이 훨씬 쉽습니다. 지금은 한 페이지만 추가해 보겠지만 다음 장에서는 여러 페이지 사이를 이동하는 방법을 보여드리겠습니다.

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

먼저 anchors.fill 속성을 사용하여 창의 모든 공간을 차지할 수 있는 크기의 페이지를 추가합니다.

그런 다음 header 속성에 Label 을 할당합니다. Label은 스타일링과 font 상속을 추가하여 Qt Quick 모듈의 기본 Text 항목을 확장합니다. 즉, 레이블은 사용 중인 스타일에 따라 다르게 보일 수 있으며 픽셀 크기를 자식에게 전파할 수도 있습니다.

애플리케이션 창 상단과 텍스트 사이에 약간의 거리가 필요하므로 padding 속성을 설정합니다. 이렇게 하면 레이블의 각 면에 추가 공간이 할당됩니다(해당 범위 내). 대신 topPaddingbottomPadding 속성을 명시적으로 설정할 수도 있습니다.

qsTr() 함수를 사용하여 라벨의 텍스트를 설정하면 Qt XML의 번역 시스템에서 텍스트를 번역할 수 있습니다. 애플리케이션의 최종 사용자에게 표시되는 텍스트의 경우 이 방법을 따르는 것이 좋습니다.

기본적으로 텍스트는 바운드의 상단에 세로로 정렬되지만 가로 정렬은 텍스트의 자연스러운 방향에 따라 달라집니다(예: 왼쪽에서 오른쪽으로 읽는 텍스트는 왼쪽으로 정렬됨). 이러한 기본값을 사용하면 텍스트가 창의 왼쪽 상단 모서리에 배치됩니다. 이는 헤더에 바람직하지 않으므로 텍스트를 가로 및 세로 모두 바운드의 중앙에 맞춥니다.

프로젝트 파일

CMakeLists.txt 파일에는 프로젝트를 실행할 수 있는 실행 파일로 빌드하는 데 필요한 모든 정보가 포함되어 있습니다.

이 파일에 대한 자세한 설명은 QML 애플리케이션 빌드하기를 참조하세요.

다음은 현재 애플리케이션을 실행했을 때의 모습입니다:

2장: 목록

이 장에서는 ListViewItemDelegate 을 사용하여 대화형 항목의 목록을 만드는 방법을 설명합니다.

ListView Qt Quick 모듈에서 제공되며 모델에서 채워진 항목 목록을 표시합니다. ItemDelegateQt 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 {
                id: contactDelegate
                text: modelData
                width: listView.width - listView.leftMargin - listView.rightMargin
                height: avatar.implicitHeight
                leftPadding: avatar.implicitWidth + 32

                required property string modelData

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

크기 및 위치 지정

가장 먼저 할 일은 뷰의 크기를 설정하는 것입니다. 페이지의 사용 가능한 공간을 채워야 하므로 anchors.fill 을 사용합니다. Page는 머리글과 바닥글에 충분한 공간을 확보하므로 이 경우 뷰는 예를 들어 머리글 아래에 배치됩니다.

다음으로 ListView 주위에 margins 을 설정하여 창 가장자리와 약간의 거리를 둡니다. 여백 속성은 뷰의 범위 내에서 공간을 예약하므로 사용자가 빈 영역을 계속 '플릭' 할 수 있습니다.

항목은 뷰 내에서 적절한 간격을 유지해야 하므로 spacing 속성은 20 으로 설정됩니다.

모델

일부 항목으로 뷰를 빠르게 채우기 위해 JavaScript 배열을 모델로 사용했습니다. QML의 가장 큰 강점 중 하나는 애플리케이션의 프로토타입을 매우 빠르게 만들 수 있다는 점이며, 이 예가 그 예입니다. 모델 프로퍼티에 숫자를 할당하여 필요한 항목 수를 간단히 나타낼 수도 있습니다. 예를 들어 model 속성에 10 을 할당하면 각 항목의 표시 텍스트는 0 에서 9 까지의 숫자가 됩니다.

그러나 애플리케이션이 프로토타입 단계를 지나면 실제 데이터를 사용해야 할 필요가 생깁니다. 이를 위해 적절한 C++ 모델을 사용하는 것이 가장 좋습니다 subclassing QAbstractItemModel.

Delegate

delegate. 모델의 해당 텍스트를 ItemDelegatetext 속성에 할당합니다. 모델의 데이터가 각 델리게이트에 제공되는 정확한 방식은 사용되는 모델 유형에 따라 다릅니다. 자세한 내용은 Qt Quick 의 모델 및 보기를 참조하세요.

애플리케이션에서 뷰의 각 항목의 너비는 뷰의 너비와 같아야 합니다. 이렇게 하면 사용자가 목록에서 연락처를 선택할 수 있는 충분한 공간이 확보되며, 이는 휴대폰과 같이 작은 터치스크린이 있는 기기에서 중요한 요소입니다. 그러나 뷰의 너비에는 48 픽셀 여백이 포함되므로 너비 속성에 할당할 때 이를 고려해야 합니다.

다음으로 Image 을 정의하면 사용자 연락처의 사진이 표시됩니다. 이미지의 너비는 40 픽셀, 높이는 40 픽셀이 됩니다. 델리게이트의 높이는 이미지의 높이를 기준으로 하여 세로 공간이 비어 있지 않도록 합니다.

3장: 탐색

이 장에서는 StackView 를 사용하여 애플리케이션의 페이지 사이를 탐색하는 방법을 알아봅니다. 다음은 수정된 main.qml 입니다:

import QtQuick.Controls

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

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

이름에서 알 수 있듯이 StackView 은 스택 기반 탐색 기능을 제공합니다. 스택에 마지막으로 '푸시 '된 항목이 가장 먼저 제거되며, 가장 위에 있는 항목이 항상 표시되는 항목이 됩니다.

페이지에서 했던 것과 같은 방식으로 StackView 에 애플리케이션 창을 채우도록 지시합니다. 그 후 남은 작업은 initialItem 을 통해 표시할 항목을 지정하는 것입니다. StackViewitems, componentsURLs 을 허용합니다.

연락처 목록의 코드를 ContactPage.qml 으로 옮긴 것을 볼 수 있습니다. 애플리케이션에 어떤 화면이 포함될지 대략적인 아이디어가 떠오르면 바로 이 작업을 수행하는 것이 좋습니다. 이렇게 하면 코드를 더 쉽게 읽을 수 있을 뿐만 아니라 꼭 필요한 경우에만 특정 컴포넌트에서 항목을 인스턴스화할 수 있으므로 메모리 사용량을 줄일 수 있습니다.

참고: Qt Creator 에서는 QML을 위한 몇 가지 편리한 리팩터링 옵션을 제공하며, 그 중 하나는 코드 블록을 별도의 파일(Alt + Enter > Move Component into Separate File)로 옮길 수 있는 옵션입니다.

ListView 을 사용할 때 고려해야 할 또 다른 사항은 id 으로 참조할지, 아니면 첨부된 ListView.view 속성을 사용할지 여부입니다. 가장 좋은 방법은 몇 가지 다른 요인에 따라 달라집니다. 뷰에 ID를 부여하면 첨부된 속성이 매우 적은 양의 오버헤드를 가지므로 바인딩 표현식이 더 짧고 효율적입니다. 그러나 다른 보기에서 델리게이트를 재사용하려는 경우 특정 보기에 델리게이트를 묶지 않도록 첨부된 속성을 사용하는 것이 좋습니다. 예를 들어, 첨부된 속성을 사용하면 델리게이트의 width 할당이 됩니다:

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

2장에서는 헤더 아래에 ListView 을 추가했습니다. 해당 챕터의 애플리케이션을 실행하면 뷰의 내용을 헤더 상단에서 스크롤할 수 있는 것을 볼 수 있습니다:

특히 델리게이트의 텍스트가 헤더의 텍스트에 닿을 정도로 긴 경우 이는 좋지 않습니다. 가장 이상적인 것은 머리글 텍스트 아래에 단색 블록이 있지만 뷰 위에 있는 것입니다. 이렇게 하면 목록 뷰 콘텐츠가 헤더 콘텐츠를 시각적으로 방해하지 않습니다. 뷰의 clip 속성을 true 으로 설정하여 이 작업을 수행할 수도 있지만 can affect performance.

ToolBar 가 이 작업에 적합한 도구입니다. 탐색 버튼 및 검색 필드와 같은 애플리케이션 전체 및 상황에 맞는 작업과 컨트롤을 모두 포함하는 컨테이너입니다. 무엇보다도 평소와 같이 애플리케이션 스타일에서 제공되는 배경색이 있다는 점이 가장 좋습니다. 실제로 작동하는 모습입니다:

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

자체 레이아웃이 없으므로 레이블을 직접 중앙에 배치합니다.

나머지 코드는 2장에서와 동일하지만 clicked 신호를 활용하여 다음 페이지를 스택뷰로 푸시한다는 점을 제외하면 동일합니다:

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

Component 또는 urlStackView 으로 푸시할 때, (결국) 인스턴스화된 항목을 몇 가지 변수로 초기화해야 하는 경우가 종종 있습니다. StackViewpush() 함수는 자바스크립트 객체를 두 번째 인수로 받아 이를 처리합니다. 이를 사용하여 다음 페이지에 연락처의 이름을 제공하고, 그 다음 페이지에서 관련 대화를 표시하는 데 사용합니다. 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: root.inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }
    ...

이 컴포넌트의 루트 항목은 inConversationWith 이라는 사용자 정의 속성이 있는 또 다른 페이지입니다. 현재 이 속성은 단순히 헤더의 레이블이 표시할 내용을 결정합니다. 나중에 대화에서 메시지 목록을 채우는 SQL 쿼리에서 이 속성을 사용할 것입니다.

사용자가 연락처 페이지로 돌아갈 수 있도록 클릭 시 pop()을 호출하는 ToolButton 을 추가합니다. ToolButton 은 기능적으로는 Button 과 유사하지만 ToolBar 에 더 적합한 모양을 제공합니다.

QML에서 항목을 레이아웃하는 방법에는 두 가지가 있습니다: 항목 포지셔너와 Qt Quick 레이아웃. 항목 포지셔너(Row, Column 등)는 항목의 크기를 알고 있거나 고정되어 있고 특정 배열로 깔끔하게 배치하기만 하면 되는 경우에 유용합니다. Qt Quick 레이아웃의 레이아웃은 항목의 위치를 지정하고 크기를 조정할 수 있으므로 크기 조정이 가능한 사용자 인터페이스에 적합합니다. 아래에서는 ColumnLayout 을 사용하여 ListViewPane 을 세로로 배치합니다:

    ColumnLayout {
        anchors.fill: parent

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

        }
        ...

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

창은 기본적으로 애플리케이션의 스타일에서 색상을 가져오는 직사각형입니다. Frame 과 유사하지만 테두리 주위에 획이 없다는 점이 다릅니다.

레이아웃의 직접 자식인 항목에는 다양한 attached properties 을 사용할 수 있습니다. ListView 에서 Layout.fillWidthLayout.fillHeight 을 사용하여 ColumnLayout 내에서 가능한 한 많은 공간을 차지하도록 합니다. 창에서도 마찬가지입니다. ColumnLayout 은 세로형 레이아웃이므로 각 하위 항목의 왼쪽이나 오른쪽에 항목이 없으므로 각 항목이 레이아웃의 전체 너비를 차지하게 됩니다.

반면에 ListViewLayout.fillHeight 문을 사용하면 창을 수용한 후 남은 공간을 차지할 수 있습니다.

목록 보기를 자세히 살펴봅시다:

        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 {
                id: messageDelegate
                anchors.right: sentByMe ? listView.contentItem.right : undefined
                spacing: 6

                required property int index
                readonly property bool sentByMe: index % 2 == 0

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

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

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

            ScrollBar.vertical: ScrollBar {}
        }

상위 뷰의 너비와 높이를 채운 후 뷰에 약간의 여백도 설정합니다. 이렇게 하면 '메시지 작성' 필드에 있는 플레이스홀더 텍스트와 잘 정렬됩니다:

다음으로 displayMarginBeginningdisplayMarginEnd 을 설정합니다. 이러한 속성은 뷰의 가장자리에서 스크롤하는 동안 뷰의 경계 밖에 있는 델리게이트가 사라지지 않도록 합니다. 속성에 주석을 달고 뷰를 스크롤할 때 어떤 일이 발생하는지 확인하면 이를 가장 쉽게 이해할 수 있습니다.

그런 다음 뷰의 세로 방향을 뒤집어 첫 번째 항목이 맨 아래에 오도록 합니다. 델리게이트는 12픽셀 간격으로 배치되며, 4장에서 실제 모델을 구현할 때까지 테스트 목적으로 "더미" 모델이 할당됩니다.

위 이미지와 같이 아바타 다음에 메시지 콘텐츠가 오도록 하기 위해 델리게이트 내에서 Row 를 루트 항목으로 선언합니다.

사용자가 보낸 메시지는 연락처가 보낸 메시지와 구별되어야 합니다. 지금은 더미 속성 sentByMe 을 설정하여 단순히 대리인의 색인을 사용하여 다른 작성자를 번갈아 가며 사용합니다. 이 속성을 사용하여 세 가지 방법으로 서로 다른 작성자를 구분합니다:

  • anchors.rightlistView.contentItem.right 으로 설정하여 사용자가 보낸 메시지를 화면 오른쪽에 정렬합니다.
  • sentByMe 을 기준으로 아바타의 visible 속성(현재는 직사각형)을 설정하여 연락처가 보낸 메시지인 경우에만 표시합니다.
  • 작성자에 따라 사각형의 색상을 변경합니다. 어두운 배경에 어두운 텍스트를 표시하고 싶지 않고 그 반대의 경우도 마찬가지이므로 작성자가 누구인지에 따라 텍스트 색상도 설정합니다. 5장에서는 스타일링이 이러한 문제를 어떻게 처리하는지 살펴보겠습니다.

화면 하단에 여러 줄의 텍스트 입력을 허용하는 TextArea 항목과 메시지를 보내는 버튼을 배치합니다. 목록 보기의 콘텐츠가 페이지 헤더를 가리지 않도록 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

튜토리얼을 단순하게 유지하기 위해 사용자 연락처 목록을 편집할 수 없도록 만들었습니다. SQL 결과 집합에 대한 읽기 전용 데이터 모델을 제공하기 때문에 이 목적을 위한 논리적 선택은 QSqlQueryModel입니다.

QSqlQueryModel 에서 파생된 SqlContactModel 클래스를 살펴보겠습니다:

#include <QQmlEngine>
#include <QSqlQueryModel>

class SqlContactModel : public QSqlQueryModel
{
    Q_OBJECT
    QML_ELEMENT

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

여기에는 별다른 내용이 없으므로 .cpp 파일로 이동해 보겠습니다:

#include "sqlcontactmodel.h"#include <QDebug>#include <QSqlError>#include <QSqlQuery>static void createTable() { if (QSqlDatabase::database().tables().contains(QStringLiteral("연락처"))) { // 테이블이 이미 존재하므로 아무 작업도 할 필요가 없습니다.   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에서 필요한 헤더 파일을 포함시킵니다. 그런 다음 SQL 테이블을 생성하는 데 사용할 createTable() 이라는 정적 함수를 정의한 다음(아직 존재하지 않는 경우) 더미 연락처로 채웁니다.

아직 특정 데이터베이스를 설정하지 않았기 때문에 database()에 대한 호출이 약간 혼란스러워 보일 수 있습니다. 이 함수에 연결 이름이 전달되지 않으면 "기본 연결"이 반환되며, 이 연결 생성에 대해서는 곧 다룰 예정입니다.

SqlContactModel::SqlContactModel(QObject *부모) : 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 <QQmlEngine>
#include <QSqlTableModel>

class SqlConversationModel : public QSqlTableModel
{
    Q_OBJECT
    QML_ELEMENT
    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_PROPERTYQ_INVOKABLE 매크로를 모두 사용하므로 Q_OBJECT 매크로를 사용하여 moc에게 알려야 합니다.

recipient 속성은 QML에서 설정되어 모델이 어떤 대화에 대해 메시지를 검색해야 하는지 알려줍니다.

QML에서 사용자 지정 역할을 사용할 수 있도록 data() 및 roleNames() 함수를 재정의합니다.

또한 QML에서 호출할 sendMessage() 함수, 즉 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)) { // 테이블이 이미 존재하므로 아무 작업도 할 필요가 없습니다.   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. 예, 잘 맞습니다. Tusen takk! " "Hvor mange timer har du brukt på den?')"); }

이것은 sqlcontactmodel.cpp 와 매우 유사하지만 Conversations 테이블에서 작동한다는 점을 제외하면 매우 유사합니다. 또한 conversationsTableName 을 정적 const 변수로 정의하여 파일 전체에서 몇 군데에 사용합니다.

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 에서와 마찬가지로 생성자에서 가장 먼저 하는 일은 테이블을 만드는 것입니다. setTable () 함수를 통해 사용할 테이블의 이름을 QSqlTableModel 에 전달합니다. 대화의 최신 메시지가 먼저 표시되도록 하기 위해 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 &수신자, const QString &message) { const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);    QSqlRecord newRecord = record(); newRecord.setValue("작성자", "나"); newRecord.setValue("수신", 수신자); newRecord.setValue("타임스탬프", timestamp); newRecord.setValue("메시지", 메시지); 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>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()));

   // 모든 디바이스에서 쓰기 가능한 위치가 있는지 확인합니다.QString fileName = writeDir.absolutePath() + "/chat-database.sqlite3"; // SQLite 드라이버를 사용하는 경우, SQLite 데이터베이스가 존재하지 않으면 open()이 생성합니다.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); connectToDatabase();    QQmlApplicationEngine engine; engine.loadFromModule("chattutorial", "Main"); if (engine.rootObjects().isEmpty()) return-1; return app.exec(); }

connectToDatabase() 는 SQLite 데이터베이스에 대한 연결을 생성하고, 아직 존재하지 않는 경우 실제 파일을 생성합니다.

main() 내에서 qmlRegisterType()을 호출하여 모델을 QML 내 유형으로 등록합니다.

QML에서 모델 사용하기

이제 모델을 QML 유형으로 사용할 수 있게 되었으므로 ContactPage.qml 에 약간의 변경이 필요합니다. 유형을 사용하려면 먼저 main.cpp 에서 설정한 URI를 사용하여 모델을 가져와야 합니다:

import chattutorial

그런 다음 더미 모델을 적절한 모델로 바꿉니다:

        model: SqlContactModel {}

델리게이트 내에서 모델 데이터에 액세스하는 데 다른 구문을 사용합니다:

            text: model.display

ConversationPage.qml 에서 동일한 chattutorial 가져오기를 추가하고 더미 모델을 대체합니다:

            model: SqlConversationModel {
                recipient: root.inConversationWith
            }

모델 내에서 recipient 속성을 페이지가 표시되는 연락처의 이름으로 설정합니다.

루트 델리게이트 항목은 모든 메시지 아래에 표시하려는 타임스탬프를 수용하기 위해 행에서 열로 변경됩니다:

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

                required property string author
                required property string recipient
                required property date timestamp
                required property string message
                readonly property bool sentByMe: recipient !== "Me"

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

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

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

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

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

이제 적절한 모델이 생겼으므로 sentByMe 속성에 대한 표현식에서 recipient 역할을 사용할 수 있습니다.

아바타에 사용되었던 직사각형이 이미지로 변환되었습니다. 이미지에는 자체적으로 암시적인 크기가 있으므로 명시적으로 지정할 필요가 없습니다. 이전과 마찬가지로 작성자가 사용자가 아닌 경우에만 아바타를 표시하지만 이번에는 visible 속성을 사용하는 대신 이미지의 source 을 빈 URL로 설정했습니다.

각 메시지 배경은 텍스트보다 약간 더 넓게(한 면당 12픽셀) 표시되기를 원합니다. 그러나 너무 길면 폭을 목록 보기의 가장자리로 제한하고 싶기 때문에 Math.min() 을 사용합니다. 메시지가 보낸 사람이 아닌 경우 항상 아바타가 앞에 오므로 아바타의 폭과 행 간격을 빼서 이를 고려합니다.

예를 들어 위 이미지에서는 메시지 텍스트의 암시적 너비가 더 작은 값입니다. 그러나 아래 이미지에서는 메시지 텍스트가 상당히 길기 때문에 더 작은 값(보기 너비)이 선택되어 텍스트가 화면의 반대쪽 가장자리에서 멈추도록 합니다:

앞서 설명한 각 메시지의 타임스탬프를 표시하기 위해 레이블을 사용합니다. 날짜와 시간은 사용자 지정 형식을 사용하여 Qt.formatDateTime()로 서식을 지정합니다.

이제 '보내기' 버튼이 클릭될 때 반응해야 합니다:

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

먼저 모델의 호출 가능한 sendMessage() 함수를 호출하여 대화 데이터베이스 테이블에 새 행을 삽입합니다. 그런 다음 텍스트 필드를 지워 향후 입력을 위한 공간을 확보합니다.

5장: 스타일링

Qt Quick Controls 의 스타일은 모든 플랫폼에서 작동하도록 설계되었습니다. 이 장에서는 기본, 머티리얼유니버설 스타일로 실행했을 때 애플리케이션이 보기 좋게 보이도록 약간의 시각적 조정을 해보겠습니다.

지금까지는 기본 스타일로 애플리케이션을 테스트했습니다. 예를 들어 머티리얼 스타일로 실행하면 몇 가지 문제를 즉시 확인할 수 있습니다. 다음은 연락처 페이지입니다:

헤더 텍스트가 진한 파란색 배경에 검은색으로 표시되어 읽기 매우 어렵습니다. 대화 페이지에서도 같은 문제가 발생합니다:

해결책은 도구 모음에 "어두운" 테마를 사용하도록 지시하여 이 정보가 하위 항목에 전파되어 텍스트 색상을 더 밝은 색으로 전환할 수 있도록 하는 것입니다. 가장 간단한 방법은 머티리얼 스타일을 직접 가져와서 머티리얼 첨부 속성을 사용하는 것입니다:

import QtQuick.Controls.Material 2.12

// ...

header: ToolBar {
    Material.theme: Material.Dark

    // ...
}

하지만 이렇게 하면 머티리얼 스타일에 대한 종속성이 강해지며, 대상 디바이스에서 사용하지 않더라도 머티리얼 스타일 플러그인을 애플리케이션과 함께 배포해야 하며 그렇지 않으면 QML 엔진이 가져오기를 찾지 못합니다.

대신 스타일 기반 파일 선택기에 대한 Qt Quick Controls 의 기본 지원을 사용하는 것이 좋습니다. 이렇게 하려면 ToolBar 을 자체 파일로 이동해야 합니다. 이 파일을 ChatToolBar.qml 이라고 부르겠습니다. 이 파일은 파일의 "기본" 버전이 되므로 기본 스타일 (아무것도 지정하지 않았을 때 사용되는 스타일)이 사용 중일 때 사용됩니다. 새 파일은 다음과 같습니다:

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: root.inConversationWith
            font.pixelSize: 20
            anchors.centerIn: parent
        }
    }

이제 두 페이지가 모두 올바르게 보입니다:

유니버설 스타일을 사용해 보겠습니다:

문제 없습니다. 이와 같이 비교적 간단한 애플리케이션의 경우 스타일을 전환할 때 조정할 필요가 거의 없습니다.

이제 각 스타일의 어두운 테마를 사용해 보겠습니다. 기본 스타일에는 어두운 테마가 없는데, 이는 최대한 성능을 높이도록 설계된 스타일에 약간의 오버헤드를 추가할 수 있기 때문입니다. 먼저 머티리얼 스타일을 테스트할 것이므로 qtquickcontrols2.conf 에 어두운 테마를 사용하도록 지시하는 항목을 추가합니다:

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

이 작업이 완료되면 애플리케이션을 빌드하고 실행합니다. 다음과 같은 화면이 표시됩니다:

두 페이지 모두 정상적으로 보입니다. 이제 유니버설 스타일에 대한 항목을 추가합니다:

[universal]
Theme=Dark

애플리케이션을 빌드하고 실행하면 다음과 같은 결과를 볼 수 있습니다:

요약

이 튜토리얼에서는 Qt Quick Controls 을 사용하여 기본 애플리케이션을 작성하는 다음 단계를 안내해 드렸습니다:

  • Qt Creator 를 사용하여 새 프로젝트 만들기.
  • 기본 ApplicationWindow 설정하기.
  • 페이지로 머리글과 바닥글 정의하기.
  • ListView 에 콘텐츠 표시하기.
  • 컴포넌트를 자체 파일로 리팩터링하기.
  • StackView 을 사용하여 화면 간 탐색하기.
  • 레이아웃을 사용하여 애플리케이션의 크기를 우아하게 조정하기.
  • SQL 데이터베이스를 애플리케이션에 통합하는 사용자 지정 읽기 전용 및 쓰기 가능 모델 구현하기.
  • Q_PROPERTY, Q_INVOKABLE, qmlRegisterType()를 통해 C++와 QML을 통합합니다.
  • 여러 스타일 테스트 및 구성.

© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.