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.

Common Patterns - 图1

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.

Common Patterns - 图2

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.

  1. import QtQuick
  2. import QtQuick.Controls
  3. ApplicationWindow {
  4. // ...
  5. header: ToolBar {
  6. // ...
  7. }
  8. Drawer {
  9. // ...
  10. }
  11. StackView {
  12. id: stackView
  13. anchors.fill: parent
  14. initialItem: Home {}
  15. }
  16. }

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.

  1. import QtQuick
  2. import QtQuick.Controls
  3. Page {
  4. title: qsTr("Home")
  5. Label {
  6. anchors.centerIn: parent
  7. text: qsTr("Home Screen")
  8. }
  9. }

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.

  1. ApplicationWindow {
  2. // ...
  3. Drawer {
  4. id: drawer
  5. width: window.width * 0.66
  6. height: window.height
  7. Column {
  8. anchors.fill: parent
  9. ItemDelegate {
  10. text: qsTr("Profile")
  11. width: parent.width
  12. onClicked: {
  13. stackView.push("Profile.qml")
  14. drawer.close()
  15. }
  16. }
  17. ItemDelegate {
  18. text: qsTr("About")
  19. width: parent.width
  20. onClicked: {
  21. stackView.push(aboutPage)
  22. drawer.close()
  23. }
  24. }
  25. }
  26. }
  27. // ...
  28. Component {
  29. id: aboutPage
  30. About {}
  31. }
  32. // ...
  33. }

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.

  1. ApplicationWindow {
  2. // ...
  3. header: ToolBar {
  4. contentHeight: toolButton.implicitHeight
  5. ToolButton {
  6. id: toolButton
  7. text: stackView.depth > 1 ? "\u25C0" : "\u2630"
  8. font.pixelSize: Qt.application.font.pixelSize * 1.6
  9. onClicked: {
  10. if (stackView.depth > 1) {
  11. stackView.pop()
  12. } else {
  13. drawer.open()
  14. }
  15. }
  16. }
  17. Label {
  18. text: stackView.currentItem.title
  19. anchors.centerIn: parent
  20. }
  21. }
  22. // ...
  23. }

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.

Common Patterns - 图3

  1. import QtQuick
  2. import QtQuick.Controls
  3. Page {
  4. title: qsTr("Profile")
  5. Column {
  6. anchors.centerIn: parent
  7. spacing: 10
  8. Label {
  9. anchors.horizontalCenter: parent.horizontalCenter
  10. text: qsTr("Profile")
  11. }
  12. Button {
  13. anchors.horizontalCenter: parent.horizontalCenter
  14. text: qsTr("Edit");
  15. onClicked: stackView.push("EditProfile.qml")
  16. }
  17. }
  18. }
  1. import QtQuick
  2. import QtQuick.Controls
  3. Page {
  4. title: qsTr("Profile")
  5. Column {
  6. anchors.centerIn: parent
  7. spacing: 10
  8. Label {
  9. anchors.horizontalCenter: parent.horizontalCenter
  10. text: qsTr("Profile")
  11. }
  12. Button {
  13. anchors.horizontalCenter: parent.horizontalCenter
  14. text: qsTr("Edit");
  15. onClicked: stackView.push("EditProfile.qml")
  16. }
  17. }
  18. }

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.

Common Patterns - 图4

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.

Common Patterns - 图5

Diving into main.qml, it consists of an ApplicationWindow with the SwipeView.

  1. import QtQuick
  2. import QtQuick.Controls
  3. ApplicationWindow {
  4. visible: true
  5. width: 640
  6. height: 480
  7. title: qsTr("Side-by-side")
  8. SwipeView {
  9. // ...
  10. }
  11. // ...
  12. }

Inside the SwipeView each of the child pages are instantiated in the order they are to appear. They are Current, UserStats and TotalStats.

  1. ApplicationWindow {
  2. // ...
  3. SwipeView {
  4. id: swipeView
  5. anchors.fill: parent
  6. Current {
  7. }
  8. UserStats {
  9. }
  10. TotalStats {
  11. }
  12. }
  13. // ...
  14. }

Finally, the count and currentIndex properties of the SwipeView are bound to the PageIndicator element. This completes the structure around the pages.

  1. ApplicationWindow {
  2. // ...
  3. SwipeView {
  4. id: swipeView
  5. // ...
  6. }
  7. PageIndicator {
  8. anchors.bottom: parent.bottom
  9. anchors.horizontalCenter: parent.horizontalCenter
  10. currentIndex: swipeView.currentIndex
  11. count: swipeView.count
  12. }
  13. }

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.

  1. import QtQuick
  2. import QtQuick.Controls
  3. Page {
  4. header: Label {
  5. text: qsTr("Community Stats")
  6. font.pixelSize: Qt.application.font.pixelSize * 2
  7. padding: 10
  8. }
  9. // ...
  10. }

Common Patterns - 图6

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.

  1. Page {
  2. // ...
  3. Column {
  4. anchors.centerIn: parent
  5. spacing: 10
  6. Label {
  7. anchors.horizontalCenter: parent.horizontalCenter
  8. text: qsTr("Community statistics")
  9. }
  10. Button {
  11. anchors.horizontalCenter: parent.horizontalCenter
  12. text: qsTr("Back")
  13. onClicked: swipeView.setCurrentIndex(0);
  14. }
  15. }
  16. }

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.

Two document windows and the close warning dialog.%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.

  1. import QtQuick
  2. import QtQuick.Controls
  3. import Qt.labs.platform as NativeDialogs
  4. ApplicationWindow {
  5. id: root
  6. // ...
  7. menuBar: MenuBar {
  8. Menu {
  9. title: qsTr("&File")
  10. MenuItem {
  11. text: qsTr("&New")
  12. icon.name: "document-new"
  13. onTriggered: root.newDocument()
  14. }
  15. MenuSeparator {}
  16. MenuItem {
  17. text: qsTr("&Open")
  18. icon.name: "document-open"
  19. onTriggered: openDocument()
  20. }
  21. MenuItem {
  22. text: qsTr("&Save")
  23. icon.name: "document-save"
  24. onTriggered: saveDocument()
  25. }
  26. MenuItem {
  27. text: qsTr("Save &As...")
  28. icon.name: "document-save-as"
  29. onTriggered: saveAsDocument()
  30. }
  31. }
  32. }
  33. // ...
  34. }

To bootstrap the program, we create the first DocumentWindow instance from main.qml, which is the entry point of the application.

  1. import QtQuick
  2. DocumentWindow {
  3. visible: true
  4. }
  1. import QtQuick
  2. DocumentWindow {
  3. visible: true
  4. }

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.

  1. ApplicationWindow {
  2. // ...
  3. function createNewDocument()
  4. {
  5. var component = Qt.createComponent("DocumentWindow.qml");
  6. var window = component.createObject();
  7. return window;
  8. }
  9. function newDocument()
  10. {
  11. var window = createNewDocument();
  12. window.show();
  13. }
  14. // ...
  15. }

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.

  1. ApplicationWindow {
  2. // ...
  3. function openDocument(fileName)
  4. {
  5. openDialog.open();
  6. }
  7. NativeDialogs.FileDialog {
  8. id: openDialog
  9. title: "Open"
  10. folder: NativeDialogs.StandardPaths.writableLocation(NativeDialogs.StandardPaths.DocumentsLocation)
  11. onAccepted: {
  12. var window = root.createNewDocument();
  13. window.fileName = openDialog.file;
  14. window.show();
  15. }
  16. }
  17. // ...
  18. }

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.

  1. ApplicationWindow {
  2. // ...
  3. property bool isDirty: true // Has the document got unsaved changes?
  4. property string fileName // The filename of the document
  5. property bool tryingToClose: false // Is the window trying to close (but needs a file name first)?
  6. // ...
  7. function saveAsDocument()
  8. {
  9. saveAsDialog.open();
  10. }
  11. function saveDocument()
  12. {
  13. if (fileName.length === 0)
  14. {
  15. root.saveAsDocument();
  16. }
  17. else
  18. {
  19. // Save document here
  20. console.log("Saving document")
  21. root.isDirty = false;
  22. if (root.tryingToClose)
  23. root.close();
  24. }
  25. }
  26. NativeDialogs.FileDialog {
  27. id: saveAsDialog
  28. title: "Save As"
  29. folder: NativeDialogs.StandardPaths.writableLocation(NativeDialogs.StandardPaths.DocumentsLocation)
  30. onAccepted: {
  31. root.fileName = saveAsDialog.file
  32. saveDocument();
  33. }
  34. onRejected: {
  35. root.tryingToClose = false;
  36. }
  37. }
  38. // ...
  39. }

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.

  1. ApplicationWindow {
  2. // ...
  3. onClosing: {
  4. if (root.isDirty) {
  5. closeWarningDialog.open();
  6. close.accepted = false;
  7. }
  8. }
  9. NativeDialogs.MessageDialog {
  10. id: closeWarningDialog
  11. title: "Closing document"
  12. text: "You have unsaved changed. Do you want to save your changes?"
  13. buttons: NativeDialogs.MessageDialog.Yes | NativeDialogs.MessageDialog.No | NativeDialogs.MessageDialog.Cancel
  14. onYesClicked: {
  15. // Attempt to save the document
  16. root.tryingToClose = true;
  17. root.saveDocument();
  18. }
  19. onNoClicked: {
  20. // Close the window
  21. root.isDirty = false;
  22. root.close()
  23. }
  24. onRejected: {
  25. // Do nothing, aborting the closing of the window
  26. }
  27. }
  28. }

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.

Common Patterns - 图8

The final piece of the puzzle is the window title. It is composed from the fileName and isDirty properties.

  1. ApplicationWindow {
  2. // ...
  3. title: (fileName.length===0?qsTr("Document"):fileName) + (isDirty?"*":"")
  4. // ...
  5. }

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.