Hierarchical models in Qt

  • Tutorial
I continue the topic of creating models using Qt MV. The last time was a critical article about how to do. We pass to the positive part.

To create flat models for lists and tables, you can use the QAbstractListModel and QAbstractTableModel presets . Bringing them to readiness is not difficult, so there is no need to consider them in detail.

Creating hierarchical models is a more difficult task. It will be discussed in this article.

In general, Qt allows you to create not only tree-like models, but also models of more complex configurations and dimensions. For example, a table in which each element is grouping for a subtable. Despite this, in most cases when they talk about hierarchical models, they mean trees. It is the process of creating trees that I want to highlight.

Figure: Table in table

Another introductory remark concerns the essence of the model, which I will create as an example. I wanted to choose something specific and universal, so I decided to create an example model that displays the file system. It does not suffer from completeness and completeness, so it is unlikely that someone will want to use it seriously (especially since there is already a QFileSystemModel ), but for an example it is quite suitable. In the next article, if there is one, I’m going to build several proxies around this model.

Design

First of all, you need to decide on the internal data structure . Here I would highlight 2 main areas:
  • Models with a well-defined structure or small nesting.
    An example would be the property editor in Qt Designer. In this case, all the information about the position of a particular model element can be stored in the index itself ( QModelIndex ) using internalId. In the case of the property editor, internalId you can save the property group identifier in.
    Another example is a notebook model. Obviously, a "record Lol4t0 » included in subgroup Lo , which in turn is included in group L . And vice versa - the Lo group includes those and only those records that begin with the prefix “ Lo ”. This is what I call a well-defined structure.

  • Models without a well-defined structure. The file system model refers to these. Knowing only the name of the Documents folder , generally speaking, it is impossible to determine which folder it is in and which folders it contains.

Additional information about data organization can be used in the design process, so creating models of the first type usually does not cause problems. The article will focus on models of the second type.

All types of data that you have to deal with when developing tree models are actually similar. Therefore, it will not be difficult to highlight some general recommendations regarding the internal organization of data:
  • Need to store node data
  • There must be a one-to-one relationship between QModelIndexand an element of the internal data structure
  • Each node must contain links to the parent node and child nodes

In the case of a file system model, I will use QFileInfo to store information about each node. In addition, you will need to store information about the child nodes and the parent node. Additionally, you will need information about whether the search for child nodes was performed or not.

Business data ( QFileInfo) had to be wrapped with service information. In most cases, it is impossible to do without it. If the data in the subject area already supports the hierarchy, you can use it, but I have never encountered the case when the initial data would contain all the necessary information.

We get the following internal data structure:
struct FilesystemModel::NodeInfo
{
	QFileInfo fileInfo; // информация об узле
	QVector children; // список дочерних узлов
	NodeInfo* parent; // ссылка на родительский узел
	bool mapped; // производился ли поиск дочерних узлов.
};

When creating a tree, I will construct a list of nodes corresponding to the root objects of file systems, and I will load their children as necessary:
typedef QVector NodeInfoList;
NodeInfoList _nodes; // список корневых узлов файловых систем

There will be several columns in the tree:
enum Columns
{
	RamificationColumn, // столбец, по которому производится ветвление, всегда первый.
                        // Другого варианта не поддерживает QTreeView
	NameColumn = RamificationColumn, // столбец с именем узла
	ModificationDateColumn, // столбец с датой изменения узла
	SizeColumn, // столбец с размером файла
	ColumnCount // число столбцов
};


Minimal implementation

Once you have decided on the data storage structure, you can begin to implement the model.
If you need to implement a hierarchical model, then there is nothing left but to inherit from QAbstractItemModel . In order to implement the simplest model, you need to write an implementation of only five functions:
virtual QModelIndex index(int row, int column, const QModelIndex &parent) const;
virtual QModelIndex parent(const QModelIndex &child) const;
virtual int rowCount(const QModelIndex &parent = QModelIndex()) const;
virtual int columnCount(const QModelIndex &parent = QModelIndex()) const;
virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;

However, the implementation of the first two usually accounts for 80% of the problems associated with the creation of hierarchical models. The thing is that they are called very often, so the use of algorithms in them is more complicated than O (1), generally speaking, it is not desirable.

I suggest storing a pointer to NodeInfoin an internalPointerindex. In most cases, they do just that. When implementing, indexin no case should non-existent indexes be returned. At the same time, one does not need to expect that no one will request such an index. There is a very convenient function for checking the existence of an index hasIndex.
QModelIndex FilesystemModel::index(int row, int column, const QModelIndex &parent) const
{
	if (!hasIndex(row, column, parent)) {
		return QModelIndex();
	}
	if (!parent.isValid()) { // запрашивают индексы корневых узлов
		return createIndex(row, column, const_cast(&_nodes[row]));
	}
	NodeInfo* parentInfo = static_cast(parent.internalPointer());
	return createIndex(row, column, &parentInfo->children[row]);
}

With parent everything a little more complicated. Despite the fact that you can always find the NodeInfoparent element at a given index , to create the index of the parent element, you must also know its position among the "brothers".

There are two options - either to store information about its position with each node, or to determine this position each time again. The trouble with the first is that when you add and remove nodes, all the underlying nodes will have to be updated. What I really did not want. Therefore, I chose the second option, despite its computational complexity. In a real model, I would stick to that choice until I could prove that this is a bottleneck.
QModelIndex FilesystemModel::parent(const QModelIndex &child) const
{
	if (!child.isValid()) {
		return QModelIndex();
	}
	NodeInfo* childInfo = static_cast(child.internalPointer());
	NodeInfo* parentInfo = childInfo->parent;
	if (parentInfo != 0) { // parent запрашивается не у корневого элемента
		return createIndex(findRow(parentInfo), RamificationColumn, parentInfo);
	}
	else {
		return QModelIndex();
	}
}
int FilesystemModel::findRow(const NodeInfo *nodeInfo) const
{
	const NodeInfoList& parentInfoChildren = nodeInfo->parent != 0 ? nodeInfo->parent->children: _nodes;
	NodeInfoList::const_iterator position = qFind(parentInfoChildren, *nodeInfo);
	return std::distance(parentInfoChildren.begin(), position);
}

Implementation rowCountand columnCount trivial: in the first case we can always determine the number of child nodes NodeInfo::children::size, and the number of columns is fixed.
int FilesystemModel::rowCount(const QModelIndex &parent) const
{
	if (!parent.isValid()) {
		return _nodes.size();
	}
	const NodeInfo* parentInfo = static_cast(parent.internalPointer());
	return parentInfo->children.size();
}
int FilesystemModel::columnCount(const QModelIndex &) const
{
	return ColumnCount;
}

Implementation dataalso does not represent anything complicated, we get all the necessary information from QFileInfo. At a minimum, you must implement role support Qt::DisplayRoleto display text in viewand Qt::EditRole, if editing is provided. Data received from the role model Qt::EditRolewill be loaded into the editor. Moreover, the data that the model returns when requested with Qt::DisplayRoleand Qt::EditRolemay vary. For example, we will display files without extensions, and edit with the extension.
Function code data
QVariant FilesystemModel::data(const QModelIndex &index, int role) const
{
	if (!index.isValid()) {
		return QVariant();
	}
	const NodeInfo* nodeInfo = static_cast(index.internalPointer());
	const QFileInfo& fileInfo = nodeInfo->fileInfo;
	switch (index.column()) {
	case NameColumn:
		return nameData(fileInfo, role);
	case ModificationDateColumn:
		if (role == Qt::DisplayRole) {
			return fileInfo.lastModified();
		}
		break;
	case SizeColumn:
		if (role == Qt::DisplayRole) {
			return fileInfo.isDir()? QVariant(): fileInfo.size();
		}
		break;
	default:
		break;
	}
	return QVariant();
}
QVariant FilesystemModel::nameData(const QFileInfo &fileInfo, int role) const
{
	switch (role) {
	case Qt::EditRole:
		return fileInfo.fileName();
	case Qt::DisplayRole:
		if (fileInfo.isRoot()) {
			return fileInfo.absoluteFilePath();
		}
		else if (fileInfo.isDir()){
			return fileInfo.fileName();
		}
		else {
			return fileInfo.completeBaseName();
		}
	default:
		return QVariant();
	}
	Q_UNREACHABLE();
}

In order for the model to come to life, it remains to fill in the root nodes:
void FilesystemModel::fetchRootDirectory()
{
	const QFileInfoList drives = QDir::drives();
	qCopy(drives.begin(), drives.end(), std::back_inserter(_nodes));
}
FilesystemModel::FilesystemModel(QObject *parent) :
	QAbstractItemModel(parent)
{
	fetchRootDirectory();
}


Now you can display the model using QTreeView and see the result.

However, what is it! Root elements cannot be expanded.
Indeed, because the data for them has not yet been downloaded.

Dynamic data loading

To implement automatic data loading as needed, the following API is implemented in Qt:
bool canFetchMore(const QModelIndex &parent) const;
void fetchMore(const QModelIndex &parent);

The first function should return truewhen the data for the given parent can be loaded, and the second should load the data itself.
Useful here NodeInfo::mapped. Data can be loaded when mapped == false.
bool FilesystemModel::canFetchMore(const QModelIndex &parent) const
{
	if (!parent.isValid()) {
		return false;
	}
	const NodeInfo* parentInfo = static_cast(parent.internalPointer());
	return !parentInfo->mapped;
}

For loading we will use the functions provided QDir. At the same time, do not forget to use beginInsertRows it endInsertRows when changing the number of lines. Unfortunately, it QTreeViewperforms loading only when trying to expand a node, and does not try to load new data when scrolling through the list. Therefore, there is nothing left but to download the entire list of child nodes as a whole. You can fix this behavior, except by creating your own display component.
void FilesystemModel::fetchMore(const QModelIndex &parent)
{
	NodeInfo* parentInfo = static_cast(parent.internalPointer());
	const QFileInfo& fileInfo = parentInfo->fileInfo;
	QDir dir = QDir(fileInfo.absoluteFilePath());
	QFileInfoList children = dir.entryInfoList(QStringList(), QDir::AllEntries | QDir::NoDotAndDotDot, QDir::Name);
	beginInsertRows(parent, 0, children.size() - 1);
	parentInfo->children.reserve(children.size());
	for (const QFileInfo& entry: children) {
		NodeInfo nodeInfo(entry, parentInfo);
		nodeInfo.mapped = !entry.isDir();
		parentInfo->children.push_back(std::move(nodeInfo));
	}
	parentInfo->mapped = true;
	endInsertRows();
}

We do the loading, run the program, but there is no result. The root nodes are also impossible to deploy. The thing is that it QTreeView uses a function hasChildrento check whether a node has children, and believes that only nodes that have children can be expanded. hasChildrenit returns by default trueonly when the number of rows and the number of columns for the parent node is greater than 0.

In this case, this behavior is not suitable. We redefine the function hasChildrenso that it returns truefor the given node when it definitely has or can (when mapped ==false) have child nodes.

You can determine for sure whether the directory is empty, but it’s a rather expensive operation, whether you use it is up to you.
bool FilesystemModel::hasChildren(const QModelIndex &parent) const
{
	if (parent.isValid()) {
		const NodeInfo* parentInfo = static_cast(parent.internalPointer());
		Q_ASSERT(parentInfo != 0);
		if (!parentInfo->mapped) {
			return true;//QDir(parentInfo->fileInfo.absoluteFilePath()).count() > 0; -- точное определение того, что директория не пуста
		}
	}
	return QAbstractItemModel::hasChildren(parent);
}

Now the model is working, you can view the folders.

I think we can end here, for the sake of completeness, we could add the function of renaming files and folders, creating directories and tracking changes to the file system. But this clearly goes beyond the permitted article on Habr. I posted a slightly more advanced example on GitHub .

Also popular now: