End to End GUI Development with Qt5
上QQ阅读APP看书,第一时间看更新

Enumerator selectors

Back in Chapter 5, Data, we created a Contact model where we implemented a ContactType property with an EnumeratorDecorator. For the other string-based properties we’ve worked with in the book, a simple textbox is a fine solution for capturing data, but how can we capture an enumerated value? The user can’t be expected to know the underlying integer values of the enumerator, and asking them to type in a string representation of the option they want is asking for trouble. What we really want is a drop-down list that somehow utilizes the contactTypeMapper container we added to the class. We’d like to present the string descriptions to the user to pick from but then store the integer value in the EnumeratorDecorator object.

Desktop applications generally present drop-down lists in a particular way, with some kind of selector you press that then pops out (or more accurately, drops down!) a scrollable list of options to choose from. However, QML is geared toward not only cross-platform, but cross-device applications, too. Many laptops have touch capable screens, and more and more hybrid devices are appearing in the market that act as both laptops and tablets. As such, it’s important to consider how “finger friendly” our application is, even if we’re not planning on building the next big thing for the mobile stores, and the classic drop-down list can be difficult to work with on a touch screen. Let’s instead use a button-based approach as used on mobile devices.

Unfortunately, we can’t really work directly with our existing std::map in QML, so we will need to add a few new classes to bridge the gap for us. We’ll represent each key/value pair as a DropDownValue and hold a collection of these objects in a DropDown object. A DropDown object should take a std::map<int, QString> in its constructor and create the DropDownValue collection for us.

Create the DropDownValue class first in cm-lib/source/data.

dropdown-value.h:

#ifndef DROPDOWNVALUE_H
#define DROPDOWNVALUE_H
#include <QObject> #include <cm-lib_global.h>
namespace cm { namespace data {
class CMLIBSHARED_EXPORT DropDownValue : public QObject { Q_OBJECT Q_PROPERTY(int ui_key MEMBER key CONSTANT ) Q_PROPERTY(QString ui_description MEMBER description CONSTANT)
public: DropDownValue(QObject* parent = nullptr, int key = 0, const QString& description = ""); ~DropDownValue();
public: int key{0}; QString description{""}; };
}}
#endif

dropdown-value.cpp:

#include "dropdown-value.h"
namespace cm { namespace data {
DropDownValue::DropDownValue(QObject* parent, int _key, const QString& _description) : QObject(parent) { key = _key; description = _description; }
DropDownValue::~DropDownValue() { }
}}

There's nothing complicated here, it’s just a QML friendly wrapper for an integer value and associated string description.

Next, create the DropDown class, again in cm-lib/source/data.

dropdown.h:

#ifndef DROPDOWN_H
#define DROPDOWN_H
#include <QObject> #include <QtQml/QQmlListProperty>
#include <cm-lib_global.h> #include <data/dropdown-value.h>
namespace cm { namespace data {
class CMLIBSHARED_EXPORT DropDown : public QObject { Q_OBJECT Q_PROPERTY(QQmlListProperty<cm::data::DropDownValue> ui_values READ ui_values CONSTANT)
public: explicit DropDown(QObject* _parent = nullptr, const std::map<int, QString>& values = std::map<int, QString>()); ~DropDown();
public: QQmlListProperty<DropDownValue> ui_values();
public slots: QString findDescriptionForDropdownValue(int valueKey) const;
private: class Implementation; QScopedPointer<Implementation> implementation; };
}}
#endif

dropdown.cpp:

#include "dropdown.h"

namespace cm {
namespace data {
class DropDown::Implementation { public: Implementation(DropDown* _dropdown, const std::map<int, QString>& _values) : dropdown(_dropdown) { for(auto pair : _values) { values.append(new DropDownValue(_dropdown, pair.first, pair.second)); } } DropDown* dropdown{nullptr}; QList<DropDownValue*> values; };
DropDown::DropDown(QObject* parent, const std::map<int, QString>& values) : QObject(parent) { implementation.reset(new DropDown::Implementation(this, values)); }
DropDown::~DropDown() { }
QString DropDown::findDescriptionForDropdownValue(int valueKey) const { for (auto value : implementation->values) { if (value->key == valueKey) { if(!value->description.isEmpty()) { return value->description; } break; } }
return "Select >"; }
QQmlListProperty<DropDownValue> DropDown::ui_values() { return QQmlListProperty<DropDownValue>(this, implementation->values); }
}}

As discussed, we implement a constructor that takes the same kind of std::map that we use in our EnumeratorDecorator class and create a collection of DropDownValue objects based on it. The UI can then access that collection via the ui_values property. The other capability we provide for the UI is via the findDescriptionForDropdownValue public slot, and this allows the UI to take a selected integer value from an EnumeratorDecorator and get the corresponding text description. If there is no current selection (that is, the description is an empty string), then we will return Select > to denote to the user that they need to make a selection.

As we will use these new types in QML, we need to register them in main.cpp:

qmlRegisterType<cm::data::DropDown>("CM", 1, 0, "DropDown");
qmlRegisterType<cm::data::DropDownValue>("CM", 1, 0, "DropDownValue");

Add a new DropDown property to the Contact named ui_contactTypeDropDown and in the constructor, instantiate the member variable with the contactTypeMapper. Now, whenever a Contact is presented in the UI, the associated DropDown will be available. This can quite easily go into a dedicated component like a drop-down manager instead, if you wanted to reuse drop-downs throughout the application, but for this example, let’s avoid the additional complexity.

We will also need to be able to add a new contact object from the UI, so add a new public slot to Client:

void Client::addContact()
{
    contacts->addEntity(new Contact(this));
    emit contactsChanged();
}

With the C++ done, we can move on to the UI implementation.

We will need a couple of components for the dropdown selection. When presenting an EnumeratorDecorator property, we want to display the currently selected value, just as we do with our string editor. Visually, it will resemble a button with the associated string description as its label and when pressed, the user will be transitioned to the second component that is essentially a view. This subview will take up the whole of the content frame and present a list of all the available enumerated options, again represented as buttons. When the user makes their selection by pressing one of the buttons, they will be transitioned back to the original view, and their selection will be updated in the original component.

First, we’ll create the view the user will transition to, which will list all the available options. To support this, we need a few additional properties in Style:

readonly property color colourDataSelectorBackground: "#131313"
readonly property color colourDataControlsBackgroundSelected: "#f36f24"
readonly property color colourDataSelectorFont: "#ffffff"
readonly property int sizeDataControlsRadius: tscale(5)

Create EnumeratorSelectorView.qml in cm-ui/components:

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
Item { id: stringSelectorView property DropDown dropDown property EnumeratorDecorator enumeratorDecorator property int selectedValue
ScrollView { id: scrollView visible: true anchors.fill: parent anchors { top: parent.bottom left: parent.left right: parent.right bottom: parent.top margins: Style.sizeScreenMargin }
Flow { flow: Grid.TopToBottom spacing: Style.sizeControlSpacing height: scrollView.height
Repeater { id: repeaterAnswers model: dropDown.ui_values delegate: Rectangle { property bool isSelected: modelData.ui_key.ui_value === enumeratorDecorator.ui_value width: Style.widthDataControls height: Style.heightDataControls radius: Style.sizeDataControlsRadius color: isSelected ? Style.colourDataControlsBackgroundSelected : Style.colourDataSelectorBackground
Text { anchors { fill: parent margins: Style.heightDataControls / 4 } text: modelData.ui_description color: Style.colourDataSelectorFont font.pixelSize: Style.pixelSizeDataControls verticalAlignment: Qt.AlignVCenter }
MouseArea { anchors.fill: parent onClicked: { selectedValue = modelData.ui_key; contentFrame.pop(); } } } } } }
Binding { target: enumeratorDecorator property: "ui_value" value: selectedValue } }

Here, we use a Repeater element for the first time. A Repeater instantiates the QML element defined in its delegate property for each item it finds in its model property. We pass it the collection of DropDownValue objects as its model and create a delegate inline. The delegate is essentially another button with some selection code. We can create a new custom component and use that for the delegate instead to keep the code cleaner, but we’ll skip that here for brevity. The key parts of this component are the Binding element that gives us the two-way binding to the supplied EnumeratorDecorator, and the onClicked event delegate in the MouseArea, which performs the update and pops this component off the stack, returning us to whichever view we came from.

Create a new EnumeratorSelector.qml in cm-ui/components:

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
Item { property DropDown dropDown property EnumeratorDecorator enumeratorDecorator id: enumeratorSelectorRoot height: width > textLabel.width + textAnswer.width ?
Style.heightDataControls : Style.heightDataControls * 2
Flow { anchors.fill: parent
Rectangle { width: Style.widthDataControls height: Style.heightDataControls Text { id: textLabel anchors { fill: parent margins: Style.heightDataControls / 4 } text: enumeratorDecorator.ui_label color: Style.colourDataControlsFont font.pixelSize: Style.pixelSizeDataControls verticalAlignment: Qt.AlignVCenter } }
Rectangle { id: buttonAnswer width: Style.widthDataControls height: Style.heightDataControls radius: Style.sizeDataControlsRadius enabled: dropDown ? dropDown.ui_values.length > 0 : false color: Style.colourDataSelectorBackground
Text { id: textAnswer anchors { fill: parent margins: Style.heightDataControls / 4 } text: dropDown.findDescriptionForDropdownValue(enumeratorDecorator.ui_value) color: Style.colourDataSelectorFont font.pixelSize: Style.pixelSizeDataControls verticalAlignment: Qt.AlignVCenter }
MouseArea { anchors.fill: parent onClicked: contentFrame.push("qrc:/components/EnumeratorSelectorView.qml", {dropDown: enumeratorSelectorRoot.dropDown, enumeratorDecorator: enumeratorSelectorRoot.enumeratorDecorator}) } } } }

This component has a lot of similarities to StringEditorSingleLine in its layout, but it replaces the Text element with a button representation. We grab the value from the bound EnumeratorDecorator and pass that to the slot we created on the DropDown class to get the string description for the currently selected value. When the user presses the button, the onClicked event of the MouseArea performs the same kind of view transition we’ve seen in MasterView, taking the user to the new EnumeratorSelectorView.

We’re cheating a bit here in that we are directly referencing the StackView in MasterView by its contentFrame ID. At design time, Qt Creator can’t know what contentFrame is as it is in a totally different file, so it may flag it as an error, and you certainly won’t get auto-completion. At runtime, however, this component will be part of the same QML hierarchy as MasterView, so it will be able to find it. This is a risky approach, because if another element in the hierarchy is also called contentFrame, then bad things may happen. A safer way to do this is to pass contentFrame all the way down through the QML hierarchy from MasterView as a QtObject property.

When we add or edit a Client, we currently ignore contacts and always have an empty collection. Let’s take a look at how we can add objects to a collection and put our shiny new EnumeratorSelector to use while we’re at it.