How to add an extra item to a QML ComboBox which is not in the model?

3.3k views Asked by At

I have a QML ComboBox which has a QAbstractListModel attached to it. Something like this:

ComboBox {
    model: customListModel
}

And I would like it to display an extra item in the drop down list which is not in the model.

For example, let's say there are two items in the customListModel: Apple and Orange. And in the drop down list it should display the following options:

  • Select all
  • Apple
  • Orange

I can't add it to the model because it contains custom objects and I use this model a couple of other places in the program and it would screw up everything.

How can I add this "Select all" option to the ComboBox???

3

There are 3 answers

3
JarMan On BEST ANSWER

One way to do it is to create a proxy model of some sort. Here's a couple ideas:

  1. You could derive your own QAbstractProxyModel that adds the "Select All" item to the data. This is probably the more complex option, but also the more efficient. An example of creating a proxy this way can be found here.

  2. You could also make your proxy in QML. It would look something like this:

Combobox {
    model: ListModel {
        id: proxyModel
        ListElement { modelData: "Select All" }

        Component.onCompleted: {
            for (var i = 0; i < customListModel.count; i++) {
                proxyModel.append(customModel.get(i);
            }
        }
    }
}
0
GrecKo On

A solution is to customize the popup to add a header.

You could implement the entire popup component, or exploit the fact that its contentItem is a ListView and use the header property:

ListModel {
    id: fruitModel
    ListElement {
        name: "Apple"
    }
    ListElement {
        name: "Orange"
    }
}

ComboBox {
    id: comboBox
    model: fruitModel
    textRole: "name"
    Binding {
        target: comboBox.popup.contentItem
        property: "header"
        value: Component {
            ItemDelegate {
                text: "SELECT ALL"
                width: ListView.view.width
                onClicked: doSomething()
            }
        }
    }
}
0
Mitch On

I found myself wanting to do something similar recently and was surprised that there's no simple way to do it; there are ways to do it but not really a dedicated API for it, not even for widgets.

I've tried both of the answers mentioned here and would like to summarise them, as well provide complete examples for each approach. My requirement was to have a "None" entry, so my answer is in that context, but you can easily replace that with "Select All".

Using QSortFilterProxyModel

The C++ code for this is based on this answer by @SvenA (thank you for sharing working code!).

Pros:

  • To avoid repeating myself too much: the pros for this are the absence of the cons in the other approach. For example: key navigation works, no need to touch any styling stuff, etc. These two alone are pretty big reasons why you would want to choose this approach, even if it does mean extra work writing (or copy-pasting :)) the model code (which is something you will only have to do once).

Cons:

  • Since you are using the 0 index for the "None" entry, you have to treat it as a special entry, unlike the -1 index, which is already established as meaning no item is selected. This means a little extra JavaScript code to handle that index being selected, but the header approach also requires this when it's clicked.
  • It is a lot of code for one extra entry, but again; you should only have to do it once, and then you can reuse it.
  • It is an extra level of indirection in terms of model operations. Assuming most ComboBox models are relatively small, this is not a problem. In practice I doubt this would be a bottleneck.
  • Conceptually a "None" entry could be considered a kind of metadata; i.e. it doesn't belong in the model itself, so this solution could be seen as less conceptually correct.

main.qml:

import QtQuick 2.15
import QtQuick.Controls 2.15

import App 1.0

ApplicationWindow {
    width: 640
    height: 480
    visible: true
    title: "\"None\" entry (proxy) currentIndex=" + comboBox.currentIndex + " highlightedIndex=" + comboBox.highlightedIndex

    ComboBox {
        id: comboBox
        textRole: "display"
        model: ProxyModelNoneEntry {
            sourceModel: MyModel {}
        }
    }
}

main.cpp:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QSortFilterProxyModel>
#include <QDebug>

class MyModel : public QAbstractListModel
{
    Q_OBJECT

public:
    explicit MyModel(QObject *parent = nullptr);

    int rowCount(const QModelIndex &parent = QModelIndex()) const override;
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;

private:
    QVector<QString> mData;
};

MyModel::MyModel(QObject *parent) :
    QAbstractListModel(parent)
{
    for (int i = 0; i < 10; ++i)
        mData.append(QString::fromLatin1("Item %1").arg(i + 1));
}

int MyModel::rowCount(const QModelIndex &) const
{
    return mData.size();
}

QVariant MyModel::data(const QModelIndex &index, int role) const
{
    if (!checkIndex(index, CheckIndexOption::IndexIsValid))
        return QVariant();

    switch (role) {
    case Qt::DisplayRole:
        return mData.at(index.row());
    }

    return QVariant();
}

class ProxyModelNoneEntry : public QSortFilterProxyModel
{
    Q_OBJECT

public:
    ProxyModelNoneEntry(QString entryText = tr("(None)"), QObject *parent = nullptr);

    int rowCount(const QModelIndex &parent = QModelIndex()) const override;
    QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override;
    QModelIndex mapToSource(const QModelIndex &proxyIndex) const override;
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
    Qt::ItemFlags flags(const QModelIndex &index) const override;
    QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
    QModelIndex parent(const QModelIndex &child) const override;

private:
    QString mEntryText;
};

ProxyModelNoneEntry::ProxyModelNoneEntry(QString entryText, QObject *parent) :
    QSortFilterProxyModel(parent)
{
    mEntryText = entryText;
}

int ProxyModelNoneEntry::rowCount(const QModelIndex &/*parent*/) const
{
    return QSortFilterProxyModel::rowCount() + 1;
}

QModelIndex ProxyModelNoneEntry::mapFromSource(const QModelIndex &sourceIndex) const
{
    if (!sourceIndex.isValid())
        return QModelIndex();
    else if (sourceIndex.parent().isValid())
        return QModelIndex();
    return createIndex(sourceIndex.row()+1, sourceIndex.column());
}

QModelIndex ProxyModelNoneEntry::mapToSource(const QModelIndex &proxyIndex) const
{
    if (!proxyIndex.isValid())
        return QModelIndex();
    else if (proxyIndex.row() == 0)
        return QModelIndex();
    return sourceModel()->index(proxyIndex.row() - 1, proxyIndex.column());
}

QVariant ProxyModelNoneEntry::data(const QModelIndex &index, int role) const
{
    if (!checkIndex(index, CheckIndexOption::IndexIsValid))
        return QVariant();

    if (index.row() == 0) {
        if (role == Qt::DisplayRole)
            return mEntryText;
        else
            return QVariant();
    }
    return QSortFilterProxyModel::data(createIndex(index.row(),index.column()), role);
}

Qt::ItemFlags ProxyModelNoneEntry::flags(const QModelIndex &index) const
{
    if (!index.isValid())
        return Qt::NoItemFlags;
    if (index.row() == 0)
        return Qt::ItemIsSelectable | Qt::ItemIsEnabled;
    return QSortFilterProxyModel::flags(createIndex(index.row(),index.column()));
}

QModelIndex ProxyModelNoneEntry::index(int row, int column, const QModelIndex &/*parent*/) const
{
    if (row > rowCount())
        return QModelIndex();
    return createIndex(row, column);
}

QModelIndex ProxyModelNoneEntry::parent(const QModelIndex &/*child*/) const
{
    return QModelIndex();
}

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    qmlRegisterType<ProxyModelNoneEntry>("App", 1, 0, "ProxyModelNoneEntry");
    qmlRegisterType<MyModel>("App", 1, 0, "MyModel");
    qmlRegisterAnonymousType<QAbstractItemModel>("App", 1);

    QQmlApplicationEngine engine;
    const QUrl url(QStringLiteral("qrc:/main.qml"));
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
                     &app, [url](QObject *obj, const QUrl &objUrl) {
        if (!obj && url == objUrl)
            QCoreApplication::exit(-1);
    }, Qt::QueuedConnection);
    engine.load(url);

    return app.exec();
}

#include "main.moc"

Using ListView's header

Pros:

  • The -1 index -- which is already established as meaning no item is selected -- can be used to refer to the "None" entry.
  • No need to set up a QSortFilterProxyModel-subclass in C++ and expose it to QML.
  • Conceptually a "None" entry could be considered a kind of metadata; i.e. it doesn't belong in the model itself, so this solution could be seen as more conceptually correct.

Cons:

  • Not possible to select the "None" entry with arrow key navigation. I briefly tried working around this (see commented-out code), but had no success.
  • Have to mimic the "current item" styling that the delegate component has. What that entails depends on the style; if you wrote the style yourself, then you could move the delegate component into its own file and reuse it for the header. However, if you you're using someone else's style, you can't do that, and will have to write it from scratch (though, you will usually only need to do this once). For example, for the Default ("Basic", in Qt 6) style it means:
    • Setting an appropriate font.weight.
    • Setting highlighted.
    • Setting hoverEnabled.
  • Have to set displayText yourself.
  • Since the header item isn't considered a ComboBox item, the highlightedIndex property (which is read-only) will not account for it. Can be worked around by setting highlighted to hovered in the delegate.
  • Have to do the following when the header is clicked:
    • Set currentIndex (i.e. to -1 on click).
    • Close the ComboBox's popup.
    • Emit activated() manually.

main.qml:

import QtQuick 2.0
import QtQuick.Controls 2.0

ApplicationWindow {
    visible: true
    width: 640
    height: 480
    title: "\"None\" entry (header) currentIndex=" + comboBox.currentIndex + " highlightedIndex=" + comboBox.highlightedIndex

    Binding {
        target: comboBox.popup.contentItem
        property: "header"
        value: Component {
            ItemDelegate {
                text: qsTr("None")
                font.weight: comboBox.currentIndex === -1 ? Font.DemiBold : Font.Normal
                palette.text: comboBox.palette.text
                palette.highlightedText: comboBox.palette.highlightedText
                highlighted: hovered
                hoverEnabled: comboBox.hoverEnabled
                width: ListView.view.width
                onClicked: {
                    comboBox.currentIndex = -1
                    comboBox.popup.close()
                    comboBox.activated(-1)
                }
            }
        }
    }

    ComboBox {
        id: comboBox
        model: 10
        displayText: currentIndex === -1 ? qsTr("None") : currentText
        onActivated: print("activated", index)

//        Connections {
//            target: comboBox.popup.contentItem.Keys
//            function onUpPressed(event) { comboBox.currentIndex = comboBox.currentIndex === 0 ? -1 : comboBox.currentIndex - 1 }
//        }
    }
}

Conclusion

I agree with the idea that "None" and "Select All" are more metadata than model data. In that sense I prefer the header approach. In my particular use case that made me look into this, I don't allow key navigation and I have already overridden the delegate property of ComboBox, so I can reuse that code for the header.

However, if you need key navigation, or you don't want to have to reimplement the delegate for the header, the QSortFilterProxyModel approach would be more practical.