Common Patterns
There a number of common user interface patterns that can be implemented using Qt Quick Controls. In this section, we try to demonstrate how some of the more common ones can be built.
Nested Screens
For this example we will create a tree of pages that can be reached from the previous level of screens. The structure is pictured below.
The key component in this type of user interface is the StackView
. It allows us to place pages on a stack which then can be popped when the user wants to go back. In the example here, we will show how this can be implemented.
The initial home screen of the application is shown in the figure below.
The application starts in main.qml
, where we have an ApplicationWindow
containing a ToolBar
, a Drawer
, a StackView
and a home page element, Home
. We will look into each of the components below.
import QtQuick
import QtQuick.Controls
ApplicationWindow {
// ...
header: ToolBar {
// ...
}
Drawer {
// ...
}
StackView {
id: stackView
anchors.fill: parent
initialItem: Home {}
}
}
The home page, Home.qml
consists of a Page
, which is n control element that support headers and footers. In this example we simply center a Label
with the text Home Screen on the page. This works because the contents of a StackView
automatically fill the stack view, so the page will have the right size for this to work.
import QtQuick
import QtQuick.Controls
Page {
title: qsTr("Home")
Label {
anchors.centerIn: parent
text: qsTr("Home Screen")
}
}
Returning back to main.qml
, we now look at the drawer part. This is where the navigation to the pages begin. The active parts of the user interface are the ÌtemDelegate
items. In the onClicked
handler, the next page is pushed onto the stackView
.
As shown in the code below, it possible to push either a Component
or a reference to a specific QML file. Either way results in a new instance being created and pushed onto the stack.
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 {}
}
// ...
}
The other half of the puzzle is the toolbar. The idea is that a back button is shown when the stackView
contains more than one page, otherwise a menu button is shown. The logic for this can be seen in the text
property where the "\\u..."
strings represents the unicode symbols that we need.
In the onClicked
handler, we can see that when there is more than one page on the stack, the stack is popped, i.e. the top page is removed. If the stack contains only one item, i.e. the home screen, the drawer is opened.
Below the ToolBar
, there is a Label
. This element shows the title of each page in the center of the header.
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
}
}
// ...
}
Now we’ve seen how to reach the About and Profile pages, but we also want to make it possible to reach the Edit Profile page from the Profile page. This is done via the Button
on the Profile page. When the button is clicked, the EditProfile.qml
file is pushed onto the 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")
}
}
}
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")
}
}
}
Side by Side Screens
For this example we create a user interface consisting of three pages that the user can shift through. The pages are shown in the diagram below. This could be the interface of a health tracking app, tracking the current state, the user’s statistics and the overall statistics.
The illustration below shows how the Current page looks in the application. The main part of the screen is managed by a SwipeView
, which is what enables the side by side screen interaction pattern. The title and text shown in the figure come from the page inside the SwipeView
, while the PageIndicator
(the three dots at the bottom) comes from main.qml
and sits under the SwipeView
. The page indicator shows the user which page is currently active, which helps when navigating.
Diving into main.qml
, it consists of an ApplicationWindow
with the SwipeView
.
import QtQuick
import QtQuick.Controls
ApplicationWindow {
visible: true
width: 640
height: 480
title: qsTr("Side-by-side")
SwipeView {
// ...
}
// ...
}
Inside the SwipeView
each of the child pages are instantiated in the order they are to appear. They are Current
, UserStats
and TotalStats
.
ApplicationWindow {
// ...
SwipeView {
id: swipeView
anchors.fill: parent
Current {
}
UserStats {
}
TotalStats {
}
}
// ...
}
Finally, the count
and currentIndex
properties of the SwipeView
are bound to the PageIndicator
element. This completes the structure around the pages.
ApplicationWindow {
// ...
SwipeView {
id: swipeView
// ...
}
PageIndicator {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentIndex: swipeView.currentIndex
count: swipeView.count
}
}
Each page consists of a Page
with a header
consisting of a Label
and some contents. For the Current and User Stats pages the contents consist of a simple Label
, but for the Community Stats page, a back button is included.
import QtQuick
import QtQuick.Controls
Page {
header: Label {
text: qsTr("Community Stats")
font.pixelSize: Qt.application.font.pixelSize * 2
padding: 10
}
// ...
}
The back button explicitly calls the setCurrentIndex
of the SwipeView
to set the index to zero, returning the user directly to the Current page. During each transition between pages the SwipeView
provides a transition, so even when explicitly changing the index the user is given a sense of direction.
TIP
When navigating in a SwipeView
programatically it is important not to set the currentIndex
by assignment in JavaScript. This is because doing so will break any QML bindings it overrides. Instead use the methods setCurrentIndex
, incrementCurrentIndex
, and decrementCurrentIndex
. This preserves the QML bindings.
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);
}
}
}
Document Windows
This example shows how to implement a desktop-oriented, document-centric user interface. The idea is to have one window per document. When opening a new document, a new window is opened. To the user, each window is a self contained world with a single document.
%20}})
The code starts from an ApplicationWindow
with a File menu with the standard operations: New, Open, Save and Save As. We put this in the DocumentWindow.qml
.
We import Qt.labs.platform
for native dialogs, and have made the subsequent changes to the project file and main.cpp
as described in the section on native dialogs above.
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()
}
}
}
// ...
}
To bootstrap the program, we create the first DocumentWindow
instance from main.qml
, which is the entry point of the application.
import QtQuick
DocumentWindow {
visible: true
}
import QtQuick
DocumentWindow {
visible: true
}
In the example at the beginning of this chapter, each MenuItem
calls a corresponding function when triggered. Let’s start with the New item, which calls the newDocument
function.
The function, in turn, relies on the createNewDocument
function, which dynamically creates a new element instance from the DocumentWindow.qml
file, i.e. a new DocumentWindow
instance. The reason for breaking out this part of the new function is that we use it when opening documents as well.
Notice that we do not provide a parent element when creating the new instance using createObject
. This way, we create new top level elements. If we would have provided the current document as parent to the next, the destruction of the parent window would lead to the destruction of the child windows.
ApplicationWindow {
// ...
function createNewDocument()
{
var component = Qt.createComponent("DocumentWindow.qml");
var window = component.createObject();
return window;
}
function newDocument()
{
var window = createNewDocument();
window.show();
}
// ...
}
Looking at the Open item, we see that it calls the openDocument
function. The function simply opens the openDialog
, which lets the user pick a file to open. As we don’t have a document format, file extension or anything like that, the dialog has most properties set to their default value. In a real world application, this would be better configured.
In the onAccepted
handler a new document window is instantiated using the createNewDocument
method, and a file name is set before the window is shown. In this case, no real loading takes place.
TIP
We imported the Qt.labs.platform
module as NativeDialogs
. This is because it provides a MenuItem
that clashes with the MenuItem
provided by the QtQuick.Controls
module.
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();
}
}
// ...
}
The file name belongs to a pair of properties describing the document: fileName
and isDirty
. The fileName
holds the file name of the document name and isDirty
is set when the document has unsaved changes. This is used by the save and save as logic, which is shown below.
When trying to save a document without a name, the saveAsDocument
is invoked. This results in a round-trip over the saveAsDialog
, which sets a file name and then tries to save again in the onAccepted
handler.
Notice that the saveAsDocument
and saveDocument
functions correspond to the Save As and Save menu items.
After having saved the document, in the saveDocument
function, the tryingToClose
property is checked. This flag is set if the save is the result of the user wanting to save a document when the window is being closed. As a consequence, the window is closed after the save operation has been performed. Again, no actual saving takes place in this example.
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;
}
}
// ...
}
This leads us to the closing of windows. When a window is being closed, the onClosing
handler is invoked. Here, the code can choose not to accept the request to close. If the document has unsaved changes, we open the closeWarningDialog
and reject the request to close.
The closeWarningDialog
asks the user if the changes should be saved, but the user also has the option to cancel the close operation. The cancelling, handled in onRejected
, is the easiest case, as we rejected the closing when the dialog was opened.
When the user does not want to save the changes, i.e. in onNoClicked
, the isDirty
flag is set to false
and the window is closed again. This time around, the onClosing
will accept the closure, as isDirty
is false.
Finally, when the user wants to save the changes, we set the tryingToClose
flag to true before calling save. This leads us to the save/save as logic.
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
}
}
}
The entire flow for the close and save/save as logic is shown below. The system is entered at the close state, while the closed and not closed states are outcomes.
This looks complicated compared to implementing this using Qt Widgets
and C++. This is because the dialogs are not blocking to QML. This means that we cannot wait for the outcome of a dialog in a switch
statement. Instead we need to remember the state and continue the operation in the respective onYesClicked
, onNoClicked
, onAccepted
, and onRejected
handlers.
The final piece of the puzzle is the window title. It is composed from the fileName
and isDirty
properties.
ApplicationWindow {
// ...
title: (fileName.length===0?qsTr("Document"):fileName) + (isDirty?"*":"")
// ...
}
This example is far from complete. For instance, the document is never loaded or saved. Another missing piece is handling the case of closing all the windows in one go, i.e. exiting the application. For this function, a singleton maintaining a list of all current DocumentWindow
instances is needed. However, this would only be another way to trigger the closing of a window, so the logic flow shown here is still valid.