Navigation
Lets make a quick addition to our SplashView:
Rectangle { anchors.fill: parent color: "#f4c842" Text { anchors.centerIn: parent text: "Splash View" } }
This just adds the name of the view to the screen, so when we start moving between views, we know which one we are looking at. With that done, copy the content of SplashView into all the other new views, updating the text in each to reflect the name of the view, for example, in DashboardView, the text could say “Dashboard View”.
The first piece of navigation we want to do is when the MasterView has finished loading and we’re ready for action, load the DashboardView. We achieve this using one of the QML component slots we’ve just seen—Component.onCompleted().
Add the following line to the root Window component in MasterView:
Component.onCompleted: contentFrame.replace("qrc:/views/DashboardView.qml");
Now when you build and run, as soon as the MasterView has finished loading, it switches the child view to DashboardView. This probably happens so fast that you no longer even see SplashView, but it is still there. Having a splash view like this is great if you’ve got an application with quite a lot of initialization to do, and you can’t really have non-blocking UI. It’s a handy place to put the company logo and a “Reticulating splines...” loading message. Yes, that was a Sims reference!
The StackView is just like the history in your web browser. If you visit www.google.com and then www.packtpub.com, you are pushing www.packtpub.com onto the stack. If you click on Back on the browser, you return to www.google.com. This history can consist of several pages (or views), and you can navigate backward and forward through them. Sometimes you don't need the history and sometimes you actively don't want users to be able to go back. The replace() method we called, as its name suggests, pushes a new view onto the stack and clears any history so that you can't go back.
In the Component.onCompleted slot, we've seen an example of how to navigate between views directly from QML. We can use this approach for all of our application navigation. For example, we can add a button for the user to create a new client and when it’s clicked on, push the CreateClientView straight on to the stack, as follows:
Button { onClicked: contentFrame.replace("qrc:/views/CreateClientView.qml") }
For UX designs or simple UI heavy applications with little business logic, this is a perfectly valid approach. The trouble is that your QML views and components become very tightly coupled, and the business logic layer has no visibility of what the user is doing. Quite often, moving to a new screen of the application isn’t as simple as just displaying a new view. You may need to update a state machine, set some models up, or clear out some data from the previous view. By routing all of our navigation requests through our MasterController switchboard, we decouple our components and gain an intercept point for our business logic to take any actions it needs to as well as validate that the requests are appropriate.
We will request navigation to these views by emitting signals from our business logic layer and having our MasterView respond to them and perform the transition. Rather than cluttering up our MasterController, we’ll delegate the responsibility for navigation to a new controller in cm-lib, so create a new header file (there is no implementation as such, so we don’t need a .cpp file) called navigation-controller.h in cm/cm-lib/source/controllers and add the following code:
#ifndef NAVIGATIONCONTROLLER_H #define NAVIGATIONCONTROLLER_H
#include <QObject>
#include <cm-lib_global.h> #include <models/client.h> namespace cm { namespace controllers {
class CMLIBSHARED_EXPORT NavigationController : public QObject { Q_OBJECT
public: explicit NavigationController(QObject* _parent = nullptr) : QObject(_parent) {}
signals: void goCreateClientView(); void goDashboardView(); void goEditClientView(cm::models::Client* client); void goFindClientView(); };
} } #endif
We have created a minimal class that inherits from QObject and implements a signal for each of our new views. Note that we don’t need to navigate to the MasterView or the SplashView, so there is no corresponding signal for those. When we navigate to the EditClientView, we will need to inform the UI which Client we want to edit, so we will pass it through as a parameter. Calling one of these methods from anywhere within our business logic code fires a request into the ether saying “I want to go to the so-and-so view, please”. It is then up to the MasterView over in the UI layer to monitor those requests and respond accordingly. Note that the business logic layer still knows nothing about the UI implementation. It's fine if nobody responds to the signal; it is not a two-way communication.
We've looked forward a little bit here and assumed that our Client class will be in the cm::models namespace, but the default Client class that Qt added for us when we created the project is not, so let's fix that before we move on:
client.h:
#ifndef CLIENT_H #define CLIENT_H
#include "cm-lib_global.h"
namespace cm { namespace models {
class CMLIBSHARED_EXPORT Client { public: Client(); };
}}
#endif
client.cpp:
#include "client.h"
namespace cm { namespace models {
Client::Client() { }
}}
We need to be able to create an instance of a NavigationController and have our UI interact with it. For unit testing reasons, it is good practice to hide object creation behind some sort of object factory interface, but we’re not concerned with that at this stage, so we'll simply create the object in MasterController. Let’s take this opportunity to add the Private Implementation (PImpl) idiom to our MasterController too. If you haven't come across PImpl before, it is simply a technique to move all private implementation details out of the header file and into the definition. This helps keep the header file as short and clean as possible, with only the includes necessary for consumers of the public API. Replace the declaration and implementation as follows:
master-controller.h:
#ifndef MASTERCONTROLLER_H #define MASTERCONTROLLER_H
#include <QObject> #include <QScopedPointer> #include <QString>
#include <cm-lib_global.h> #include <controllers/navigation-controller.h>
namespace cm { namespace controllers {
class CMLIBSHARED_EXPORT MasterController : public QObject { Q_OBJECT Q_PROPERTY( QString ui_welcomeMessage READ welcomeMessage CONSTANT ) Q_PROPERTY( cm::controllers::NavigationController* ui_navigationController READ navigationController CONSTANT )
public: explicit MasterController(QObject* parent = nullptr); ~MasterController();
NavigationController* navigationController(); const QString& welcomeMessage() const;
private: class Implementation; QScopedPointer<Implementation> implementation; };
}} #endif
master-controller.cpp:
#include "master-controller.h"
namespace cm { namespace controllers {
class MasterController::Implementation { public: Implementation(MasterController* _masterController) : masterController(_masterController) { navigationController = new NavigationController(masterController); }
MasterController* masterController{nullptr}; NavigationController* navigationController{nullptr}; QString welcomeMessage = "This is MasterController to Major Tom"; };
MasterController::MasterController(QObject* parent) : QObject(parent) { implementation.reset(new Implementation(this)); }
MasterController::~MasterController() { }
NavigationController* MasterController::navigationController() { return implementation->navigationController; }
const QString& MasterController::welcomeMessage() const { return implementation->welcomeMessage; }
}}
Next, we need to register the new NavigationController class with the QML system in the cm-ui project, so in main.cpp, add the following registration next to the existing one for MasterController:
qmlRegisterType<cm::controllers::NavigationController>("CM", 1, 0, "NavigationController");
We’re now ready to wire up MasterView to react to these navigation signals. Add the following element before the StackView:
Connections { target: masterController.ui_navigationController onGoCreateClientView: contentFrame.replace("qrc:/views/CreateClientView.qml") onGoDashboardView: contentFrame.replace("qrc:/views/DashboardView.qml") onGoEditClientView: contentFrame.replace("qrc:/views/EditClientView.qml", {selectedClient: client}) onGoFindClientView: contentFrame.replace("qrc:/views/FindClientView.qml") }
We are creating a connection component bound to our new instance of NavigationController, which reacts to each of the go signals we added and navigates to the relevant view via the contentFrame, using the same replace() method we used previously to move to the Dashboard. So whenever the goCreateClientView() signal gets fired on the NavigationController, the onGoCreateClientView() slot gets called on our Connections component and the CreateClientView is loaded into the StackView named contentFrame. In the case of onGoEditClientView where a client parameter is passed from the signal, we pass that object along to a property named selectedClient, which we will add to the view later.
Next, let’s add a navigation bar to MasterView with some placeholder buttons so that we can try these signals out.
Add a Rectangle to form the background for our bar:
Rectangle { id: navigationBar anchors { top: parent.top bottom: parent.bottom left: parent.left } width: 100 color: "#000000" }
This draws a black strip 100 pixels wide anchored to the left-hand side of the view.
We also need to adjust our StackView so that it allows some space for our bar. Rather than filling its parent, let’s anchor three of its four sides to its parent, but attach the left-hand side to the right-hand side of our bar:
StackView { id: contentFrame anchors { top: parent.top bottom: parent.bottom right: parent.right left: navigationBar.right } initialItem: Qt.resolvedUrl("qrc:/views/SplashView.qml") }
Now, let’s add some buttons to our navigation Rectangle:
Rectangle { id: navigationBar … Column { Button { text: "Dashboard" onClicked: masterController.ui_navigationController.goDashboardView() } Button { text: "New Client" onClicked: masterController.ui_navigationController.goCreateClientView() } Button { text: "Find Client" onClicked: masterController.ui_navigationController.goFindClientView() } } }
We use the Column component to lay out our buttons for us, rather than having to individually anchor the buttons to each other. Each button displays some text and when clicked on, calls a signal on the NavigationController. Our Connection component reacts to the signals and performs the view transition for us:
Great stuff, we have a functional navigation framework! However, when you click on one of the navigation buttons, the navigation bar disappears momentarily and comes back again. We are also getting “conflicting anchors” messages in our Application Output console, which suggest that we’re doing something that’s not quite right. Let’s address those issues before we move on.