Распространенные патерны UI с использованием Qt Quick Controls

Добавлено 20 марта 2022 в 17:05

Существует ряд распространенных шаблонов построения пользовательских интерфейсов, которые можно реализовать с помощью Qt Quick Controls. В этом разделе мы попытаемся продемонстрировать, как можно построить некоторые из наиболее распространенных.

Вложенные экраны

Для этого примера мы создадим дерево страниц, на которые можно попасть с предыдущего уровня экранов. Структура изображена ниже.

структура

Ключевым компонентом пользовательского интерфейса этого типа является StackView. Он позволяет нам размещать страницы в стеке, которые затем могут быть извлечены, когда пользователь захочет вернуться. На примере здесь мы покажем, как это можно реализовать.

Начальный домашний экран приложения показан на рисунке ниже.

начальный экран

Приложение запускается в main.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, которая представляет собой элемент управления, поддерживающий верхние и нижние колонтитулы. В этом примере мы просто центрируем метку с текстом «Home Screen» на странице. Это работает, потому что содержимое StackView автоматически заполняет представление стека, и поэтому страница будет иметь правильный размер.

import QtQuick
import QtQuick.Controls

Page {
    title: qsTr("Home")

    Label {
        anchors.centerIn: parent
        text: qsTr("Home Screen")
    }
}

Вернувшись к main.qml, мы теперь рассмотрим часть, относящуюся к выдвижному элементу Drawer. Здесь начинается навигация по страницам. Активными частями пользовательского интерфейса являются элементы ÌtemDelegate. В обработчике 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..." представляют нужные нам символы Юникода.

В обработчике onClicked мы видим, что когда в стеке более одной страницы, стек извлекается, т.е. верхняя страница удаляется. Если стек содержит только один элемент, то есть главный экран, открывается выдвижной элемент Drawer.

Под панелью инструментов 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
        }
    }

    // ...

}

Теперь мы увидели, как получить доступ к страницам «О программе» и «Профиль», но мы также хотим сделать возможным доступ к странице «Редактировать профиль» со страницы «Профиль». Это делается с помощью кнопки на странице профиля. При нажатии кнопки файл 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")
        }
    }
}

Последовательность экранов

Для этого примера мы создадим пользовательский интерфейс, состоящий из трех страниц, по которым пользователь может перемещаться. Страницы показаны на схеме ниже. Это может быть интерфейс приложения для отслеживания здоровья, отслеживания текущего состояния, статистики пользователя и общей статистики.

структура пользовательского интерфейса

На рисунке ниже показано, как страница текущего состояния выглядит в приложении. Основная часть экрана управляется SwipeView, что позволяет использовать шаблон взаимодействия последовательности экранов. Заголовок и текст, показанные на рисунке, взяты со страницы внутри SwipeView, а PageIndicator (три точки внизу) взят из main.qml и находится под SwipeView. Индикатор страницы показывает пользователю, какая страница в данный момент активна, что помогает при навигации.

страница текущего состояния

Рассмотрим файл main.qml, он состоит из ApplicationWindow со SwipeView.

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 {
        }
    }

    // ...

}

Наконец, свойства count и currentIndex элемента SwipeView привязаны к элементу PageIndicator. Это завершает структуру, связанную со страницами.

ApplicationWindow {

    // ...

    SwipeView {
        id: swipeView

        // ...

    }

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

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

Каждая страница состоит из Page с заголовком header, состоящей из метки Label и некоторого содержимого. Для страниц «Текущее состояние» и «Статистика пользователя» содержимое состоит из простой метки Label, а для страницы «Статистика сообщества» имеется кнопка «Назад».

// TotalStats.qml

import QtQuick
import QtQuick.Controls

Page {
    header: Label {
        text: qsTr("Community Stats")
        font.pixelSize: Qt.application.font.pixelSize * 2
        padding: 10
    }

    // ...

}
Статистика сообщества

Кнопка «Назад» явно вызывает setCurrentIndex элемента SwipeView, чтобы установить нулевой индекс, возвращая пользователя непосредственно на страницу «Текущее состояние». Во время каждого перехода между страницами SwipeView обеспечивает переход, поэтому даже при явном изменении индекса пользователю дается ощущение направления.

Подсказка

При программной навигации в SwipeView важно не устанавливать currentIndex путем присваивания в JavaScript. Это связано с тем, что это нарушит любые привязки QML, которые он переопределяет. Вместо этого используйте методы setCurrentIndex, incrementCurrentIndex и decrementCurrentIndex. Это сохраняет привязки QML.

// TotalStats.qml

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);
        }
    }
}

Окна документов

В этом примере показано, как реализовать пользовательский интерфейс, ориентированный на десктоп и работу с документами. Идея состоит в том, чтобы иметь отдельное окно для каждого документа. При открытии нового документа открывается новое окно. Для пользователя каждое окно представляет собой автономный мир с одним документом.

Окна документов

Код начинается с ApplicationWindow с меню File со стандартными операциями: New, Open, Save и Save As. Мы помещаем этот код в DocumentWindow.qml.

Мы импортировали Qt.labs.platform для нативных диалогов и внесли последующие изменения в файл проекта и main.cpp, как описано в разделе о нативных диалогах.

// DocumentWindow.qml

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()
            }
        }
    }

    // ...

}

Чтобы запустить программу, мы создаем первый экземпляр DocumentWindow из файла main.qml, который является точкой входа приложения.

// main.qml

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 создается экземпляр нового окна документа с использованием метода createNewDocument, а имя файла задается перед отображением окна. В данном примере реальной загрузки не происходит.

Подсказка

Мы импортировали модуль Qt.labs.platform как NativeDialogs. Это связано с тем, что он предоставляет MenuItem, который конфликтует с MenuItem, предоставляемым модулем QtQuick.Controls.

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 проверяется свойство tryToClose. Этот флаг устанавливается, если сохранение является результатом желания пользователя сохранить документ при закрытии окна. Как следствие, после выполнения операции сохранения окно закрывается. Опять же, в этом примере фактического сохранения не происходит.

ApplicationWindow {

    // ...

    property bool isDirty: true        // Есть ли в документе несохраненные изменения?
    property string fileName           // Имя файла документа
    property bool tryingToClose: false // Окно пытается закрыться (сначала необходимо имя файла)?

    // ...

    function saveAsDocument()
    {
        saveAsDialog.open();
    }

    function saveDocument()
    {
        if (fileName.length === 0)
        {
            root.saveAsDocument();
        }
        else
        {
            // Здесь сохраняем документ
            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. Здесь код может не принимать запрос на закрытие. Если в документе есть несохраненные изменения, мы открываем closeWarningDialog и отклоняем запрос на закрытие.

closeWarningDialog спрашивает пользователя, следует ли сохранить изменения, но у пользователя также есть возможность отменить операцию закрытия. Отмена, обрабатываемая в onRejected, является самым простым случаем, так как мы отклонили закрытие при открытии диалога.

Когда пользователь не хочет сохранять изменения, т.е. в onNoClicked, флаг isDirty устанавливается в false, и окно снова закрывается. На этот раз onClosing примет закрытие, так как isDirty имеет значение false.

Наконец, когда пользователь хочет сохранить изменения, мы устанавливаем для флага tryToClose значение 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: {
            // Попытаться сохранить документ
            root.tryingToClose = true;
            root.saveDocument();
        }
        onNoClicked: {
            // Закрыть окно
            root.isDirty = false;
            root.close()
        }
        onRejected: {
            // Ничего не делать, прервать закрытие окна
        }
    }
}

Весь процесс для закрытия и логики «сохранить / сохранить как» показан ниже. Входная точка данного алгоритма – «закрыть», выходные точки – «закрыто» и «не закрыто».

Это выглядит сложнее по сравнению с реализацией этой же логики с помощью Qt Widgets и C++. Это связано с тем, что диалоги не блокируют QML. Это означает, что мы не можем ждать результата диалога в операторе switch. Вместо этого нам нужно запомнить состояние и продолжить операцию в соответствующих обработчиках onYesClicked, onNoClicked, onAccepted и onRejected.

логика закрытия окна

Последняя часть пазла – это заголовок окна. Он состоит из свойств fileName и isDirty.

ApplicationWindow {

    // ...

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

    // ...

}

Этот пример далеко не полный. Например, в нем не показаны загрузка и сохранение документа. Еще одна недостающая часть – обработка случая закрытия всех окон за один раз, т.е. выхода из приложения. Для этой функции необходим синглтон, удерживающий список всех текущих экземпляров DocumentWindow. Однако это был бы только другой способ инициировать закрытие окна, поэтому показанная здесь логическая последовательность по-прежнему корректна.

Теги

GUI / Графический интерфейс пользователяQMLQtQtQuickQtQuick ControlsStackViewSwipeViewПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.