可编辑树形模型示例

本示例展示了如何实现一个简单的基于项目的树形模型,它可以与其他模型/视图框架中的类一起使用。

该模型支持可编辑的项目、自定义标题,以及插入和删除行和列的能力。有了这些特性,也可以插入新的子项目,这在辅助示例代码中有展示。

概述

如《模型派生参考》中所述,模型必须实现标准模型功能的实现:stoiabstractitemmodel.html#flags()、data()、headerData()、columnCount()和rowCount()。此外,类似此类似的层次模型还需要提供index()和parent()的实现。

可编辑模型需要提供setData()和setHeaderData()的实现,并必须从其flags()函数返回一个合适的标志组合。

由于此示例允许更改模型的维度,我们还必须实现insertRows()、insertColumns()、removeRows()和removeColumns()。

设计

与《简单树形模型》示例类似,该模型简单地将一系列TreeItem类的实例包装起来。每个TreeItem都是为了在一个树视图中存储项目行数据而设计的,因此它包含一个与列中显示的数据相对应的值列表。

由于QTreeView为模型提供了一个面向行的视图,因此选择面向行的设计对于将通过模型向此类视图提供数据的结构来说是自然的。虽然这会使树模型不太灵活,可能对于使用更复杂的视图也不太有用,但它使得设计和实现更加简单。

内部元素之间的关系

在为自定义模型设计数据结构时,通过像 TreeItem::parent() 这样的函数公开每个项目的父项非常有用,因为这将使编写模型自身的 parent() 函数更简单。同样,在实现模型的 index() 函数时,像 TreeItem::child() 这样的函数也很有用。因此,每个 TreeItem 都会维护关于其父项和子项的信息,使得我们能够遍历树结构。

图表展示了 TreeItem 实例如何通过其 parent()child() 函数连接。

在所示的示例中,可以通过调用根项的 child() 函数来获得两个顶级项 AB,这些项的每个从中返回根节点给它们的 parent() 函数,尽管这只为项 A 所示。

每个 TreeItem 在其 itemData 私有成员(一个 QVariant 对象列表)中存储代表其在行中每列的数据。由于视图中的每一列都与列表中的每个条目一一对应,因此我们提供了一个简单的 data() 函数来读取 itemData 列表中的条目,并提供一个 setData() 函数来允许对其进行修改。与其他函数类似,这简化了模型 data() 和 setData() 函数的实现。

我们将一个项放在项树的根处。此根项对应于空模型索引,QModelIndex(),在处理模型索引时用于表示顶级项的父项。尽管根项在任何标准视图中都没有可视表示,但我们使用其内部 QVariant 对象列表来存储传递给视图以用作水平标题的字符串列表。

通过模型访问数据

在图中所示的情况下,可以使用标准模型/视图 API 获取表示 a 的信息

QVariant a = model->index(0, 0, QModelIndex()).data();

由于每个项在指定行中为每列持有数据,因此可以有多个映射到同一 TreeItem 对象的模型索引。例如,使用以下代码可以获取表示 b 的信息

QVariant b = model->index(1, 0, QModelIndex()).data();

为获取与 b 在同一行中的其他模型索引的信息,将访问相同的基础 TreeItem

在模型类 TreeModel 中,我们在 index()parent() 的实现中,通过为每个项传递指针,将 TreeItem 对象与模型索引关联起来。我们可以通过在相关模型索引上调用 internalPointer() 函数来检索以这种方式存储的指针 - 我们创建了我们的自己的 getItem() 函数来为此工作,并在我们的 data()parent() 实现中调用它。

当控制对象的创建和销毁时,将指向对象的指针存储起来是方便的,因为我们能假设从internalPointer()获得的地址是有效的指针。然而,有些模型需要处理源自系统其他组件的项目,在许多情况下无法完全控制项目的创建或销毁。在这些情况下,纯指针方法需要通过安全措施来补充,以确保模型不会尝试访问已被删除的项目。

在底层数据结构中存储信息

在每棵树结构的实例中,几项数据都作为QVariant对象存储在树结构成员itemData中。

图示展示了如何将前两个图中的标记为abc的信息存储在底层数据结构中的项目ABC中。请注意,来自模型中同一行的信息都来自同一个项目。列表中的每个元素都对应于模型中给定行每列公开的信息。

由于树模型实现旨在与QTreeView一起使用,我们在它使用TreeItem实例的方式上增加了一个限制:每个项目必须公开相同数量的数据列。这使得查看模型保持一致,使得我们可以使用根项目来确定任何给定行的列数,并且只增加了这样一个要求:我们需要创建包含足够数据以便容纳全部列数的项目。因此,插入和删除列的操作耗时,因为我们需要遍历整个树来修改每一个项。

可以采用另一种方法来设计TreeModel类,以便当修改数据项时,它会截断或扩展单个TreeItem实例中的数据列表。然而,这种“懒加载”调整大小方法只能使我们能够在每一行的末尾插入和删除列,而不会允许在每一行的任意位置插入或删除列。

通过模型索引关联项

Simple Tree Model示例一样,TreeModel需要能够接受一个模型索引,找到对应的TreeItem,并返回与父项和子项对应的模型索引。

在图中,我们展示了模型parent()的实现如何根据调用者提供一个项目,使用前一个图中展示的项目来获取项目父项对应的模型索引。

使用QModelIndex::internalPointer()函数从对应的模型索引中获取指向项目C的指针。指针在创建索引时被内部存储。因为子项包含其父项的指针,所以我们使用它的parent()函数来获取指向项目B的指针。父模型索引使用QAbstractItemModel::createIndex()函数创建,并将项目B的指针作为内部指针传递。

树项目类定义

TreeItem类提供了包含几项数据以及它们父项和子项信息的简单项。

class TreeItem
{
public:
    explicit TreeItem(QVariantList data, TreeItem *parent = nullptr);

    TreeItem *child(int number);
    int childCount() const;
    int columnCount() const;
    QVariant data(int column) const;
    bool insertChildren(int position, int count, int columns);
    bool insertColumns(int position, int columns);
    TreeItem *parent();
    bool removeChildren(int position, int count);
    bool removeColumns(int position, int columns);
    int row() const;
    bool setData(int column, const QVariant &value);

private:
    std::vector<std::unique_ptr<TreeItem>> m_childItems;
    QVariantList itemData;
    TreeItem *m_parentItem;
};

我们设计的API与QAbstractItemModel提供的相似,每个项目都具有返回信息列数、读取和写入数据、以及插入和删除列的功能。然而,我们通过提供处理“子项”而不是“行”的函数,将项目之间的关系明确化。

每个项目包含一个指向子项目的指针列表、一个指向其父项目的指针,以及一个列表,该列表对应于模型中给定行中的信息列。

TreeItem类实现

每个TreeItem使用数据列表和一个可选的父项目构造。

TreeItem::TreeItem(QVariantList data, TreeItem *parent)
    : itemData(std::move(data)), m_parentItem(parent)
{}

最初,每个项目没有子项目。这些子项目将通过后面介绍的insertChildren()函数添加到项的内部childItems成员。

子项目存储在std::unique_ptr中,以确保每个添加到项中的子项目在项目本身被删除时被删除。

由于每个项目都存储对其父项目的指针,所以parent()函数很简单。

TreeItem *TreeItem::parent()
{
    return m_parentItem;
}

三个函数提供了关于一个项目子项目的信息。child()从内部子项目列表检索一个特定期望的孩子。

TreeItem *TreeItem::child(int number)
{
    return (number >= 0 && number < childCount())
        ? m_childItems.at(number).get() : nullptr;
}

childCount()函数返回子项目的总数。

int TreeItem::childCount() const
{
    return int(m_childItems.size());
}

childNumber()函数用于确定子项目在其父项目子项目列表中的索引。它直接访问父项目的childItems成员以获取此信息。

int TreeItem::row() const
{
    if (!m_parentItem)
        return 0;
    const auto it = std::find_if(m_parentItem->m_childItems.cbegin(), m_parentItem->m_childItems.cend(),
                                 [this](const std::unique_ptr<TreeItem> &treeItem) {
        return treeItem.get() == this;
    });

    if (it != m_parentItem->m_childItems.cend())
        return std::distance(m_parentItem->m_childItems.cbegin(), it);
    Q_ASSERT(false); // should not happen
    return -1;
}

根项目没有父项目;对于此项目,我们返回零,以与其他项目保持一致。

columnCount()函数简单地返回内部QVariant对象列表中的元素数量。

int TreeItem::columnCount() const
{
    return int(itemData.count());
}

使用data()函数检索数据,该函数访问itemData列表中的适当元素。

QVariant TreeItem::data(int column) const
{
    return itemData.value(column);
}

使用setData()函数设置数据,该函数仅在有效列表索引中存储值,对应于模型中的列值。

bool TreeItem::setData(int column, const QVariant &value)
{
    if (column < 0 || column >= itemData.size())
        return false;

    itemData[column] = value;
    return true;
}

为了简化模型实现,我们返回true以指示数据已成功设置。

可编辑的模型通常需要可调整大小,允许行和列的插入和删除。在模型中给定模型索引下插入行会导致在相应的项中插入新的子项目,由insertChildren()函数处理。

bool TreeItem::insertChildren(int position, int count, int columns)
{
    if (position < 0 || position > qsizetype(m_childItems.size()))
        return false;

    for (int row = 0; row < count; ++row) {
        QVariantList data(columns);
        m_childItems.insert(m_childItems.cbegin() + position,
                std::make_unique<TreeItem>(data, this));
    }

    return true;
}

这确保了新的项目以所需的列数创建并插入到内部childItems列表的有效位置。使用removeChildren()函数删除项目。

bool TreeItem::removeChildren(int position, int count)
{
    if (position < 0 || position + count > qsizetype(m_childItems.size()))
        return false;

    for (int row = 0; row < count; ++row)
        m_childItems.erase(m_childItems.cbegin() + position);

    return true;
}

如上所述,插入和删除列的函数的使用方式与插入和删除子项目的函数不同,因为预计它们将在树中的每个项上调用。我们通过递归调用此函数对项的每个子项执行此操作。

bool TreeItem::insertColumns(int position, int columns)
{
    if (position < 0 || position > itemData.size())
        return false;

    for (int column = 0; column < columns; ++column)
        itemData.insert(position, QVariant());

    for (auto &child : std::as_const(m_childItems))
        child->insertColumns(position, columns);

    return true;
}

TreeModel类定义

TreeModel类提供了QAbstractItemModel类的实现,公开了编辑和可调整大小的模型所需的所有接口。

class TreeModel : public QAbstractItemModel
{
    Q_OBJECT

public:
    Q_DISABLE_COPY_MOVE(TreeModel)

    TreeModel(const QStringList &headers, const QString &data,
              QObject *parent = nullptr);
    ~TreeModel() override;

构造函数和析构函数是针对此模型特定的。

    QVariant data(const QModelIndex &index, int role) const override;
    QVariant headerData(int section, Qt::Orientation orientation,
                        int role = Qt::DisplayRole) const override;

    QModelIndex index(int row, int column,
                      const QModelIndex &parent = {}) const override;
    QModelIndex parent(const QModelIndex &index) const override;

    int rowCount(const QModelIndex &parent = {}) const override;
    int columnCount(const QModelIndex &parent = {}) const override;

只读的树模型只需要提供上述函数。以下公共函数提供了编辑和调整大小的支持

    Qt::ItemFlags flags(const QModelIndex &index) const override;
    bool setData(const QModelIndex &index, const QVariant &value,
                 int role = Qt::EditRole) override;
    bool setHeaderData(int section, Qt::Orientation orientation,
                       const QVariant &value, int role = Qt::EditRole) override;

    bool insertColumns(int position, int columns,
                       const QModelIndex &parent = {}) override;
    bool removeColumns(int position, int columns,
                       const QModelIndex &parent = {}) override;
    bool insertRows(int position, int rows,
                    const QModelIndex &parent = {}) override;
    bool removeRows(int position, int rows,
                    const QModelIndex &parent = {}) override;

private:
    void setupModelData(const QList<QStringView> &lines);
    TreeItem *getItem(const QModelIndex &index) const;

    std::unique_ptr<TreeItem> rootItem;
};

为了简化此示例,模型可通过模型的setupModelData()函数将暴露的数据组织成数据结构。许多现实世界的模型甚至不会处理原始数据,而只是简单地使用现有的数据结构或库API。

TreeModel 类实现

构造函数创建一个根项,并使用提供的标题数据对其进行初始化

TreeModel::TreeModel(const QStringList &headers, const QString &data, QObject *parent)
    : QAbstractItemModel(parent)
{
    QVariantList rootData;
    for (const QString &header : headers)
        rootData << header;

    rootItem = std::make_unique<TreeItem>(rootData);
    setupModelData(QStringView{data}.split(u'\n'));
}

我们调用内部setupModelData()函数,将提供的文本数据转换为可以与模型一起使用的数据结构。其他模型可以使用预先制作的数据结构进行初始化,或使用库的API以维护其自己的数据。

TreeModel::~TreeModel() = default;

析构函数只需要删除根项,这将递归地删除所有子项。由于根项存储在唯一_ptr 中,这由默认析构函数自动完成。

由于模型的接口依赖于其他模型/视图组件是基于模型索引的,并且由于内部数据结构是基于项的,因此模型实现的大多数函数都需要能够将任何给定的模型索引转换为相应的项。为方便和一致性起见,我们定义了一个getItem()函数来执行这项重复性任务

TreeItem *TreeModel::getItem(const QModelIndex &index) const
{
    if (index.isValid()) {
        if (auto *item = static_cast<TreeItem*>(index.internalPointer()))
            return item;
    }
    return rootItem.get();
}

传递给此函数的每个模型索引应对应内存中的一个有效项。如果索引无效或其内部指针不指向有效项,则返回根项。

模型的rowCount()实现很简单:它首先使用getItem()函数获取相关项;然后返回它包含的子项数

int TreeModel::rowCount(const QModelIndex &parent) const
{
    if (parent.isValid() && parent.column() > 0)
        return 0;

    const TreeItem *parentItem = getItem(parent);

    return parentItem ? parentItem->childCount() : 0;
}

相比之下,columnCount()实现不需要查找特定项,因为所有项都定义为与它们关联相同数量的列。

int TreeModel::columnCount(const QModelIndex &parent) const
{
    Q_UNUSED(parent);
    return rootItem->columnCount();
}

因此,可以直接从根项中获取列数。

为了使项可以编辑和选择,需要实现flags()函数,以便返回包含Qt::ItemIsEditableQt::ItemIsSelectable标志以及Qt::ItemIsEnabled

Qt::ItemFlags TreeModel::flags(const QModelIndex &index) const
{
    if (!index.isValid())
        return Qt::NoItemFlags;

    return Qt::ItemIsEditable | QAbstractItemModel::flags(index);
}

模型需要有生成模型索引的能力,以允许其他组件请求有关其结构的数据和信息。这项任务由index()函数执行,该函数用于获取对应于给定父项子项的模型索引

QModelIndex TreeModel::index(int row, int column, const QModelIndex &parent) const
{
    if (parent.isValid() && parent.column() != 0)
        return {};

在此模型中,只有当父索引无效(对应于根项)或它有零列数时,才返回子项的模型索引。

我们使用自定义的getItem()函数获取对应于提供的模型索引的TreeItem实例,并请求其对应的指定行数的子项。

    TreeItem *parentItem = getItem(parent);
    if (!parentItem)
        return {};

    if (auto *childItem = parentItem->child(row))
        return createIndex(row, column, childItem);
    return {};
}

由于每个项包含整个行数据的详细信息,我们通过调用createIndex()并传入行和列编号以及项的指针来创建一个唯一标识它的模型索引。在data()函数中,我们使用项指针和列编号来访问与模型索引相关的数据;在此模型中,行编号不需要用来识别数据。

parent()函数通过查找对应于给定模型索引的项来为项的父项提供模型索引,使用其parent()函数来获取其父项,然后创建一个表示父项的模型索引。(参见上面的图表)。

QModelIndex TreeModel::parent(const QModelIndex &index) const
{
    if (!index.isValid())
        return {};

    TreeItem *childItem = getItem(index);
    TreeItem *parentItem = childItem ? childItem->parent() : nullptr;

    return (parentItem != rootItem.get() && parentItem != nullptr)
        ? createIndex(parentItem->row(), 0, parentItem) : QModelIndex{};
}

没有父项的项,包括根项,通过返回null模型索引来处理。否则,将创建并返回模型索引,如在index()函数中,使用合适的行号,但列号为0,以与index()实现中使用的方案保持一致。

模型测试

正确实现项模型可能具有挑战性。来自Qt Test模块的类QAbstractItemModelTester检查模型一致性,如模型索引创建和父子关系。

您可以通过将模型实例传递给类构造函数来测试您的模型,例如作为Qt单元测试的一部分。

class TestEditableTreeModel : public QObject
{
    Q_OBJECT

private slots:
    void testTreeModel();
};

void TestEditableTreeModel::testTreeModel()
{
    constexpr auto fileName = ":/default.txt"_L1;
    QFile file(fileName);
    QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text),
             qPrintable(fileName + " cannot be opened: "_L1 + file.errorString()));

    const QStringList headers{"column1"_L1, "column2"_L1};
    TreeModel model(headers, QString::fromUtf8(file.readAll()));

    QAbstractItemModelTester tester(&model);
}

QTEST_APPLESS_MAIN(TestEditableTreeModel)

#include "test.moc"

要创建一个可使用ctest可执行文件运行的测试,请使用add_test()

# Unit Test

include(CTest)

qt_add_executable(editabletreemodel_tester
    test.cpp
    treeitem.cpp treeitem.h
    treemodel.cpp treemodel.h)

target_link_libraries(editabletreemodel_tester PRIVATE
    Qt6::Core
    Qt6::Test
)

if(ANDROID)
    target_link_libraries(editabletreemodel_tester PRIVATE
        Qt6::Gui
    )
endif()

qt_add_resources(editabletreemodel_tester "editabletreemodel_tester"
    PREFIX
        "/"
    FILES
        ${editabletreemodel_resource_files}
)

add_test(NAME editabletreemodel_tester
         COMMAND editabletreemodel_tester)

示例项目@ code.qt.io

© 2024 Qt公司有限公司。本文档中的贡献版权属于其各自的所有者。本文档根据免费软件基金会发布的GNU自由文档许可协议版本1.3的条款进行许可。Qt及其Logo是芬兰及其它国家Qt公司有限公司的商标。所有其他商标均为其各自所有者的财产。