2010-07-23

Использование Tree Model в QML интерфейсах

На данный момент в QML мы можем описать только линейные модели и представления. Также есть вариант использовать VisualDataModel для построения псевдо-деревьев (с изменением текущего rootIndex).
Но иногда не нужно строить большие деревья, где оправдано изменение rootIndex модели, а нужно построить дерево с небольшим ветвлением и с возможностью отображения его целиком.
Ниже описан способ как это сделать.


Ограничения
  • Данный способ будет работать медленно на моделях с большим ветвлением и/или большим количеством вложенных элементов.
  • Данный способ использует C++-модели, что ограничивает возможности его применения.

Описание метода
Мы не будем писать полноценное tree-представление для QML, а ограничимся его имитацией. Имитировать будем посредством вставки/удаления элементов-потомков в обычную линейную модель. То есть, у нас есть линейная модель:
  1. Element 1
  2. Element 2
  3. Element 3
Предположим, что Element 2 содержит два потомка, тогда после его раскрытия модель будет содержать:
  1. Element 1
  2. Element 2
  3. Element 2.1
  4. Element 2.2
  5. Element 3
После свертывания Element 2 модель вернется к первоначальному виду.

Что в итоге получится
Сразу приведу скриншоты результата, чтобы знать, что мы хотим получить.
Все элементы свернутыРазвернут один элементВсе элементы развернуты

Реализация части на QML
Начнем разработку с реализации декларативной части.

import Qt 4.7



ListView {
    id: treeView
    width: 500
    height: 500

    //Задаем делегата
    delegate: treeDelegate 
    //Задаем модель, этот объект позже придет из C++
    model: treeModel 


    //Компонент делегата
    Component {
        id: treeDelegate

        Item {
            id: wrapper
            height: 50
            width: treeView.width

            //Полоска для отделения элементов друг от друга
            Rectangle {
                height: 1
                anchors.top: parent.top
                anchors.left: parent.left
                anchors.right: parent.right
                color: "#d0d0d0"
            }


            //"Отбой" слева элементов-потомков
            Item {
                id: levelMarginElement
                //Начиная с 6 уровня вложенности не сдвигаем потомков,
                //так как иначе можно получить очень широкое окно
                width: (level>5?6:level)*32 + 5
                anchors.left: parent.left
            }


            //Область для открытия/закрытия потомков.
            //На листьях не виден
            Item {
                id: nodeOpenElement
                anchors.left: levelMarginElement.right
                anchors.verticalCenter: wrapper.verticalCenter
                height: 24
                state: "leafNode"

                Image {
                    id: triangleOpenImage
                    
                    //Отлавливаем нажатие мышкой и открываем/закрываем элемент
                    MouseArea {
                        anchors.fill: parent
                        onClicked: { (isOpened) ? 
                                     treeModel.closeItem(index) : 
                                     treeModel.openItem(index) }
                    }
                }


                states: [
                    //Лист
                    //Область не видна
                    State {
                        name: "leafNode"
                        when: !hasChildren
                        PropertyChanges {
                            target: nodeOpenElement
                            visible: false
                            width: 0
                        }
                    },
                    //Открытый элемент
                    //Область видна и отображена соответствующая иконка
                    State {
                        name: "openedNode"
                        when: (hasChildren)&&(isOpened)
                        PropertyChanges {
                            target: nodeOpenElement
                            visible: true
                            width: 32
                        }
                        PropertyChanges {
                            target: triangleOpenImage
                            source: "qrc:/images/tree-node-opened.png"
                        }
                    },
                    //Закрытый элемент
                    //Область видна и отображена соответствующая иконка
                    State {
                        name: "closedNode"
                        when: (hasChildren)&&(!isOpened)
                        PropertyChanges {
                            target: nodeOpenElement
                            visible: true
                            width: 32
                        }
                        PropertyChanges {
                            target: triangleOpenImage
                            source: "qrc:/images/tree-node-closed.png"
                        }
                    }
                ]
            }


            //Область для отображения данных элемента
            Text {
                id: nameTextElement
                text: name
                verticalAlignment: "AlignVCenter"
                anchors.left: nodeOpenElement.right
                anchors.top: parent.top
                anchors.bottom: parent.bottom
                anchors.right: parent.right
            }
        }
    }
}


По сути, весь компонент состоит из ListView, в котором реализован делегат.
Делегат состоит из:
  1. "Отбоя" дочерних элементов, который нужен для сдвига потомков относительно предка вправо. Причем сделано ограничение на 6 уровней сдвига, чтобы не получилось очень широкого окна в случае большой вложенности. Механизм аналогичен механизму комментариев на хабре.
  2. Области открытия/закрытия элемента. В данной области отображается плюс/минус, если у элемента есть потомки и ничего не отображается (а точнее область просто скрывается), если это лист.
  3. Области отображения содержимого элемента. В нашем случае просто текстовая строка.

Реализация части на C++
Теперь рассмотрим, что нам надо реализовать на C++. Нам нужна модель (наследник QAbstractListModel) и проброс ее экземпляра в QML-код. Начнем с модели.

Объявление модели (treemodel.h):

#ifndef TREEMODEL_H
#define TREEMODEL_H

#include < QAbstractListModel>

class TreeModelItem;
class TreeModel : public QAbstractListModel
{
    Q_OBJECT
public:
    explicit TreeModel(QObject *parent=0);
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;
    int rowCount(const QModelIndex &parent = QModelIndex()) const;

public slots:
    void openItem(int numIndex);
    void closeItem(int numIndex);

private:
    Q_DISABLE_COPY(TreeModel);
    QList<TreeModelItem *> items;

    enum ListMenuItemRoles
    {
        NameRole = Qt::UserRole+1,
        LevelRole,
        IsOpenedRole,
        HasChildrenRole
    };
};

#endif // TREEMODEL_H

В модель, кроме обязательных к переопределению data() и rowCount(), добавим еще два метода openItem() и closeItem(). Они объявлены слотами, чтобы можно было достучаться до них из QML-кода. Также добавим 4 роли к имеющимся стандартным для получения доступа к параметрам элементов модели.

В файле реализации (treemodel.cpp) опишем еще один класс, который хранит один элемент модели:

class TreeModelItem
{
public:
    TreeModelItem(QString name_ = QString())
                    : name(name_),
                    level(0),
                    isOpened(false)
    {}

    void adjustChildrenLevels()
    {
        foreach(TreeModelItem *item, children)
        {
            item->level = level+1;
            item->adjustChildrenLevels();
        }
    }

    QString name;
    int level;
    bool isOpened;
    QList<TreeModelItem *> children;
    inline bool hasChildren() {return !children.empty();}
};

Этот класс кроме параметров, также имеет один метод adjustChildrenLevels(), с помощью которого мы рекурсивно проставляем уровни вложенности (level) у всех потомков элемента. В реальном коде он скорее всего будет удален и расчет уровня будет происходить автоматически при добавлении элемента в дерево, но в примере он уменьшает код и упрощает общую картину.

Конструктор модели:

TreeModel::TreeModel(QObject *parent) :
    QAbstractListModel(parent)
{
    TreeModelItem *toAdd;

    items << new TreeModelItem("First");
    toAdd = new TreeModelItem("Child 1.1");
    toAdd->children << new TreeModelItem("Child 1.1.1");
    items[0]->children << toAdd;
    items[0]->adjustChildrenLevels();

    items << new TreeModelItem("Second");
    items << new TreeModelItem("Third");
    items << new TreeModelItem("Fourth");

    toAdd = new TreeModelItem("Fifth");
    toAdd->children << new TreeModelItem("Child 5.1");
    toAdd->adjustChildrenLevels();
    items << toAdd;

    QHash<int, QByteArray> roles = roleNames();
    roles.insert(NameRole, QByteArray("name"));
    roles.insert(LevelRole, QByteArray("level"));
    roles.insert(IsOpenedRole, QByteArray("isOpened"));
    roles.insert(HasChildrenRole, QByteArray("hasChildren"));
    setRoleNames(roles);

}


В конструкторе мы заносим тестовые элементы в модель и регистрируем наши дополнительные роли (под этими именами они будут доступны в QML).

Переопределение методов класса-предка:

QVariant TreeModel::data(const QModelIndex &index, int role) const
{
    if (!index.isValid())
        return QVariant();
    if (index.row() > (items.size()-1) )
        return QVariant();

    TreeModelItem *item = items.at(index.row());
    switch (role)
    {
    case Qt::DisplayRole:
    case NameRole:
        return QVariant::fromValue(item->name);
    case LevelRole:
        return QVariant::fromValue(item->level);
    case IsOpenedRole:
        return QVariant::fromValue(item->isOpened);
    case HasChildrenRole:
        return QVariant::fromValue(item->hasChildren());
    default:
        return QVariant();
    }
}

int TreeModel::rowCount(const QModelIndex &parent) const
{
    Q_UNUSED(parent)
    return items.size();
}


Метод rowCount() не представляет интереса. Метод data() по пришедшей в параметре роли возвращает нужные данные.

Метод открытия элемента:

void TreeModel::openItem(int numIndex)
{
    if (numIndex > (items.size()-1))
        return;
    if (items[numIndex]->isOpened)
        return;
    QModelIndex modelIndex = index(numIndex);
    //Выставляем флаг открытого элемента
    items[numIndex]->isOpened = true;
    //Оповещаем QML-код об изменении данных
    emit dataChanged(modelIndex, modelIndex);
    int i = numIndex+1;
    //Оповещаем QML-код о том, что будут добавлены строки в модель
    beginInsertRows(QModelIndex(), i, i+items[numIndex]->children.size()-1);
    //Добавляем всех потомков элемента в модель после этого элемента
    foreach(TreeModelItem *item, items[numIndex]->children)
        items.insert(i++, item);
    //Оповещаем QML-код о том, что все строки добавлены
    endInsertRows();
}


Метод openItem() выставляет флаг открытого элемента и по очереди добавляет всех потомков текущего элемента в модель после этого элемента.

Метод закрытия элемента:

void TreeModel::closeItem(int numIndex)
{
    if (numIndex > (items.size()-1))
        return;
    if (!items[numIndex]->isOpened)
        return;
    QModelIndex modelIndex = index(numIndex);
    //Сбрасываем флаг открытого элемента
    items[numIndex]->isOpened = false;
    //Оповещаем QML-код об изменении данных
    emit dataChanged(modelIndex, modelIndex);
    int i = numIndex+1;
    //Ищем все элементы после текущего с большим level
    //Таким образом найдем всех прямых и косвенных потомков
    for (; i < items.size() && (items[i]->level > items[numIndex]->level); ++i) {}
    --i;
    //Оповещаем QML-код о том, что будут удалены строки из модели
    beginRemoveRows(QModelIndex(), numIndex+1, i);
    //Удаляем все посчитанные элементы из модели и сбрасываем у них флаг открытия
    while (i > numIndex)
    {
        items[i]->isOpened = false;
        items.removeAt(i--);

    }
    //Оповещаем QML-код о том, что все строки удалены
    endRemoveRows();
}

Метод closeItem() сбрасывает флаг открытого элемента у текущего элемента и у всех его прямых и косвенных потомков (чтобы при повторном открытии не учитывать возможных открытых потомков, а считать что они все закрыты), ищет в модели все элементы с level большим чем у текущего элемента и удаляет их из модели.

Осталось только отобразить QML и передать ему экземпляр нашей модели (main.cpp):

#include < QtGui/QApplication>
#include < QtDeclarative>

#include "treemodel.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    QDeclarativeView view;
    TreeModel *model = new TreeModel;
    view.rootContext()->setContextProperty("treeModel", model);
    view.setSource(QUrl("qrc:/Main.qml"));
    view.show();
    return a.exec();
}


Заключение
Вышеприведенный код это только пример реализации и содержит множество упрощений, которые вряд ли будут допустимы в реальном применении. Тем не менее, он отображает общую идею способа.

9 комментариев:

  1. Замечательно.
    Если будет много таких отличных технических статей. Буду постоянным читателем.

    ОтветитьУдалить
  2. Fastman, статьи конечно будут, но не регулярно.

    ОтветитьУдалить
  3. Я как раз мучаюсь построить Трее (используя MVC) из 4 таблиц бд.
    Таблицы имеют структуру:
    1.Категорий товаров (ИД,Наименование ....)
    2.Релэшыны (связки между категориями Парент-Чилд)
    3.Товары (ИД...)
    4.Релэшыны (связки между товарами и категориями)
    На форумах нашел тему для меня ... немного переделал запрос (выбираю из 2 таблиц) ... а вот как прикрутить еще 2 никак не пойму ...
    Кто может помочи?

    ОтветитьУдалить
  4. victor, ну вопрос не совсем по теме поста, так что советую вам спросить на форуме prog.org.ru или на developer.qt.nokia.com .

    ОтветитьУдалить
  5. Спасибо! Как раз срочно понадобился такой компонент.

    ОтветитьУдалить
  6. Познавательно.
    А как лучше реализовать такое дерево для контакт-листа?

    типа

    group_1
    - contact_1_1
    - contact_1_2
    group_2
    - contact_2_1
    - contact_2_2


    c учётом того, что высота и дизайн итема group и contact разные...

    ОтветитьУдалить
  7. Можно, например, сделать в делегате Loader, который будет грузить разные .qml файлы для разных типов.

    ОтветитьУдалить
  8. Этот комментарий был удален автором.

    ОтветитьУдалить
  9. Жалко, что QML умеет только перебирать индексы и не умеет использовать вложенность в AbstractItemModel. То есть для нескольких ListView, идущих друг за другом приходится создавать QList (требуется одним классом выгребать данные всех ListView).

    ОтветитьУдалить