Accueil > Programmation Cocoa > Manipulation des tables - Ecriture d’une application de gestion de carnet d’adresses

Manipulation des tables - Ecriture d’une application de gestion de carnet d’adresses

Par Mike Beam, le 10/08/2001

traduit par Thierry, le 28/05/2002

Une des manières les plus communes d’afficher et d’organiser des données dans une interface est d’utiliser une table. Nous voyons ceci dans les tableurs, les talons de carnets de chèques, les horaires de train, les carnets d’adresses et, même dans le Finder. Les tables sont, après tout, une des manières les plus polyvalentes et efficaces d’afficher une série de données. Dans cet article, nous allons construire une application de carnet d’adresses simple articulée autour d’une table de manière à apprendre comment implémenter les tables sous Cocoa.

La classe Cocoa qui nous fournit les fonctionnalités relatives aux tables est NSTableView. NSTableView prend soin de tout ce qui est requis pour une table tel que la gestion des colonnes, le design du tableau, la récupération des données à partir des sources de données, l’affichage de ces données dans les cellules, et plus encore. L’assemblage d’une table est assez facile, bien qu’il ne se fasse pas de manière très évidente pour un développeur débutant sous Cocoa - il y a bien quelques pièces à emboîter. J’ai quand même passé la majeure partie d’un week-end l’hiver dernier à m’arracher les cheveux à essayer de comprendre comment tout cela fonctionnait.

Cet article est un long article, donc prenez vos aises. Nous allons construire cette application basée sur une interface à coder et à construire. Nous allons tout d’abord construire et mettre en page notre GUI (Graphical User Interface) puis nous allons connecter l’interface à l’objet contrôleur. Dans Interface Builder (IB), nous allons aussi passer un peu de temps à dresser et configurer notre table. Enfin, nous retournerons dans Project Builder (PB) et étalerons le code essentiel requis pour l’exécution de l’application.

Construction de l’interface

Avant de commencer l’interface, nous devons d’abord créer un nouveau projet. Donc, lancer PB et créez en un. Le type de projet sera « Cocoa Application » (sans gestion de document) et nous l’appèlerons « AddressBook ». Avec ce nouveau projet entre les mains, double-cliquez sur MainMenu.nib sous le groupe « Resources » et ouvrez l’interface du AddressBook dans IB.

Le GUI de notre application est constituée des éléments suivants :

  • Quatre champs textes de saisie,
  • Quatre champs textes d’affichage,
  • Trois boutons standard,
  • Une vue de table.

Les vues de tables sont localisées dans la palette « Cocoa Tabulation Views ». Cette palette contient quatre composants différents d’interface : une vue de navigateur, une vue en liste, une vue avec onglets, et une vue de table. Cette dernière est mise en évidence dans l’illustration suivante.


L’objet NSTableView est mis en évidence dans la palette « Cocoa Tabulation Views ».

Pour s’en servir, glisser la simplement à partir de la palette vers l’interface. Poursuivez et placez les autres composants listés précédemment. Appelez les champs d’affichage « First Name », « Last Name », « Email », et « Home Phone », et nommez les trois boutons « Add », « Insert », et « Delete ». Dans l’illustration ci-dessous vous pouvez voir comment j’ai composé mon interface (pour l’instant, ne vous souciez pas des intitulés d’en-tête de colonnes du tableau, nous en reparlerons bientôt).


Voici comment j’ai arrangé mon interface. Vous pouvez faire la vôtre comme bon vous semble (faites juste attention à avoir toutes les bonnes connexions pour plus tard).

L’étape suivante consiste à créer l’objet contrôleur. Rappelez-vous que nous l’avons fait en allant sur le panneau « Classes » de la fenêtre du fichier nib et en créant une sous-classe de la classe NSObject. (Vous pouvez créer une sous-classe très facilement en tapant la touche « Retour » alors que NSObject est sélectionné). Nommez votre objet « Controller ».

L’interface de notre application requiert les cinq outlets suivantes :

  • emailField
  • firstNameField
  • homePhoneField
  • lastNameField
  • tableView

et ces trois actions, pour chaque bouton :

  • addRecord:
  • deleteRecord:
  • insertRecord:

Ajoutez ces actions et ces prises au Controller. Maintenant, créez une instance en le sélectionnant dans la liste des classes et en choisissant « Instantiate » à partir du menu « Classes ».

Avec notre nouvelle instance du contrôleur, nous sommes prêt à connecter les prises et les actions du contrôleur à notre interface. Rappelez-vous que les connexions se font par « contrôle-glisser » à partir d’un objet vers un autre dans la direction qu’emprunteront les messages échangés entre eux. La connexion d’une vue de table n’est pas différente. Poursuivez, connectez tous les objets et sauvegardez votre travail.

Une fois que toutes les connexions nécessaires sont faites entre le contrôleur et les objets de notre interface retournez au panneau « Classes », choisissez la classe du contrôleur, puis sélectionnez « Create Files » à partir du menu « Classes » de manière à ajouter les fichiers interface (.h) et implémentation (.m) de la classe de notre contrôleur à notre projet.

Maintenant, notre interface est presque finie. La dernière chose à faire et de configurer et d’ajuster la vue de notre table pour qu’elle convienne à notre application.

Réglage d’une table

Pour configurer notre vue de table, ouvrez la palette d’informations d’Interface Builder. Elle se trouve sous le menu « Tools » et sous la rubrique « Show Info », ou en tapant Màj-Commande-i. Sélectionnez la vue de table pour en afficher les attributs dans la palette d’informations ; s’ils ne s’affichent pas, sélectionnez « Attributes » dans le menu déroulant situé en haut de la palette.

Ce que nous désirons faire en premier est de changer le nombre de colonnes. Dans le champ # Colms: entrez « 4 », une colonne pour chaque champ de notre carnet d’adresses. Si vous ne pouvez voir toutes les colonnes après en avoir changé le nombre, vous devrez les redimensionner de manière à ce qu’elles conviennent à la vue. Un double-clic sur la vue de table provoquera une sélection nous permettant de changer la taille des colonnes par glissement des lignes séparatrices situées entre les en-têtes de colonne. Quand vous sélectionnez une vue de table par double-clic une ligne épaisse de contour apparaîtra autour de la vue, avec un effet d’ombre.

Pendant que nous en sommes à éditer les attributs de la vue, assurez vous que dans la boite « Allow » les options« Empty Selection » (Sélection vide permise), « Multiple Selection » (Sélection multiple permise), et « Column Selection » (Sélection de colonne permise) sont toute cochées.

L’action suivante consiste à nommer nos colonnes. Après double-clic sur la table (comme nous l’avons fait pour le redimensionnement), vous pourrez sélectionner des colonnes individuellement en cliquant sur chaque en-tête de colonne. Quand vous ferez ceci, la palette d’infos se transformera pour vous montrer les attributs de la colonne sélectionnée (qui est une instance de NSTableColumn), au lieu que celles de la table entière (NSTableView). Là nous devons faire deux choses : mettre le nom qui apparaîtra dans l’en-tête de colonne, et donner à chaque colonne un identifiant unique.

L’identifiant de colonne est une chaîne que nous utiliserons dans le code pour déterminer qui est telle ou telle colonne. Maintenant faisons le réglage de nos colonnes.

J’ai donné à mes colonnes les mêmes noms que ceux des champs textes de l’interface : « First Name », « Last Name », « Email », et « Home Phone » — et j’ai fait la même chose pour les identifiants. Dans les infos NSTableColumn, vous pouvez aussi régler le mode d’alignement du texte dans les en-têtes de colonne et dans les cellules de données. Bien, maintenant il serait temps de sauvegarder notre travail et de contrôler que nous avons correctement paramétré tous les identifiants de colonne. Il ne serait peut être pas idiot d’être vraiment prudent et de vérifier deux fois votre travail dans IB ; car ce sera probablement l’un des derniers endroits où vous irez chercher les erreurs qui vous seront signalées (en tout cas pour moi).

Avant de fermer boutique dans IB, nous avons une dernière chose importante à faire. Pour que la vue de notre table fonctionne correctement, elle doit être capable d’aller quelque part pour obtenir les données qu’elle va afficher : un objet source de données. IB offre la possibilité de connecter une vue de table à un objet source de données de la même manière que nous connectons l’interface aux actions et aux prises.

Dans cette application, nous allons dire que le contrôleur est la source de données. Pour faire cette connexion, assurez-vous d’abord que la vue de table a été sélectionnée par double-clic, puis tirer un fil à partir de l’objet vue de table vers l’objet contrôleur et, dans la palette « Connections », mettez en surbrillance « dataSource » dans la liste des prises (et oui, ce n’est qu’une prise, rien de plus élégant), et enfin, établissez la connexion en cliquant sur le bouton « Connect ».

Sauvegardez votre travail de nouveau dans IB, et allons maintenant dans Project Builder pour écrire notre code.

Codage du carnet d’adresse

Le code de AddressBook comporte trois pièces. La première chose que nous devons faire est de mettre en place des types de structures de données propres à accueillir les enregistrements de notre carnet d’adresses. Puis, nous implémenterons nos méthodes d’action et, enfin, nous permettrons à notre contrôleur de fonctionner en tant que source correcte de données de notre vue de table.

Dictionnaires et Tableaux

En construisant une structure de données pour cette application, nous devons considérer que nous avons deux niveaux de stockage de données. Au premier niveau, nous devons traiter des données individuelles qui supportent le prénom, le nom, l’adresse email et le n° de téléphone d’une personne. Au deuxième niveau, nous devons collecter toutes les données de chaque individu et les stocker d’une manière efficace et commode.

Une manière d’organiser les données d’un enregistrement peut se faire avec une rangée (tableau) où chaque élément (index) correspond à un champ différent : index 0 = prénom, index 1 = nom, et ainsi de suite. Toutefois, cela peut devenir gênant car nous ne pouvons pas référencer directement les champs par leur nom. Si l’on veut une partie d’information d’un enregistrement, nous devons d’abord nous débrouiller pour trouver quel index appeler, puis lire la donnée figurant à cet index. Une meilleure façon de stocker cette information serait de dire simplement à l’enregistrement de retourner le « Prénom » ou le « Téléphone personnel ». Cela tombe bien puisque nous avons une structure de donnée qui répond parfaitement à ce type de besoin : la classe Cocoa NSDictionary.

Un dictionnaire stocke des données sous forme de collections désordonnées de paires formées par des clés et des valeurs. Cela veut dire que chaque morceau de donnée de notre dictionnaire a une clé (un identifiant unique) que nous utilisons pour récupérer la valeur des données associées. Par exemple, dans un vrai dictionnaire, les clés sont les mots inscrits en gras que nous scrutons, et la valeur associée est la définition et l’information grammaticale de ce mot. Nous trouvons la définition d’un mot en recherchant sa clé, le mot. Un carnet d’adresse est un autre exemple de ce concept. Le nom d’une personne est la clé, le n° de téléphone et l’adresse sont la valeur.

Un dictionnaire de données fonctionne de la même manière, à part que nous n’avons pas à tourner des pages et des pages avant de trouver l’information que nous recherchons, ce qui est franchement inefficace. Les dictionnaires de données gèrent ce genre de choses nettement mieux. Un des aspects puissants de NSDictionary tient dans le fait que le type id est donné à la clé, ce qui veut dire que nous pouvons avoir n’importe quel type d’objet comme clé. Ceci est aussi valable pour le type de valeur stockée, nous pouvons stocker tout ce qui est objet dans un dictionnaire. Cela ouvre la porte à des solutions ingénieuses et puissantes. Le carnet d’adresse utilisera simplement des chaînes, mais des applications plus ésotériques pourraient utiliser toute sorte d’objets personnels.

En conséquence, nous allons utiliser des instances de NSDictionary pour représenter chaque enregistrement et stocker les informations d’un individu. La table ci-dessous montre quelle clé aura notre dictionnaire ainsi que quelques valeurs hypothétiques :

Key Value
First Name Mike
Last Name Beam
email Address mikebeam@mail.utexas.edu
Home Phone (512) 123-4567

Remarquez que les clés portent le même nom que les en-têtes de colonnes que nous avons paramétrés avant. Cela deviendra commode et efficace quand sera venu le temps de coder le côté source de données de notre contrôleur.

Le second niveau de l’organisation de nos données porte sur la collection complète de nos enregistrements. La façon de gérer cet aspect se fait en se servant de la classe Cocoa spécifique NSArray. La classe NSArray n’a conceptuellement rien de différent de tout autre type de tableau que vous auriez pu être amené à utiliser en programmation. Un NSArray est juste une collection d’éléments indexés et ordonnés. La seule différence tient dans l’interface qui gère les ajouts, les insertions, les suppressions et les accès aux membres d’une rangée du tableau.

Comme pour les dictionnaires, les tableaux Cocoa nous donnent la possibilité de collecter et d’organiser de manière transparente tout type d’objet. La transparence évoquée tient dans le fait que l’interface d’utilisation d’un tableau de champs texte (nous les utiliserons dans un prochain article) est exactement la même que celle utilisée pour les tableaux de chaînes de caractères, qui n’est pas différente de celle appliquée aux tables de marines et de tanks que l’on peut trouver dans certains jeux « Starcraftesques ».

L’objectif est donc de collecter tous nos enregistrements dans un tableau. Chaque fois que nous crérons un dictionnaire, nous le stockerons dans notre tableau. Chaque fois que nous aurons besoin de récupérer des données de notre dictionnaire, nous sortirons d’abord le dictionnaire de notre tableau.

Du fait que la plupart de nos méthodes utiliseront ce tableau, nous devons lui fournir un point d’accès global. Cela se fait assez simplement en déclarant une nouvelle variable d’instance dans Controller.h. Nous utilisons un tableau transformable (NSMutableArray) en opposition à un tableau statique (NSArray) pour la raison évidente que nous voulons être capables d’ajouter, d’insérer ou de supprimer des enregistrements à tout moment lors de l’exécution de l’application. NSMutableArray est comme NSArray, sauf que vous pouvez en changer le contenu après sa création.

Ajouter la déclaration de la variable d’instance d’un objet NSMutableArray nommé « records » dans Controller.h dans le même bloc de code où sont déclarées les outlets :

NSMutableArray *records;

Avant que toutes nos opérations puissent utiliser le tableau « records », nous devons l’initialiser d’une manière ou d’une autre au démarrage de l’application. La méthode awakeFromNib est un bon endroit pour le faire. Au moment où l’application est chargée et qu’elle décompacte son fichier nib, chaque objet contenu dans le fichier nib reçoit un message awakeFromNib (toutes les classes n’implémentent pas forcément ce message). Nous allons faire en sorte que le contrôleur initialise un nouveau tableau d’enregistrements vides au moment où awakeFromNib sera invoqué.

De retour au contrôleur, dans Controller.m, ajoutez la méthode suivante :

-(void)awakeFromNib { records = [[NSMutableArray alloc] init]; }

Une autre manière d’initialiser ce tableau peut se faire en surpassant -(id)init. Ces deux manières permettront que tout soit prêt au moment où nous en aurons besoin. La première pièce de notre code est maintenant en place, nous pouvons maintenant poursuivre pour implémenter les méthodes d’action que nous avons déclarées dans Interface Builder.

Les Méthodes d’Action

Notre application va fonctionner de telle sorte qu’un utilisateur va entrer les informations relatives à un individu dans les champs textes à cet effet puis va cliquer sur « Add » pour ajouter le nouvel enregistrement à la fin de la liste ou sur « Insert » pour l’insérer juste avant la ligne sélectionnée dans la table. Si l’utilisateur veut supprimer un enregistrement, il mettra en surbrillance la ligne en question dans la table et il cliquera sur le bouton « Delete ».

Notez que deux de ces actions font appel à la création d’un nouvel enregistrement à partir des données entrées. Au lieu d’écrire des lignes redondantes pour addRecord et insertRecord, nous allons créer une nouvelle méthode à partir de la partie commune et nous l’appelerons createRecord. En faisant ça, la maintenance de notre code sera plus facile et moins sujet à erreur lors des modifications. De ce fait, au lieu d’aller dans toutes les méthodes qui créent des enregistrements, nous n’aurons qu’à changer le code dans un endroit unique.

createRecord crée un nouveau dictionnaire d’enregistrement à partir des valeurs entrées dans les champs texte, puis de retourner ce dictionnaire à l’émetteur du message createRecord. Nous allons utiliser la méthode setObject:forKey: de la classe NSMutableDictionary pour ajouter une paire clé-valeur à un dictionnaire. Voyons à quoi cela ressemble dans le code :

-(NSDictionary *)createRecord { NSMutableDictionary *record = [[NSMutableDictionary alloc] init];
[record setObject:[firstNameField stringValue] forKey:@”First Name”];
[record setObject:[lastNameField stringValue] forKey:@”Last Name”];
[record setObject:[emailField stringValue] forKey:@”Email”];
[record setObject:[homePhoneField stringValue] forKey:@”Home Phone”];
[record autorelease]; return record; }

Dans la première ligne, nous avons créé un nouveau dictionnaire vide en utilisant alloc et init : rien de neuf ici. Dans les quatre lignes suivantes, nous ajoutons la chaîne de chaque champ texte, avec une clé appropriée, dans le dictionnaire.

Du fait d’avoir utilisé alloc pour la création de notre nouveau dictionnaire nous devons nous assurer de sa destruction. Mais compte tenu du fait que l’on veut le retourner et laisser l’émetteur du message createRecord prendre possession du dictionnaire retourné, nous devons le rendre auto-destructif. Une autre manière consisterait à avoir recours à un constructeur de commodité (convenience constructor) de la classe NSDictionary ou NSMutableDictionary à la place de alloc ou init et [record autorelease] car les objets retournés par ces constructeurs sont auto-détruits. Cette version de code ressemble à ceci :

-(NSDictionary *)createRecord { NSMutableDictionary *record = [NSMutableDictionary dictionary];
[record setObject:[firstNameField stringValue] forKey:@"First Name"];
[record setObject:[lastNameField stringValue] forKey:@"Last Name"];
[record setObject:[emailField stringValue] forKey:@"Email"];

[record setObject:[homePhoneField stringValue] forKey:@”Home Phone”]; return record; }

Là, nous avons utilisé le constructeur de commodité +dictionary défini dans NSDictionary (et hérité par NSMutableDictionary), dont le seul but est de retourné un dictionnaire vide.

addRecord

Maintenant regardons la première de nos méthodes d’action. addRecord: va utiliser la méthode addObject: de la classe NSMutableDictionary dont le but est d’ajouter l’objet, passé en argument, au tableau récepteur du message (comme suggéré par son nom). L’objet que nous allons ajouter est le dictionnaire retourné par createRecord.

-(IBAction)addRecord:(id)sender { [records addObject:[self createRecord]]; [tableView reloadData]; }

Remarquez la dernière ligne. Quand nous voulons mettre à jour les contenus d’une table de manière à refléter les changements survenus sur les données internes, nous envoyons un message reloadData à l’objet vue de table. Nous ferons ceci chaque fois que les données seront modifiées, de ce fait nous verrons reloadData dans chacune des méthodes d’actions.

J’ai dit auparavant que l’émetteur d’un createMethod devait affirmer sa possession du dictionnaire retourné avant que le pool d’autodestruction ne soit vidé, sinon l’objet allait disparaître. Donc, où cela est-il arrivé dans addRecord ? Nous ne l’avons retenu d’aucune manière, pas plus que « records » ne l’a fait.

Quand des objets sont ajoutés à un tableau, celui-ci émet un message de rétention à l’objet ajouté. De cette manière, le tableau affirme sa possession sur l’objet ajouté, qui est dans addRecord le dictionnaire provenant de createRecord, ainsi l’objet ne sera pas détruit par le pool d’autodestruction. Les objets qui sont membres d’un tableau (rangées) sont détruits au moment où le tableau parent est détruit.

insertRecord

La méthode insertRecord est similaire à la méthode addRecord:, sauf que nous utilisons la méthode insertObject:atIndex: de la classe NSMutableArray au lieu de la méthode addObject:.

insertObject:atIndex prend deux arguments. Le premier est l’objet que nous souhaitons ajouter au tableau. Nous ferons ici la même chose qu’avant : utiliser la valeur retournée par createObject comme premier argument.

Le second argument est l’index du tableau où nous souhaitons placer notre objet. L’insertion d’un objet à un index précis place l’objet avant celui qui occupait cet index. Mais comment récupérer l’index à passer à cette méthode ? Nous avons dit auparavant que nous voulions que insertRecord insère un nouvel enregistrement juste avant la ligne mise en surbrillance dans la vue de table en émettant un message selectedRow à cette vue :

-(IBAction)insertRecord:(id)sender { int index = [tableView selectedRow];
[records insertObject:[self createRecord] atIndex:index]; [tableView reloadData]; }

Et voilà !

deleteRecord et ses amis

La méthode deleteRecord opère sur le même principe de récupération de l’index de la ligne sélectionnée comme vu dans le paragraphe précédent. Cependant, cette fois ci nous allons utiliser la méthode removeObjectAtIndex: de la classe NSMutableArray dont le but est simplement de retirer du tableau l’objet situé à l’index indiqué dans l’argument. Les objets placés au-delà de celui que nous allons supprimer vont alors être déplacés de manière à combler le trou ainsi formé. Voici à quoi ressemble la version 1 de deleteRecord dans le code :

-(IBAction)deleteRecord:(id)sender { int index = [tableView selectedRow];
[records removeObjectAtIndex:index]; [tableView reloadData]; }

Cette implémentation pourrait être étendue de manière à ôter la limitation induite par une suppression au coup par coup. Pour ce faire, nous devons utiliser ce qui est connu sous le nom d’énumérateur, qui est une instance de la classe fondamentale NSEnumerator.

NSEnumerator

Un énumérateur est un objet créé par la méthode objectEnumerator et par certaines autres méthodes appartenant aux classes telles que NSArray, NSDictionary, et NSSet. Un énumérateur parcours l’objet de type suite qui est à l’origine de sa création et retourne ses objets membres un par un (NSEnumerator est analogue à la classe Java « Iterator »). On obtient un objet à partir d’un énumérateur de suite en émettant un message nextObject à l’énumérateur. Tant qu’il y a des objets à énumérer, nous pouvons continuer à émettre des messages nextObject.

Quand tous les objets de la suite ont été retournés par l’énumérateur, le nextObject suivant retourne un « nil » (vide).

En résumé, les énumérateurs sont une manière orientée objet d’implémenter la boucle for souvent utilisée pour parcourir un tableau. Voyons ces deux bouts de code qui nous permettent de répéter l’énumération d’une rangée. En premier, l’implémentation de la boucle for :

// assume anArray exists id object; int i; for (i = 0; i < [anArray count]; i++ )  {
object = [anArray objectAtIndex:i]; // do something with object }

Puis, le mode NSEnumerator :

// again, assume anArray exists
NSEnumerator *enumerator = [anArray objectEnumerator];
id object; while ( (identifier = [enumerator nextObject]) )  {
    // do something with object
}

Nous avons envoyer un message objectEnumerator à anArray qui renvoie un énumérateur de anArray. La partie conditionnelle de la boucle while utilise nextObject pour récupérer l’objet suivant situé dans anArray, et le stocke dans l’objet variable qui sera utilisée à l’intérieur de la boucle while.

Nous avons dit qu’après avoir atteint la fin de la rangée, l’énumérateur se voyait retourné l’objet « nil » par nextObject. Cet objet est évalué à « faux » dans une instruction conditionnelle. Nous nous servons de ce fait pour stopper la boucle while après que tous les membres de la suite aient été énumérés.

Vous devez vous demander pourquoi nous voudrions utiliser cette superbe manière de parcourir une rangée alors que la boucle for le fait si bien. La réponse à cette question tient dans le fait que les boucles for ne fonctionnent pas si bien. Elles ont de sévères limitations quand elles sont utilisées dans des logiciels orientés objet.

Remarquez dans l’exemple, où nous avons utilisé une boucle for, comment nous nous sommes servis de la méthode objectAtIndex de la classe NSArray pour obtenir l’objet à extraire de la suite et trouver combien de membres étaient contenus dans le tableau. Que se serait il passé si au lieu d’utiliser un tableau pour stocker un tas d’objets nous avions utilisé un dictionnaire ou un ensemble (set) ? (NSSet est une autre suite Cocoa dont nous ne discuterons pas dans cet article) ? Et bien nous n’aurions pu utiliser objectAtIndex pour accéder aux contenus de la suite car aucune suite n’indexe ses membres comme le font les tableaux.

Donc, si nous avions décidé de changer la structure de donnée de notre suite, de la passer du type tableau bénin au type dictionnaire, ensemble, ou tout autre type de suite de notre concoction, nous aurions du étudier à fond notre code pour détecter tous les endroits où du code spécifique à NSArray serait utilisé : le parcours de la suite par exemple ; et changer le code de manière appropriée. Ça chie !

La solution orientée objet à ce problème est de prendre une classe abstraite qui fournit une interface simple pour énumérer les suites : NSEnumerator. En tant que classe abstraite, le seul travail de NSEnumerator est de déclarer les opérations que les sous-classes concrètes doivent implémenter pour adhérer à l’interface de NSEnumerator. Il y a de nombreuses sous-classes concrètes de NSEnumerator, cachées dans les programmes-cadres, qui implémentent la mécanique d’énumération de plein de choses telles que les dictionnaires, les rangées, et les ensembles. Ce qu’il faut retenir c’est que les énumérateurs concrets collent tous à l’interface définie par la super-classe abstraite NSEnumerator.

Notre boulot est d’être sûr que nous programmons pour une interface et pas pour une implémentation. Dit d’une autre façon, n’écrivez pas de code qui fasse référence aux boulons et écrous d’une classe (son implémentation). Essayez plutôt autant que faire se peut d’interagir avec une classe au travers de son interface. Le mode de répétition de la boucle for repose sur les informations d’implémentation de la structure interne de NSArray en se servant de la méthode objectAtIndex:. Cette boucle particulière for ne marchera qu’avec la classe NSArray.

Le code qui utilisait NSEnumerator, cependant, ne se plantera pas car nous ne nous sommes pas reposés sur des informations propres à l’implémentation de la suite. Nous n’utilisons que la méthode nextObject déclarée dans l’interface NSEnumerator. Si nous décidons d’implémenter notre propre classe de suite, tout ce que nous avons à faire consiste à sous-classer NSEnumerator et implémenter nextObject pour qu’il fonctionne avec notre suite personnalisée.

De cette manière, la responsabilité de s’assurer qu’une application fonctionne incombe à la personne qui a implémenté une nouvelle structure de données. Sa tà¢che d’adhésion à l’interface de NSEnumerator est très localisée, beaucoup plus que celle de la personne qui implémente le code source accédant à la suite, qui devra changer une centaine de boucles for de façon à ce que l’application fonctionne correctement avec la nouvelle suite. J’espère que vous comprenez l’utilité de ceci. Cela rend la maintenance du code plus facile et c’est un des buts majeurs et des avantages de la programmation orientée objet.

Bon alors, quel est le rapport entre les énumérateurs et la suppression de groupes d’enregistrements appartenant à une vue de table ? Nous avons déjà vu comment la méthode selectedRow de NSTableView retourne l’index d’une ligne en cours de sélection. Supposons que nous ayons sélectionné plusieurs lignes. Comment obtenir les index de ces lignes ?

La réponse tient dans l’utilisation d’une autre méthode de NSTableView : selectedRowEnumerator. Cette méthode renvoie un objet énumérateur qui nous permet de passer en revue les lignes sélectionnées. A chaque fois que l’on envoie un message nextObject à l’énumérateur nous obtenons en retour une représentation de type NSNumber de l’index de la ligne.

L’incorporation de cette gestion multi-lignes à notre méthode deleteRecord la fait progresser vers sa version 2 suivante :

-(IBAction)deleteRecord:(id)sender { NSEnumerator *enumerator = [tableView selectedRowEnumerator];
NSNumber *index;
while ( (index = [enumerator nextObject]) ) { records removeObjectAtIndex:[index intValue]]; }
[tableView reloadData]; }

Nous avons tout d’abord envoyer un message selectedRowEnumerator vers tableView, puis nous avons stocké l’énumérateur obtenu en retour dans la variable enumerator. Nous avons ensuite déclaré une variable de type NSNumber, « index », pour stocker l’index de la ligne obtenu en retour de la méthode nextObject à chaque itération de la boucle.

Vous pouvez voir dans l’instruction conditionnelle de la boucle while comment nous obtenons l’index de la ligne suivante, cette récupération s’effectue en envoyant un message nextObject à la variable enumerator, puis en le stockant dans la variable « index ». NSNumber est une classe simple, rien de plus qu’un emballage pour les types de nombres standard du langage C. L’argument de la méthode removeObjectAtIndex: est un entier non signé int, et ne peut accepter des objets NSNumber comme valeur d’argument. Nous pouvons résoudre ce problème en envoyant un message intValue à l’objet de la classe NSNumber, afin d’obtenir en retour un vrai int, et s’en servir alors comme argument. A la fin de la boucle (quand nextObject retourne un « nil »), nous terminons en demandant à tableView de mettre à jour ses contenus.

Problèmes

Par manque de chance, la version 2 comporte un défaut subtil mais sérieux qui résulte du simple fait qu’il n’est pas prudent de modifier une rangée transformable au moment où elle est énumérée. Prenez le cas d’une table avec cinq enregistrements dont on veux supprimer le premier et le dernier élément (vous pouvez faire des sélections discontinues par commande-clic). Nous faisons donc un [tableView selectedRowEnumerator] et cela nous donne en retour un énumérateur d’objets NSNumber pour 0 et 4.

Au premier envoi du message nextObject vers l’énumérateur, nous obtenons l’objet NSNumber « 0 », et nous finissons par supprimer le premier membre des enregistrements du tableau. Avant ceci, notre tableau comportait cinq éléments indexés de 0 à 4. Maintenant, il comporte 4 éléments indexés de 0 à 3 (les enregistrements restants ont tous descendu d’un cran de manière à combler le vide laissé par l’élément supprimé).

Le problème est qu’à l’itération suivante de la boucle, nextObject va retourner l’autre objet NSNumber de l’énumérateur : 4. Nous tentons alors de supprimer l’objet indexé à la position 4 qui a disparu depuis que l’objet en position 0 a été supprimé. Le résultat d’un telle tentative est une erreur d’index hors de portée, et un tableau dont le dernier élément n’est pas supprimé. La morale de cette histoire est qu’il ne faut pas changer le contenu d’une rangée quand elle est en cours d’énumération, et qu’il nous faudra adopter une approche moins directe pour supprimer des enregistrements multiples.

Donc, voyons comment supprimer des lignes sélectionnées après que leur énumération ait pris place. Une chose que nous pourrions faire pendant l’énumération est de construire un tableau constitué des enregistrements que nous souhaitons supprimer, et d’attendre que l’énumération soit finie avant de les supprimer effectivement. Nous pouvons alors nous servir de la méthode removeObjectsInArray: de la classe NSMutableArray pour supprimer du récepteur tout objet contenu dans le tableau que nous avons créé durant l’énumération. Et voici la version 3 de deleteRecord :

-(IBAction)deleteRecord:(id)sender {
    NSEnumerator *enumerator = [tableView selectedRowEnumerator];
    NSNumber *index; NSMutableArray *tempArray = [NSMutableArray array];
    id tempObject;
    while ( (index = [enumerator nextObject]) ) {
      tempObject = [records objectAtIndex:[index intValue]]; // Pas modifications, pas de problèmes
      [tempArray addObject:tempObject]; // garde trace dans tempArray de l'enregistrement à supprimer
    }
  [records removeObjectsInArray:tempArray]; // nous sommes vernis
  [tableView reloadData]; }

Bien, nous l’avons changée en créant deux variables : tempObject et tempArray. Pendant l’énumération nous avons juste fait ce que nous avions annoncé : récupérer à chaque itération de l’énumération l’enregistrement correspondant à chacune des lignes sélectionnées et l’enregistrer dans tempArray. Après la fin de l’énumération, nous avons dans tempArray tous les enregistrements de notre structure de donnée « records » qui étaient élus à la suppression dans la table. Avec removeObjectsInArray, nous supprimons de « records » tous les objets contenus dans tempArray (de manière basique, nous supprimons l’intersection de ces deux ensembles : les objets communs). Bien ! Problème résolu !

Panneaux d’alerte

Du fait que la suppression n’est pas annulable dans notre implémentation, il serait peut être intéressant de mettre en place une demande de confirmation de manière à éviter que des enregistrements soient supprimés par inadvertance. La fonction NSRunAlertPanel de AppKit nous permet justement d’implémenter ceci. Cette fonction demande les cinq arguments suivants :

  • Arg 1: une chaîne représentant le titre de la boite d’alerte.
  • Arg 2: le texte du message à afficher.
  • Arg 3: le texte à afficher dans le bouton par défaut.
  • Arg 4: le texte pour l’autre bouton (« Alternate »).
  • Arg 5: le texte du troisième bouton (« Other alternate »).

Si vous voulez formater le texte de votre message à la mode printf(), vous pouvez le faire en ajoutant les variables à afficher dans le message sous forme d’arguments optionnels à la fin de la liste des arguments.

La valeur retournée par cette fonction est un entier qui indique quel bouton a été pressé. AppKit définit plusieurs constantes relatives aux messages d’alerte : NSAlertDefaultReturn, NSAlertAlternateReturn, NSAlertOtherReturn, et NSAlertErrorReturn, dont les valeurs sont 0, 1, 2, et 3 respectivement. Les valeurs de ces constantes sont les mêmes que celles retournées par NSRunAlertPanel, et peuvent ainsi être utilisées pour déterminer quel bouton a été pressé.

Changeons maintenant notre code de manière à y incorporer une boite d’alerte. Tant qu’on y est, faisons émettre un bip au système au moment de l’ouverture de la boite d’alerte en utilisant la fonction NSBeep() de AppKit. Voici ce à quoi notre version 4 finale de deleteRecord ressemble :

-(IBAction)deleteRecord:(id)sender
{
  int status;
  NSEnumerator *enumerator;
  NSNumber *index;
  NSMutableArray *tempArray = [NSMutableArray array];
  id tempObject;
  if ( [tableView numberOfSelectedRows] == 0 )
      return;
  NSBeep();
  status = NSRunAlertPanel(@"Attention !", @"Etes vous sûr de vouloir supprimer les enregistrements sélectionnés ?", @"OK", @"Annuler", nil);
  if ( status == NSAlertDefaultReturn )  {
      enumerator = [tableView selectedRowEnumerator];
      while ( (index = [enumerator nextObject]) )  {
          tempObject = [records objectAtIndex:[index intValue]];
          [tempArray addObject:tempObject];   
      }
      [records removeObjectsInArray:tempArray]; 
      [tableView reloadData];
    }
}

La première chose que nous avons implémenté après la déclaration des variables est l’envoi d’un message numberOfSelectedRows de la classe NSTableView pour tester combien de lignes sont sélectionnées. S’il n’y en a aucune, alors il y a peut d’intérêt à supprimer du vide et la méthode s’arrête à ce point.

Ensuite nous émettons un bip avec NSBeep() et provoquons l’ouverture d’une boite d’alerte - dont nous stockons l’état de retour dans la variable status. Si vous ne voulez pas qu’un bouton soit affiché dans la boite d’alerte, servez vous de nil comme valeur d’argument pour le texte du bouton. Nous avons fait ainsi pour éliminer le troisième bouton. Vous pouvez voir ce à quoi ressemble la boite dans l’illustration ci-dessous :


La boite d’alerte.

Ensuite nous avons comparé le statut avec la constante NSAlertDefaultReturn et exécuté notre précédent code de suppression d’enregistrements.

Nous avons maintenant mis au point un système simple et propre de manipulation de données, mais nous n’avons cependant pas encore parlé de la manière dont on se sert la table de notre interface pour récupérer les données à partir de notre tableau. La section suivante va faire le point la dessus.

Installation de la source de données

Maintenant il est temps d’installer la source de données. Rappelez vous que dans IB nous avons établit une connexion entre le Contrôleur et tableView indiquant que le Contrôleur agirait en tant que source de données de tableView. Quand une vue de table reçoit un message reloadData, ce message est perçu comme le signal indiquant qu’il faut émettre des messages vers la source de données (Contrôleur) afin de récupérer les données à afficher. Quels sont les messages que l’objet « vue de table » émet vers l’objet dataSource pour récupérer les données ? Avant de répondre à cette question je me sens dans l’obligation de parler un peu des protocoles Objective-C.

Nous pouvons trouver ces messages dans le protocole NSTableDataSource, qui fait partie de « l’AppKit ». Nous connaissons déjà de l’Objective-C les interfaces de classes, qui rendent publiques tous les messages qui peuvent être envoyés à une classe donnée. Mais cette messagerie est une voie à double sens.

Il est souvent utile de savoir les types de messages qu’une classe peut émettre de manière à préparer des récepteurs potentiels capables de leur répondre de manière appropriée et intelligente. Les protocoles sont là pour ça. Un protocole apporte à la connaissance publique toutes les méthodes qui peut être émises par l’instance d’une classe. Une classe est dite conforme à un protocole si elle implémente les méthodes disposées dans le protocole.

Dans notre situation, tableView émet des messages vers sa source de données avec l’espoir que des données seront obtenues en retour. La protocole NSTableDataSource nous indique ce que sont les messages, leurs arguments y compris, et quelles valeurs de retour l’objet tableView est en droit d’espérer. Pour qu’un objet dataSource fonctionne correctement, il doit implémenter le minimum requis des méthodes qui en feront un dataSource et se conformer au protocole.

Si nous jetons un oeil à la documentation de référence de NSTableDataSource, nous y trouverons qu’il y a deux méthodes à implémenter pour qu’une table affiche les données de sa source. Ces deux méthodes sont numberOfRowsInTableView:, et tableView:objectValueForTableColumn:row:.

La première est simple. De manière basique, cette méthode est la manière dont se sert la vue de table pour demander à la source de données « Combien d’enregistrements as tu en ta possession ? » et dont se sert la source de données pour répondre gentiment avec un nombre entier. Ajoutez cette méthode au fichier d’implémentation de notre Contrôleur en utilisant le code suivant :

- (int)numberOfRowsInTableView:(NSTableView *)aTableView {  return [records count]; }

L’argument aTableView fait référence à la vue de table qui émet la requête. Cette argument permet à un objet dataSource de gérer des données pour plusieurs vues de table. Dans notre application, nous n’avons qu’une seule vue de table, donc cet argument est inutile. Si toutefois vous aveiz plusieurs tables dans votre application, vous pourriez utiliser cet argument dans une instruction conditionnelle : si aTableView est la Table 1, alors retourne le nombre d’enregistrements contenus dans Array 1, ou si aTableView est la Table 2, alors retourne le nombre d’enregistrements contenus dans Array 2.

Installation de la source de données (suite)

D’après la recette de NSTableDataSource, nous voyons que la méthode suivante à implémenter est tableView:objectValueForTableColumn:row:. Le code de cette implémentation ressemble à ceci :

-(id)tableView:(NSTableView *)aTableView  
objectValueForTableColumn:(NSTableColumn *)aTableColumn row:(int)rowIndex {
id theRecord, theValue; theRecord = [records objectAtIndex:rowIndex];
theValue = [theRecord objectForKey:[aTableColumn identifier]]; return theValue; }

Encore une fois, nous n’allons pas utiliser la variable aTableView comme argument. Tout ce qui nous intéresse ici est rowIndex et aTableColumn. Quand nous envoyons un message reloadData à tableView, la vue de table scanne les colonnes et les lignes en émettant à son tour ce message pour obtenir le contenu de chaque cellule de la table. En attachant au message les informations relatives à la ligne et à la colonne, nous sommes capables de viser la bonne valeur dans notre structure de données bi-dimensionnelle.

En premier nous avons déclaré deux variables objet génériques. En déclarant ces variables avec le type id, nous avons éliminé le risque d’assigner une variable à un objet qui lui aussi n’est pas typé. Dans la deuxième ligne, nous avons assigné à theRecord l’objet dictionnaire que nous avons stocké à l’index rowIndex de « records ».

Puis, nous utilisons le dictionnaire pour obtenir l’objet approprié à afficher dans aTableColumn. Comment savons nous que cet objet est l’objet approprié ? Bien, par chance, nous avons constitué de la même manière les identifiants de colonne et les clés du dictionnaire pour chaque champ de donnée, par conséquent tout ce que nous devons faire est d’envoyer un message objectForKey: à l’objet dictionnaire theRecord avec la chaîne identificatrice de la colonne comme argument. Cette chaîne est récupéreé en envoyant à aTableColumn un message d’identification.

Par exemple, tableView pourrait envoyer un message d’obtention de données issues de la source de données dont la ligne est 0 en passant aTableColumn dans la liste des arguments. Notre code accèderait le premier élément de « records » (correspondant à la première ligne de la table) et ferait pointer la variable theRecord vers cet élément. Nous obtiendrions alors, de aTableColumn, la chaîne identificatrice de la colonne que nous avons renseignée dans IB, qui devrait être « First Name », et nous l’utiliserions comme clé d’accès à la donnée « first name » contenue dans theRecord. L’objet retourné par objectForKey: est alors stocké dans la variable theValue qui est alors retournée à tableView.

Finalement, nous avons les pièces primaires de code qui vont permettre à tout ça de décoller, donc passons à la compilation, espérons qu’il n’y ait pas trop d’erreurs, et lançons le tout. Vous devriez être capable d’ajouter, d’insérer et de supprimer des enregistrements, et ils devraient apparaître dans la vue de table.

En exercice final, le protocole NSTableDataSource mentionne une méthode que nous pouvons implémenter de manière à pouvoir changer les données d’un enregistrement directement dans la table. Si vous double-cliquez sur une cellule, vous pourrez alors la modifier, mais les changements apportés ne seront pas sauvegardés tant que cette méthode ne sera pas implémentée dans l’objet dataSource. Poursuivez et essayez.

Et la fin

Voilà comment implémenter une table sous Cocoa. Cela peut paraître long, mais c’est en réalité très rapide dès l’instant où vous réalisez comment les différents objets et pièces interagissent. Nous avons vu le rôle de l’objet Contrôleur dans notre application, dont le but est autant de servir la noble cause d’ajouter ou de soustraire des enregistrements à notre structure de données en réponse aux actions utilisateur, que de permettre à la table de connaître le moment de mise à jour de son contenu.

Nous avons aussi mis en place une structure simple de données qui n’était rien de plus qu’une suite de dictionnaires standard Cocoa transformables, parqués dans un tableau Cocoa transformable. Finalement, une prise dataSource a été apportée à notre vue de table pour indiquer à la table où émettre les messages d’obtention de données à afficher.

Dans notre cas, nous avons attribué au Contrôleur le rôle de dataSource. La seule chose requise par un objet pour qu’il fonctionne comme un dataSource était une conformité minimale au protocole NSTableDataSource. Si vous voulez en savoir plus sur les protocoles en général, examinez le manuel Object-Oriented Programming and The Objective-C Language (par Apple, en ligne pour ceux parmis vous qui n’ont toujours pas lu ce livre). Si vous rencontrez des problèmes, vous pouvez télécharger le dossier du projet AddressBook.

Cette petite application comporte déjà pas mal de fonctionnalités pour le peu de travail que nous avons fourni, mais elle est gravement incomplète. Tout d’abord, nous n’avons aucun moyen de sauvegarder les données entre deux sessions. Notez que nous ne l’avons pas créée sous forme d’application de gestion de documents, ce que nous aurions sûrement dû faire, mais je voulais vous montrer une manière différente de sauvegarder des données (imaginez comment vous auriez sauvegardé et chargé les données du carnet d’adresses si nous avions pris le chemin d’une application de gestion de documents. N’y a t’il pas une manière transparente pour l’utilisateur qui afficherait le même ensemble de données par défaut à chaque démarrage et qui le sauvegarderait à chaque fois qu’il serait modifié ?). Bon, le but du prochain article sera d’implémenter la sauvegarde des données, et de donner la faculté à notre application de se rappeler, juste avant qu’elle ne se lance, des choses qui lui sont propres (telles que la taille de la fenêtre). En attendant, bonne programmation.


Textes originaux en anglais sur O’Reilly : http://www.macdevcenter.com/pub/au/159

Thierry Programmation Cocoa , , ,

  1. Pas encore de commentaire
  1. Pas encore de trackbacks
Vous devez être identifié pour poster un commentaire