IT Notes

Модель-представление в Qt

Введение

Мы уже затрагивали тему паттернов ООП. Более того, в заметке Паттерн MVC на примере Cапера мы разработали полноценную игру на основе одного из них. Но пришло время заняться чем-то более серьезным.

Табличные представления весьма распространены в приложениях, которые работают с данными. Например, в заметке QProgressBar в таблице QTableView мы коротко рассмотрели работу с делегатами. Но это была в большей степени работа на стороне представления. Сейчас же мы сосредоточимся на применении моделей.

Чем мы займемся?

Как обычно, не будем ничего усложнять. Qt, как и другие продуманные библиотеки, хорош тем, что изучив один пример на какую-то тему, вы можете применять эти знания многократно. Вот и сейчас мы рассмотрим работу с моделями и представлениями в Qt на примере таблицы. Однако вы сможете использовать те же приемы и для других комбинаций модели/представления (например, для списков и деревьев).

Давайте реализуем приложение, которое позволяет добавлять и удалять произвольные ФИО. Для удаления записей ФИО мы предусмотрим в таблице дополнительный столбец. Этот столбец будет принимать логические значения, то есть True или False. Будем считать, что запись отмечена для удаления, если в этом столбце стоит значение True. Само же удаление будет происходить после нажатия соответствующий кнопки подтверждения.

Ничего особо сложного, поэтому приступим.

Объявление виджета приложения

Начнем с объявления главного виджета:

#include <QWidget>

class QTableView;
class QLineEdit;

class PersonsModel;

class ModelViewDemoWidget : public QWidget {
    Q_OBJECT

public:
    ModelViewDemoWidget( QWidget* parent = 0 );
    ~ModelViewDemoWidget();

private slots:
    void onAppend();

private:
    QTableView* m_view;
    PersonsModel* m_model;

    QLineEdit* m_surnameEdit;
    QLineEdit* m_nameEdit;
    QLineEdit* m_patronymicEdit;
};

Объявление Модели

Думаю, что вы обратили внимание на объявление класса PersonsModel. Это и будет наша модель, которую мы подключим к представлению QTableView. Посмотрим, что она из себя представляет:

#include <QAbstractTableModel>

class PersonsModel : public QAbstractTableModel {
    Q_OBJECT
public:
    PersonsModel( QObject* parent = 0 );

    int rowCount( const QModelIndex& parent ) const;
    int columnCount( const QModelIndex& parent ) const;
    QVariant data( const QModelIndex& index, int role ) const;
    bool setData( const QModelIndex& index, const QVariant& value, int role );
    QVariant headerData( int section, Qt::Orientation orientation, int role ) const;
    Qt::ItemFlags flags( const QModelIndex& index ) const;

    void appendPerson( const QString& surname, const QString& name, const QString& patronymic );

public slots:
    void removeSelected();

private:
    enum Column {
        SURNAME = 0,
        NAME,
        PATRONYMIC,
        SELECTION,
        LAST
    };

    typedef QHash< Column, QVariant > PersonData;
    typedef QList< PersonData > Persons;
    Persons m_persons;

};

Модель PersonsModel реализует класс QAbstractTableModel. При этом мы переопределяем некоторые виртуальные функции-члены, с которыми разберемся чуть позже. Мы же добавили только appendPerson() и removeSelected(). Их назначение понять не сложно. Первый предназначен для добавления новой строки ФИО в таблицу, а второй для удаления строк, которые были отмечены.

Кроме того, мы определили перечисление Column для индексации столбцов нашей таблицы. В первом столбце мы поместим фамилию, во втором - имя, в третьем - отчество, а последний оставим для отметок на удаление.

Данные модели мы будем хранить в списке хэш-карт. Ключом хэш-карты будет индекс столбца. И к нему мы будем привязывать данные произвольного типа QVariant.

Реализация главного виджета

Но сначала закончим с виджетом ModelViewDemoWidget:

#include <QTableView>
#include <QHeaderView>
#include <QLayout>
#include <QPushButton>
#include <QLabel>
#include <QLineEdit>

ModelViewDemoWidget::ModelViewDemoWidget( QWidget* parent ) : QWidget( parent ) {
    QVBoxLayout* mainLayout = new QVBoxLayout;
    setLayout( mainLayout );

    m_view = new QTableView;
    m_view->horizontalHeader()->setResizeMode( QHeaderView::Stretch );
    m_view->setModel( m_model = new PersonsModel );
    mainLayout->addWidget( m_view );

    QGridLayout* panelLayout = new QGridLayout;
    mainLayout->addLayout( panelLayout );

    QLabel* lbRemove = new QLabel( trUtf8( "<a href=\"#\">Удалить</a>" ) );
    connect( lbRemove, SIGNAL( linkActivated( QString ) ), m_model, SLOT( removeSelected() ) );
    lbRemove->setAlignment( Qt::AlignRight );
    panelLayout->addWidget( lbRemove, 0, 6 );

    QLabel* lbSurname = new QLabel( trUtf8( "Фамилия" ) );
    panelLayout->addWidget( lbSurname, 1, 0 );
    m_surnameEdit = new QLineEdit;
    panelLayout->addWidget( m_surnameEdit, 1, 1 );

    QLabel* lbName = new QLabel( trUtf8( "Имя" ) );
    panelLayout->addWidget( lbName, 1, 2 );
    m_nameEdit = new QLineEdit;
    panelLayout->addWidget( m_nameEdit, 1, 3 );

    QLabel* lbPatronymic = new QLabel( trUtf8( "Отчество" ) );
    panelLayout->addWidget( lbPatronymic, 1, 4 );
    m_patronymicEdit = new QLineEdit;
    panelLayout->addWidget( m_patronymicEdit, 1, 5 );

    QPushButton* bnAdd = new QPushButton( trUtf8( "Добавить" ) );
    connect( bnAdd, SIGNAL( clicked() ), SLOT( onAppend() ) );
    panelLayout->addWidget( bnAdd, 1, 6 );

    resize( 800, 600 );
}

ModelViewDemoWidget::~ModelViewDemoWidget() {
}

void ModelViewDemoWidget::onAppend() {
    m_model->appendPerson(
        m_surnameEdit->text(),
        m_nameEdit->text(),
        m_patronymicEdit->text()
    );
}

Здесь все весьма предсказуемо. Однако обратите внимание на строку:

m_view->setModel( m_model = new PersonsModel );

В этом месте мы создаем нашу модель и связываем ее с представлением.

Реализация Модели

Теперь переходим к реализации модели. А начнем мы с нескольких базовых функций, которые задают структуру таблицы:

PersonsModel::PersonsModel( QObject* parent ) : QAbstractTableModel( parent ) {
}

int PersonsModel::rowCount( const QModelIndex& parent ) const {
    Q_UNUSED( parent )
    return m_persons.count();
}

int PersonsModel::columnCount( const QModelIndex& parent ) const {
    Q_UNUSED( parent )
    return LAST;
}

QVariant PersonsModel::headerData( int section, Qt::Orientation orientation, int role ) const {
    if( role != Qt::DisplayRole ) {
        return QVariant();
    }

    if( orientation == Qt::Vertical ) {
        return section;
    }

    switch( section ) {
    case SURNAME:
        return trUtf8( "Фамилия" );
    case NAME:
        return trUtf8( "Имя" );
    case PATRONYMIC:
        return trUtf8( "Отчество" );
    case SELECTION:
        return trUtf8( "Выбор" );
    }

    return QVariant();
}

В rowCount() мы просто возвращаем количество элементов в списке, а в columnCount() - заранее заготовленное значение из перечисления Columns (такой прием удобен тем, что если нам придется добавлять в таблицу столбцы, то оставив LAST на последнем месте перечисления, его значение все равно будет равно количеству столбцов, но уже новому).

Функция headerData() должна вернуть заголовок, который будет отображаться в шапке таблицы сверху для каждого столбца или слева для каждой строки. Она принимает три параметра: номер секции (то есть столбца или строки), ориентацию (вертикально или горизонтально) и роль. В данном случае нас интересует лишь роль для отображения (есть и другие), поэтому мы сразу делаем соответствующую проверку и возвращаем пустое значение QVariant, если что-то не так. Далее мы проверяем ориентацию. Если она была вертикальной, то мы возвращаем номер секции, чтобы отображался номер строки. А для горизонтальной ориентации мы возвращаем текстовые значения полей с помощью оператора switch по номеру секции.

Если мы запустим приложение сейчас, оставив для оставшихся функций реализации по умолчанию, то должны увидеть следующее:

model_view_demo

Конечно, все сделано без изысков, но мы успешно подключили модель к представлению. Первый шаг сделан. Теперь реализуем добавление записей:

QVariant PersonsModel::data( const QModelIndex& index, int role ) const {
    if(
        !index.isValid() ||
        m_persons.count() <= index.row() ||
        ( role != Qt::DisplayRole && role != Qt::EditRole )
    ) {
        return QVariant();
    }

    return m_persons[ index.row() ][ Column( index.column() ) ];
}

Qt::ItemFlags PersonsModel::flags( const QModelIndex& index ) const {
    Qt::ItemFlags flags = QAbstractTableModel::flags( index );
    if( index.isValid() ) {
        if( index.column() == SELECTION ) {
            flags |= Qt::ItemIsEditable;
        }
    }

    return flags;
}

void PersonsModel::appendPerson( const QString& surname, const QString& name, const QString& patronymic ) {
    PersonData person;
    person[ SURNAME ] = surname;
    person[ NAME ] = name;
    person[ PATRONYMIC ] = patronymic;
    person[ SELECTION ] = false;

    int row = m_persons.count();
    beginInsertRows( QModelIndex(), row, row );
    m_persons.append( person );
    endInsertRows();
}

Функция-член data() должна вернуть данные ячейки. Для этого ей передаются индекс QModelIndex и роль. Индекс модели содержит довольно много информации о ячейке, на которую указывает, но нам будет достаточно узнать ряд и столбец. Однако сначала мы проверяем, что все переданные параметры в порядке. Мы смотрим, чтобы индекс был действительным, ряд не превосходил число строк в таблице, а роль предназначалась либо для отображения, либо для редактирования. Для краткости я опустил проверку номера столбца, поскольку при запросе по хэш-карте это не вызовет серьезных проблем, но сюда можно было добавить и ее. Вернуть же значение по позиции ячейки в таблице совсем просто:

return m_persons[ index.row() ][ Column( index.column() ) ];

Функцию flags() мы переопределили для того, чтобы представление знало наши намерения относительно ожидаемого поведения ячеек. Мы могли бы провести тонкую настройку параметров для каждой ячейки, но в большинстве случаев это не имеет смысла. Для нашего приложения мы лишь добавили флаг ItemIsEditable для последнего столбца SELECTION, чтобы обеспечить возможность выбора строк для удаления.

В нашей функции appendPerson() мы просто добавляем в список новую запись с ФИО. Однако обратите внимание на фрагмент:

beginInsertRows( QModelIndex(), row, row );
m_persons.append( person );
endInsertRows();

Чтобы представление знало о том, что мы вставили новые строки, необходимо сообщить ему об этом явно. Это делается с помощью комбинации вызовов beginInsertRows() и endInsertRows(). Причем, в первом из них мы должны указать диапазон строк, в котором произойдут изменения. Но поскольку мы вставляем всего одну запись за раз, то нам достаточно передать пустой диапазон из одного значения.

Этого кода уже хватит, чтобы заполнить таблицу. Вот что получилось:

model_view_demo_append_test

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

Но если вы попробуете запустить это приложение, то заметите, что значения в последнем столбце изменить можно, но они не сбрасываются обратно в значение False. Что не так? - Все дело в том, что мы еще не реализовали функцию-член setData(), которая отвечает за присвоение значений ячейкам, полученным из представления. А делается это очень схожим образом с тем, как мы это реализовали ранее для data():

bool PersonsModel::setData( const QModelIndex& index, const QVariant& value, int role ) {
    if( !index.isValid() || role != Qt::EditRole || m_persons.count() <= index.row() ) {
        return false;
    }

    m_persons[ index.row() ][ Column( index.column() ) ] = value;
    emit dataChanged( index, index );

    return true;
}

Сначала мы проводим некоторые проверки входных параметров. Они практически полностью совпадают с тем, что было в data(). Затем мы выполняем присвоение значения для элемента списка m_persons и вызываем сигнал dataChanged(), который сообщает представлению о том, что что-то изменилось. Чтобы оптимизировать этот процесс и не перерисовывать все представление, dataChanged() принимает два индекса для указания области таблицы, ограниченной двумя ячейками в левом верхнем углу и правом нижнем. Однако поскольку мы меняем всего одну ячейку, то ни о каких диапазонах нам думать не приходится.

Ура! Теперь мы можем менять значения для ячеек последнего столбца и все сохраняется:

model_view_demo_select_test

Теперь осталось добавить лишь функцию удаления. Так давайте сделаем это:

void PersonsModel::removeSelected() {
    int i = 0;
    Persons::iterator it = m_persons.begin();
    while( it != m_persons.end() ) {
        if( it->value( SELECTION, false ).toBool() ) {
            beginRemoveRows( QModelIndex(), i, i );
            it = m_persons.erase( it );
            endRemoveRows();
        } else {
            ++i;
            ++it;
        }
    }
}

В removeSelected() мы проходим по каждому элементу списка в цикле и проверяем состояние поля SELECTION. Если оно равно True, то текущий элемент удаляется. Следует заметить, что как и в случае добавления строк, нам нужно явно сообщать об изменениях в представление. Для этого мы используем beginRemoveRows() и endRemoveRows().

Посмотрим, что произойдет, если сейчас в приложении нажать Удалить:

model_view_demo_remove

Отлично. Отмеченная запись ФИО была удалена, а остальные никуда не делись. Программа работает. Однако в реальном приложении не лишним было бы реализовать хранение данных модели в БД. Кроме того, не помешало бы предусмотреть возможность экспорта и импорта данных в файлы, чтобы можно было передать их кому-нибудь.

Заключение

Вот мы и рассмотрели основы работы с концепцией модель-представление в Qt на примере таблицы. Полученных знаний хватит вам для реализации достаточно сложных приложений с динамичным изменением структуры и данных таблиц, а также схожих с ними виджетов Qt.

Исходники

 Скачать пример использования Модели-Представления в Qt

Похожие публикации

Комментарии

Добрый день. У Вас много полезных статей, но, мне кажется, не хватает исходников проектов. Мне, как новичку в QT, они бы очень помогли в освоении. Спасибо.

Здравствуйте, Алексей. Спасибо за отзыв. Вы правы. При выпуске новых статей возьму за правило прикладывать исходники проектов в конце публикации.

Для уже выпущенных статей попытаюсь добавить исходники по мере возможностей. Но быстрых поправок не обещаю.

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

Anonymous:

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

Извиняюсь, недоглядел.

Добрый день! Спасибо за статью. Вопрос от новичка. Не очень поняла, как связываются файлы, в которых прописаны модель и представление, объявление которых происходит в первом листинге. Возможно, вопрос глупый, но что из этого в .h, а что .cpp, и куда что подключать?

Newbie:

Добрый день! Спасибо за статью. Вопрос от новичка. Не очень поняла, как связываются файлы, в которых прописаны модель и представление, объявление которых происходит в первом листинге. Возможно, вопрос глупый, но что из этого в .h, а что .cpp, и куда что подключать?

Здравствуйте. Спасибо за комментарий.

В заголовочные файлы уместно вынести листинги, приведенные в разделах "Объявление виджета приложения" и "Объявление Модели". Можно завести два отдельных h-файла, или поместить все в один. Тогда в cpp-файлах будет размещен код из разделов "Реализация главного виджета" и "Реализация Модели".

Добавил в конец статьи раздел с исходниками. Думаю, это должно помочь.

Спасибо за статью. Маленькое замечание: В PersonsModel::removeSelected() проверка

if( it == m_persons.end() ) {

break;

}

лишняя. Она дублирует условие цикла while().

Anonymous:

Спасибо за статью. Маленькое замечание: В PersonsModel::removeSelected() проверка

if( it == m_persons.end() ) {

break;

}

лишняя. Она дублирует условие цикла while().

Здравствуйте. Спасибо за комментарий. Исправил.

Здравствуйте.

Потокобезопасна модель или нет?

Юрий:

Здравствуйте.

Потокобезопасна модель или нет?

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

Здравствуйте.

Есть вариант решения если заранее не известно количество столбцов?

Anonymous:

Здравствуйте.

Есть вариант решения если заранее не известно количество столбцов?

Здравствуйте. Подобной статьи у меня нет. Однако технически работа со столбцами через Модель практически идентична работе со строками. Отличие лишь в том, что используется ключевое слово Column, а не Row.