如何向不在模型中的 QML ComboBox 添加额外的项目?

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

我有一个 QML ComboBox,它附加了一个 QAbstractListModel。像这样:

ComboBox {
    model: customListModel
}

我希望它在下拉列表中显示模型中没有的额外项目。

例如,假设 customListModel 中有两个项目:Apple 和 Orange。 在下拉列表中,它应该显示以下选项:

我无法将它添加到模型中,因为它包含自定义对象,而且我在程序的其他几个地方使用了这个模型,它会搞砸一切。

如何将这个“Select 全部”选项添加到 ComboBox???

一种方法是创建某种代理模型。这里有一些想法:

  1. 您可以派生自己的 QAbstractProxyModel,将“Select 全部”项添加到数据中。这可能是更复杂的选择,但也更有效。可以找到以这种方式创建代理的示例 here

  2. 您也可以在 QML 中创建代理。它看起来像这样:

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

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

一种解决方案是自定义弹出窗口以添加 header。

您可以实现整个弹出组件,或者利用它的 contentItem 是一个 ListView 这一事实并使用 header 属性:

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()
            }
        }
    }
}

我发现自己最近想做一些类似的事情,但很惊讶没有简单的方法可以做到;有很多方法可以做到这一点,但并不是真正的专用 API,not even for widgets

我已经尝试了这里提到的两个答案,并想对它们进行总结,并为每种方法提供完整的示例。我的要求是有一个“None”条目,所以我的回答是在那个上下文中,但您可以轻松地将其替换为“Select All”。

使用 QSortFilterProxyModel

此 C++ 代码基于@SvenA 的this answer(感谢您分享工作代码!)。

优点:

  • 为了避免重复太多:这样做的优点是没有另一种方法的缺点。例如:按键导航有效,无需触摸任何样式等。仅这两个就是您选择这种方法的重要原因,即使这确实意味着额外的写作工作(或 copy-pasting :) ) 型号代码(您只需要做一次)。

缺点:

  • 由于您正在为“None”条目使用 0 索引,因此您必须将其视为一个特殊条目,不像 -1 索引,后者已经建立这意味着没有项目是 selected。这意味着需要一些额外的 JavaScript 代码来处理 selected 的索引,但是 header 方法在单击时也需要这样做。
  • 一个额外的条目需要很多代码,但是又一次;你应该只需要做一次,然后你就可以重复使用它。
  • 就模型操作而言,这是一个额外的间接级别。假设大多数 ComboBox 模型都比较小,这不是问题。实际上,我怀疑这会是一个瓶颈。
  • 从概念上讲,“None”条目可以被视为一种元数据;即它不属于模型本身,因此该解决方案在概念上可能被视为不太正确。

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"

使用 ListView 的 header

优点:

  • -1 索引 -- 已经确定没有项目被 selected -- 可以用来指代“None”条目。
  • 无需在 C++ 中设置 QSortFilterProxyModel-subclass 并将其公开给 QML。
  • 从概念上讲,“None”条目可以被视为一种元数据;即它不属于模型本身,所以这个解决方案在概念上可以被视为更正确。

缺点:

  • 无法 select 使用箭头键导航的“None”条目。我曾短暂地尝试解决这个问题(参见 commented-out 代码),但没有成功。
  • 必须模仿委托组件具有的“当前项目”样式。这需要什么取决于风格;如果您自己编写样式,那么您可以将 delegate 组件移动到它自己的文件中,然后将其重新用于 header。但是,如果您使用的是其他人的样式,则不能这样做,并且必须从头开始编写(尽管通常只需要这样做一次)。例如,对于默认(“基本”,在 Qt 6 中)样式,它意味着:
    • 设置合适的 font.weight.
    • 设置highlighted.
    • 设置hoverEnabled.
  • 必须自己设置displayText
  • 由于 header 项不被视为 ComboBox 项,因此 highlightedIndex 属性(即 read-only)不会考虑它。可以通过在委托中将 highlighted 设置为 hovered 来解决。
  • 单击 header 时必须执行以下操作:
    • 设置 currentIndex(即点击 -1)。
    • 关闭 ComboBox 的弹出窗口。
    • 手动发射 activated()

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 }
//        }
    }
}

结论

我同意“None”和“Select 全部”是元数据多于模型数据的观点。从这个意义上说,我更喜欢 header 方法。在让我对此进行调查的特定用例中,我不允许按键导航,并且我已经覆盖了 ComboBox 的 delegate 属性,因此我可以将该代码重新用于 header.

但是,如果您需要按键导航,或者您不想为 header 重新实现 delegate,QSortFilterProxyModel 方法会更实用。