Qt - Transformation d'une vue tableau en une vue hiérarchique

Posté le 21. September 2009 dans Programmation

Pour visualiser des données internes à l'écran, Nokia/Qt nous propose l'architecture MVC à l'aide des classes QAbstractItemModel et de ses sous classes (QAbstractListModel, QAbstractTableModel, ...). Le but de l'architecture MVC est de séparer la représentation mémoire des données, de l'affichage qu'elles auront.

mvc1

Si les données sont par exemple issue d'une requête SQL, le modèle QSqlQueryModel permet de représenter la sélection SQL, à l'écran dans un composant QTableView. Ces données sont alors représentées sous forme d'un tableau deux dimensions.

tableau

Si l'on veut représenter les dossiers de l'ordinateur, le modèle QDirView permet d'afficher les données de façon hiérarchique.

qdirview

Il est également possible d'écrire nos propres modèles pour représenter nos propres données.

Le but de cet article est de présenter l'écriture d'un modèle, transformant une vue plane (tableau deux dimensions) en vue hiérarchique. Pour cela nous allons prendre l'exemple de l'affichage d'une liste de catégorie1.

Présentation de l'exemple

Nous allons voir ci-dessous comment transformer une vue plane en une vue hiérarchique. Pour cela nous allons prendre l'exemple d'une liste de catégorie. Ces catégories seront stockées dans une base de données Sqlite. Ceci n'a pas d'importance mais permet d'expliquer pourquoi les données en mémoire ne sont pas déjà sous forme hiérarchique.

proxymodel

La description d'une catégorie passe donc par :

  1. un identifiant unique appelé id
  2. un nom de catégorie
  3. un lien vers la catégorie parente appelé parent_id

C'est le champ parent_id qui permettra de déterminer dans quelle catégorie notre sous-catégorie se trouve. Dans notre cas, test3 a comme parent_id l'identifiant 11 qui est la catégorie test.

Dans notre exemple, nous allons nous conditionner à un modèle en lecture seul. Il est également possible de faire un modèle en lecture/écriture mais nous n'en parlerons pas dans cet article. Nous nous cantonnerons donc à un modèle lecture seul sur un modèle source de type QSqlTableView. (Un modèle en lecture/écriture peut nécessiter de mettre à jour notre structure, ce qui peut être assez compliqué, par exemple en cas de changement de la catégorie parente).

Déclaration d'un modèle

Qu'est qu'un proxy ?

Notre interface se basera sur la classe QAbstractProxyModel. Le but de cette classe est de permettre de faire une transposition entre un premier modèle et une vue. Elle peut par exemple (à l'aide de QSortFilterProxyModel) permettre de filtrer ou trier les données d'une vue.

mvc

Cette vue nous permettra de faire la transposition entre un modèle tableau et une vue arborescente.

Interface du proxy

Les méthodes à ré-implémenter pour faire fonctionner une proxy, sont les suivantes :

virtual QModelIndex index( int row, int column, const QModelIndex & parent ) const;
virtual QModelIndex parent( const QModelIndex & index ) const;

virtual int rowCount( const QModelIndex & parent = QModelIndex() ) const;
virtual int columnCount( const QModelIndex & parent = QModelIndex() ) const;

virtual QModelIndex mapFromSource ( const QModelIndex & sourceIndex ) const;
virtual QModelIndex mapToSource ( const QModelIndex & proxyIndex ) const;

Les quatre premières méthodes appartiennent à l'objet QAbstractItemModel et seront questionnées par la vue (liste, arbre, …) pour connaître la représentation à l'écran.

Les méthodes mapFromSource(), et mapToSource() sont à implémenter pour l'objet QAbstractProxyModel. Elles permettent de faire la liaison entre les indexes (QModelIndex) du modèle d'origine et les indexes du nouveau modèle.

Les méthodes data(), headerData(), flags() sont réimplémentées par l'objet QAbstractProxyModel et utilisent les méthodes mapFromSource() et mapToSource() pour, lors de l'appel d'une de ces méthodes, récupérer les informations stockées sur le modèle d'origine.

Des méthodes sourceModel() et setSourceModel() permettent de spécifier le modèle d'origine.

Implémentation de notre modèle

Structure interne

Nous allons, dans cette partie, parler de la structure interne utilisée par notre objet.

struct Mapping {
    int id, parrentId;
    int index, parentIndex;
    QVector<int> source_rows;
};
typedef QMap<int,Mapping*> IndexMap;

IndexMap m_sourcesIndexMapping;
QHash<int,int> m_categoryIdMapping;

Nous allons créer une structure Mapping qui associera pour une ligne dans le modèle source, les informations concernant :

  1. l'identifiant de la ligne.
  2. l'identifiant de la ligne parente.
  3. l'index dans le modèle source.
  4. l'index du parent dans le modèle source.
  5. la liste des indexes dans le modèle source des enfants dans notre modèle.

Une table de hashage permettra de retrouver à partir d'un identifiant (id), la ligne dans le modèle source correspondante. On ne gardera que la seconde colonne (le nom de la catégorie), on ne fera donc l'association qu'entre le nom de la colonne de notre modèle et le modèle source en dure dans le programme.

createMapping()

Le but de cette méthode est de générer l'arborescence qui sera ensuite utilisée dans la suite des méthodes. Cette arborescence étant générée une fois pour toute (ou à chaque fois que le modèle source sera réinitialisé), il faudra parcourir l'ensemble des lignes du modèle source pour construire notre arbre. Parcourir l'ensemble des lignes du modèle source peut prendre du temps si ce dernier contient beaucoup de ligne.

Nous allons considérer dans la suite, que l'identifiant de ligne 1 (id) correspond à la catégorie de plus haut niveau (ci-dessus du nom de Categories). Nous allons créer une catégorie fictive 0 qui correspondra à la racine de notre modèle. Cette catégorie sera donc la catégorie parente de celle à l'identifiant 1.Dans notre modèle source, la catégorie 0 n'a pas d'équivalence parmi les lignes du modèle source. Nous allons donc lui associer la ligne -1 dans le modèle source.

Maintenant voyons la génération de la structure interne.

qDeleteAll( m_sourcesIndexMapping );
m_sourcesIndexMapping.clear();
m_categoryIdMapping.clear();

Nous commençons ci-dessous par nettoyer les différentes structures afin de les régénérer. Il faudra à la suite de la création du mapping appeler la méthode reset() afin de prévenir la vue que l'ensemble des indexes du modèle sont obsolètes.

int source_rows = m_sourceModel->rowCount();
for( int i = -1; i < source_rows; ++i ) {
    Mapping * m = new Mapping;
    IndexMap::const_iterator it = IndexMap::const_iterator( m_sourcesIndexMapping.insert( i, m ) );
    m->index = i;
    m->parrentId = 0;
    m->parentIndex = 0;

    if( i >= 0 ) {
        QSqlRecord record = m_sourceModel->record( i );
        m_categoryIdMapping[ record.value( list_id ).toInt() ] = i;
        m->id = record.value( list_id ).toInt();
    } else { // Create the root Item
        m->id = 0;
        m_categoryIdMapping[ 0 ] = -1;
    }
}

Nous récupérons le nombre de ligne dans le modèle source et commençons à initialiser notre structure. Le but de cette boucle est surtout d'initialiser dans notre correspondance identifiant/index (m_categoryIdMapping), l'emplacement des identifiants dans notre tableau. Ainsi dans la suite lorsque l'on essaiera d'associer une ligne à son parent, nous pourrons savoir à quel emplacement retrouver notre id dans la structure m_sourcesIndexMapping.

Nous profitons également de cette boucle pour initialiser les différents champs de notre structure comme l'index (qui correspond à la ligne dans le modèle source), et l'id qui correspond à un identifiant unique dans notre liste.

Nous n'oublions pas notre cas particulier que représente notre racine (ligne source -1 inexistante).

for( int i = 0; i < source_rows; ++i ) {
    QSqlRecord record = m_sourceModel->record( i );

    int parentCategoryId = record.value( list_parentid ).toInt();
    int parentCategoryIndex = m_categoryIdMapping.value( parentCategoryId, -2 );
    Q_ASSERT( parentCategoryIndex > -2 );
    Mapping * mapping = m_sourcesIndexMapping.value( i );
    mapping->parentIndex = parentCategoryIndex;
    mapping->parrentId   = parentCategoryId;

    Mapping * categoryMapping = m_sourcesIndexMapping.value( parentCategoryIndex );
    categoryMapping->source_rows.append( i );
}

Enfin pour chaque enregistrement de notre modèle source, nous récupérons l'identifiant du parent et recherchons dans la structure que nous venons de créer l'index dans le modèle source de la catégorie parente.

Ces valeurs sont alors renseignées dans le modèle. Nos ajoutons également dans la structure de mapping de la catégorie parent, notre ligne dans la liste des enfants.

Notre structure est ainsi complètement initialisé. Un identifiant connaît donc son parent, et la liste de ses enfants. C'est sur cette structure que se basera le reste des méthodes de notre modèle.

mapFromSource()

Le but de cette méthode est de convertir notre index venant du modèle source vers un index de notre modèle à nous.

L'index du modèle source nous donne l'emplacement de la donnée dans le modèle source sous forme (ligne, colonne) alors que l'index de notre modèle doit donner l'emplacement dans notre modèle sous la forme (ligne, colonne, pointeur indiquant le parent).

if( ! sourceIndex.isValid() ) return QModelIndex();
if( sourceIndex.model() != m_sourceModel ) {
    qWarning( "CategoryItemModel: index from wrong model passed to mapFromSource" );
    return QModelIndex();
}

Il faut donc d'abord vérifier que l'index du modèle source est valide. S'il n'est pas valide, alors il ne sera pas valide non plus dans notre modèle à nous.

int row = sourceIndex.row();
IndexMap::const_iterator it = m_sourcesIndexMapping.constFind( row );
Q_ASSERT( it != m_sourcesIndexMapping.constEnd() );

Nous récupérons l'index dans le modèle source, et à l'aide de notre tableau d'index source, nous allons retrouver l'objet Mapping correspondant. Cette objet Mapping correspondra à celui de notre index source. Ce dont nous avons besoin pour notre modèle est l'objet Mapping de l'objet parent (En effet, notre index est construit sous la forme : ligne, colonne, pointeur sur la structure Mapping du parent de notre index). Nous récupérons donc l'index de la ligne source dans l'objet parent.

int parentRow = it.value()->parentIndex;
IndexMap::const_iterator parentIt = m_sourcesIndexMapping.constFind( parentRow );
Q_ASSERT( parentIt != m_sourcesIndexMapping.constEnd() );

Mapping * m = parentIt.value();

Cela nous permet de récupérer l'objet Mapping de l'index parent.

int proxyRow    = m->source_rows.indexOf( row );
int proxyColumn = sourceColumnToProxy( sourceIndex.column() );
if( proxyColumn == -1 ) return QModelIndex();

La méthode sourceColumnToProxy() permet de transformer une colonne de l'objet source en une colonne de l'objet courant. Cela signifie que la colonne 2 sera convertie en colonne 1, et que les colonnes 1 et 3 ne seront pas converties (et cachées). Dans ce dernier cas, nous retournons QModelIndex().

L'index de la ligne dans notre modèle sera donné par la position dans la liste source_rows dans notre structure de conversion.

return createIndex( proxyRow, proxyColumn, *parentIt );

Nous pouvons alors créer notre index.

mapToSource()

De la manière inverse à la méthode mapFromSource(), cette méthode permet de convertir un index de notre objet en un index du modèle source.

if( ! proxyIndex.isValid() ) return QModelIndex();
if( proxyIndex.model() != this ) {
    qWarning( "CategoryItemModel: index from wrong model passed to mapToSource" );
    return QModelIndex();
}

Si le modèle est invalide dans notre modèle, il l'est aussi dans le modèle source. Il n'y a pas d'équivalent dans le modèle source de l'index racine.

Mapping * m = static_cast<Mapping*>( proxyIndex.internalPointer() );

Nous récupérons la structure dans le pointeur interne de notre index.

int sourceColumn = proxyColumnToSource( proxyIndex.column() );
if( sourceColumn == -1 ) return QModelIndex();

Nous récupérons également les informations sur notre colonne (Soit, conversion de la colonne 0 dans notre modèle, en la colonne 1 dans le modèle source).

int sourceRow = m->source_rows.at( proxyIndex.row() );

Nous recherchons dans notre liste source_rows la ligne dans le modèle source qui correspond à la ligne (relative au père) indiqué par notre index et nous construisons un index de type (ligne, colonne) correspondant au modèle source.

return m_sourceModel->index( sourceRow, sourceColumn );

L'implémentation des méthodes mapFromSource() et mapToSource() permettent de faire fonctionner les implémentations des méthodes data(), headerData(), et flags(). Il n'y aura donc aucun intérêt à ré-implémenter ces méthodes à moins de vouloir traiter les données de ces fonctions. Nous ne les décrirons donc pas ici, mais vous pourrez voir dans le fichier attaché à la fin du billet, un exemple d'utilisation.

index()

Le principe de cette méthode est de générer un index pour notre modèle. L'index doit être valide et réutilisable dans les méthodes rowCount(), flags(), columnCount(), data(), mapToSource().

if( ( row < 0 ) || ( column < 0 ) ) return QModelIndex();

Si la ligne ou la colonne est inférieure à 0, l'index n'est pas valide.

IndexMap::const_iterator it = m_sourcesIndexMapping.constFind( -1 );

QModelIndex sourceParent = mapToSource( parent );
if( sourceParent.isValid() ) {
    it = m_sourcesIndexMapping.constFind( sourceParent.row() );
}

A partir de l'index parent nous retrouvons l'index qui correspond dans le modèle source (sourceParent'). Si l'on ne trouve pas d'index dans le modèle source, nous considérons être sur l'élément racine de notre arbre. Sinon nous recherchons dans la table deMapping'' la structure qui correspond.

Q_ASSERT( it != m_sourcesIndexMapping.constEnd() );
if( it.value()->source_rows.count() <= row )
    return QModelIndex();

Nous vérifions le nombre d'élément dans la structure et nous retournons un index non valide si l'index demandé va au delà de la taille du tableau.

return createIndex( row, column, *it );

Finalement nous retournons l'index créé avec le pointeur vers la structure Mapping du père en internalData.

parent()

Cette méthode permet de retourner pour l'index donné du proxy, l'index du parent. Un index invalide n'a pas de parent.

if( ! index.isValid() ) return QModelIndex();

Mapping * m = static_cast<Mapping*>( index.internalPointer() );

On récupère la structure de correspondance stockée dans le pointeur interne de l'objet. Cette structure nous donne les informations du parent (en effet dans internalPointer(), on stock la structure Mapping du parent), et donc l'index dans le modèle source.

int sourceRow = m->index;
if( sourceRow == -1 ) return QModelIndex();

QModelIndex sourceParent = m_sourceModel->index( sourceRow, proxyColumnToSource( 0 ) );
QModelIndex proxyParent = mapFromSource( sourceParent )

On utilise alors notre méthode mapFromSource() pour retrouver l'index du parent dans le référentiel du proxy.

return proxyParent;

rowCount()

Le but de cette méthode est de retourner le nombre de ligne enfant pour un index. Dans le modèle source les indexes n'ont pas d'enfant.Lorsque l'index vaut QModelIndex(), cela signifie qu'il faut retourner le nombre de ligne pour notre racine.

if( index.column() > 0 ) return 0;

Seul la première colonne a des enfants.

if( ! index.isValid() ) {
    Mapping * rootMapping = m_sourcesIndexMapping.value( -1 );
    return rootMapping->source_rows.count();

Pour l'index racine, on récupère le nombre de ligne à la ligne -1 du modèle source.

} else {
    Mapping * parrentMapping = static_cast<Mapping*>( index.internalPointer() );
    int sourceRowIndex = parrentMapping->source_rows.at( index.row() );
    Mapping * rowMapping = m_sourcesIndexMapping.value( sourceRowIndex );

    return rowMapping->source_rows.count();

Sinon on récupère la structure stockée dans le pointeur interne. Cette structure est celle du parent de notre objet. On récupère à l'aide de la méthode row() de l'index l'emplacement de la ligne source. Cette ligne source nous permet de récupérer la structure de correspondance de notre index, et ainsi le nombre de ligne de notre index.

}

columnCount()

Notre modèle ne possède qu'une seule colonne.

return 1;

proxyColumnToSource()

Si la colonne du proxy vaut 0, alors on retourne la colonne 1, sinon on retourne la colonne -1 : la colonne n'est pas convertible, elle n'existe pas dans notre proxy.

if( proxyColumn == 0 )
    return list_name;
return -1;

sourceColumToProxy()

Si la colonne source est 1, alors nous convertissons celle-ci en la colonne 0. Sinon la colonne n'existe pas dans notre proxy.

if( sourceColumn == list_name )
    return 0;
return -1;

Les sources

Vous pouvez trouver les sources suivants décrivants ce que l'on trouve ci-dessus :

l'objet QSortFilterProxyModel de Nokia.


  1. La construction de notre objet s'est basé sur l'analyse de