Forums Développement Multimédia

Les formations Mediabox
Les formations Mediabox

Passage de paramètres entre deux ViewControllers : Delegate & @protocol

Compatible iOS 3. Cliquer pour en savoir plus sur les compatibilités.Compatible iOS 4. Cliquer pour en savoir plus sur les compatibilités.Par AliGator (O.Halligon), le 17 novembre 2010

Introduction

Ce tutoriel présente comment faire communiquer deux ViewControllers pour faire passer des paramètres de l'un à l'autre en utilisant le mécanisme de délégation (delegate) et la notion de @protocol.

Il va ainsi vous montrer comment créer votre propre @protocol et déclarer un delegate à une de vos classes perso et ainsi utiliser ce mécanisme de delegation pour passer des informations d'un ViewController à un autre

Prérequis

Pour pouvoir suivre ce tutoriel vous devez déjà connaître les bases de Cocoa et de l'Objective-C et la POO (notions de variable d'instance, etc) ; avoir idéalement déjà créé une application iPhone utilisant des ViewControllers & NavigationControllers vous aidera d'autant plus à suivre ce tutoriel.

Contexte

Soit VC1 et VC2 deux classes, disons des ViewControllers, et VC2 veut dialoguer avec VC1 pour lui envoyer (ou lui demander) des informations.

En effet, il est souvent utile, depuis un ViewController VC1 représentant disons une fiche produit, d'afficher suite à un clic sur un champ de la fiche, un deuxième ViewController VC2 permettant par exemple à l'utilisateur de choisir une valeur (un critère) dans une liste de choix.

Le but de l'opération est donc :

  • de fournir d'une part les éléments nécessaires au ViewController VC2 depuis le ViewController VC1 (par exemple le nom du champ cliqué, pour l'afficher comme titre pour VC2)
  • récupérer la valeur choisie par l'utilisateur dans VC2 pour la faire remonter au ViewController VC1 (et remplir ainsi le champ de VC1 précédemment cliqué avec ladite valeur)

Nous nous placerons dans un contexte de NavigationController (cas typique utilisé par le template “Navigation-Based Application” de Xcode), où ce NavigationController affiche dans un premier temps le ViewController VC1, le but étant justement de pousser VC2 puis d'en récupérer les informations, même si ce tutoriel reste valable si VC2 est présenté autrement (sous forme de vue modale par exemple)

Une première solution

Typiquement, la première solution qui vient à l'esprit est de mettre dans la classe VC2 une variable d'instance pointant sur VC1, pour que VC2 puisse accéder à VC1 et communiquer avec lui :

@interface VC2 : UIViewController
{
  ...
  VC1* referenceVersVc1;
}
@property(nonatomic, assign) VC1* referenceVersVc1;
...
@end
 
@implementation VC2
@synthesize referenceVersVc1;
...
@end
Notez que la propriété referenceVersVc1 est déclarée en mode “assign”, en effet c'est juste une référence vers VC1, qui lui “possède” déjà VC2. Comme typiquement VC1 “retient” déjà VC2 car c'est lui qui “pousse” ce ViewController, il faut éviter également que VC2 retienne VC1 car on risque alors de créer un ”retain-cycle

Ensuite, quand on crée le ViewController VC2 par exemple pour le “pousser” sur le navigationController, il faut assigner sa propriété referenceVersVc1 pour lui passer la référence vers le ViewController VC1 et pour qu'il puisse ainsi appeler des méthodes dessus.

Donc par exemple dans l'IBAction de VC1 qui va pousser le VC2 :

@implementation VC1
...
-(IBAction)afficherVC2
{
  VC2* vc2 = [[VC2 alloc] initWithNibName:... bundle:...];
  vc2.referenceVersVc1 = self;  // ici on affecte la propriété referenceVersVc1 de l'objet VC2 à notre objet VC1 courant
  // pour que VC2 puisse avoir une référence vers ton objet VC1 et puisse communiquer avec
  [self.navigationController pushViewController:vc2 animated:YES];
  [vc2 release];
}
 
// méthode de VC1 que le VC2 va appeler quand il aura fait une action (disons changer le nom d'utilisateur de ton application
// pour notifier VC1 de ce changement
-(void)criteresDidChange:(NSString*)criteres {
  // VC1 va ainsi pouvoir prendre en compte la modification qui a été faite via VC2.
  // il y a bien communication entre les deux.
  ...
  ... actions ... // (1)
}

Et quand dans du code de VC2 on a besoin de faire appel à VC1 :

... {
  // ici on va notifier vc1 que nous, l'objet VC2, on a fait qqch. Histoire de communiquer quoi.
  [referenceVersVc1 criteresDidChange:newCriteres];
}
Au lieu de prévoir une méthode “criteresDidChange:” dans VC1 qui effectues les actions (1) lorsque l'utilisateur a choisi une option dans VC2, et d'appeler cette méthode ensuite depuis VC2, on pourrait mettre directement ce code (les actions (1)) dans le code de VC2.

Cependant ce ne serait pas la solution la plus élégante, car cela suppose que VC2 ait accès à tout ce dont il a besoin de VC1, et qu'il connaisse beaucoup de la structure de VC1 (variables d'instance, etc), ce qui créerait une forte dépendance entre les deux.

De plus si les actions en question effectuées dans “criteresDidChange:” doivent modifier des variables d'instances privées de VC1, seul du code présent dans VC1 peut le faire (sinon cela ne respecterai pas spécialement le principe d'encapsulation de la POO)

Problématique de cette solution

Avec cette première solution, nous avons déjà un principe de base, qui sera l'exemple à partir duquel on va partir pour la suite. On voit ici qu'on a déjà pu passer newCriteres de VC2 à VC1.

Mais cet exemple a un gros inconvénient : il lie de façon forte VC1 et VC2, et les rend interdépendants. VC2 doit connaître la totalité de VC1 pour pouvoir dialoguer avec lui, et s'adapter si VC1 est modifié…

C'est pour cette raison que pour notre solution plus aboutie, on va préférer utiliser les @protocol

Solution 2 : Créer un @protocol

L'idée de cette solution est qu'au lieu de déclarer la variable referenceVersVc1 de type “VC1*”, et donc d'avoir un typage fort dessus, on va juste dire que referenceVersVc1 doit être une variable de type… et bien n'importe quoi, du moment qu'elle se conforme à un protocole donné, qui nous dit juste comment communiquer avec elle.

Le principe c'est donc juste de définir, via un @protocol, quelles sont les méthodes qui doivent être utilisées pour communiquer. Sans implémenter ces méthodes, juste les lister avec le nom et leurs paramètres :

@protocol CriteresDelegate
-(void)criteresDidChange:(NSString*)newCriteres;
@end

Ainsi on définit juste une API, un ensemble de méthodes (en l'occurrence il n'y en a qu'une) auxquelles les classes qui vont se conformer à ce protocole s'engagent à implémenter et répondre.

Du coup, une fois que l'on a ce protocole, qu'a-t-on à changer par rapport à au dessus et utiliser un protocole ? Bah quasiment rien en fait !

  • Il suffit de changer le type de la variable, du typage fort et dépendant VC1* referenceVersVc1 en type plus laxiste id<CriteresDelegate> vc1 (et de même pour la @property qui va avec). Comme ça la variable d'instance referenceVersVc1 n'est plus obligée d'être de type VC1, elle peut être de n'importe quel type… du moment que ce type se conforme au protocole <CriteresDelegate>, c'est à dire implémente la méthode criteresDidChange:.
  • Et ensuite faut quand même dire quelquepart que notre classe VC1 justement se conforme au protocole, donc ça on le met dans VC1.h, au moment de déclarer la classe, entre chevrons < et > :
    @interface VC1 : UIViewController <CriteresDelegate>

Et sinon c'est tout, le reste marche comme au dessus.

Le fait de déclarer dans VC1.h que la classe VC1 se conforme au protocole CriteresDelegate (entre les chevrons) permet au compilateur de vérifier toute la chaîne, entre autres de prévenir si l'on oublie d'implémenter une des méthodes requises/attendues du @protocol, ou de nous avertir si l'on affecte à la variable referenceVersVc1 un objet qui ne déclare pas ledit protocole.

Quelle différence alors ? Eh bien c'est juste qu'avec la déclaration du @protocol qui liste les méthodes auxquelles on doit se conformer (une sorte de contrat entre les 2 objets pour être d'accord sur comment dialoguer quoi) + le fait de rendre le type de la variable vc1 plus générique, on a enlevé la dépendance forte entre VC1 et VC2.

Maintenant si l'on veut créer une classe VC3 ou même une classe Toto (même si ce n'est pas du tout un UIViewController mais un NSObject quelconque), alors lui aussi il peut implémenter “criteresDidChange:”, et si on affecte cet objet à la variable vc1 de l'objet VC2, c'est son criteresDidChange qui sera appelé.
Ainsi VC2 n'a plus à connaître les détails de VC1, VC3 ou Toto ni comment elles sont implémentées, il sait juste qu'elles répondent à la méthode criteresDidChange puisqu'elles se conforment au @protocol <CriteresDelegate>

Finalisation de la solution

Pour finir et paufiner le tout, plutôt que d'appeler notre variable “referenceVersVc1”, maintenant qu'elle est générique et qu'elle n'a plus forcément à voir avec la classe VC1 à proprement parler, en général pour ce genre de cas d'usage de la variable on l'appelle plutôt… “delegate”, tiens donc !

Par convention, on nomme souvent les méthodes de delegate en commençant par le nom de l'objet qui “notifie” du changement (dans notre cas le VC2) que l'on passe alors en premier paramètre, en plus du paramètre transportant l'information utile.

Ainsi pour être plus propre et homogène avec les conventions, au lieu de nommer notre méthode
-(void)criteresDidChange:(NSString*)newCriteria;
nous devrions plutôt nommer
-(void)vc2:(VC2*)ctrl didChangeCriteria:(NSString*)newCriteria;
et lorsque VC2 appelle cette méthode sur sa variable delegate (anciennement referenceVersVc1), cela donnerait donc :

[delegate vc2:self didChangeCriteria:newCritere];
Notez que dans ce cas, comme on déclare en général dans VC1.h le @protocol CriteresDelegate avant le @interface VC2, on se retrouve avec des références croisées, la méthode définie dans le @protocol utilisant un paramètre de type VC2* (pas encore défini à ce stade ce qui va provoquer une erreur de compilation), et la déclaration de la classe VC2 juste après déclare une variable d'instance de type id<CriteresDelegate> (donc même si on déclarait la classe VC2 avant le protocole CriteresDelegate en inversant l'ordre des déclarations, on aurait le même problème dans l'autre sens)
La solution est d'utiliser le mot clé @class avant la déclaration du @protocol pour juste indiquer au compilateur que la classe VC2 existe et sera définie un peu plus tard (juste après en fait)

Ce qui nous donne donc :

@class VC2; // pour pouvoir déclarer un paramètre de type VC2* même avant que la classe VC2 soit déclarée
@protocol CriteresDelegate
-(void)vc2:(VC2*)viewCtrl didChangeCriteria:(NSString*)newCrit;
@end
 
@interface VC2 : UIViewController {
  ...
  id<CriteresDelegate> delegate;
}
@property(nonatomic,assign) id<CriteresDelegate> delegate;
@end

Bien évidemment pour finir avec un code vraiment propre, j'espère que dans votre cas vos classes ont un nom plus explicite que “VC2” (comme par exemple CriteriaChooserViewController) ;)

Utiliser des méthodes @optional dans les @protocol

Il est évidemment possible, lorsque l'on déclare un @protocol, de définir plusieurs méthodes (alors que dans notre exemple nous n'en avons définie qu'une seule).
Mais il est également possible (depuis la version 2.0 d'Objective-C maintenant adoptée partout) de définir certaines de ces méthodes comme optionnelles.

Ainsi, si une classe déclare se conformer à ce @protocol (en indiquant dans son .h sur la ligne du @interface MaClasse : MaClasseParente le nom du protocole entre chevrons après MaClasseParente), elle ne sera obligée d'implémenter que les méthodes qui sont indiquées comme requises, et pourra se passer d'implémenter les méthodes optionnelles.

Le mot clé @optional au sein d'une déclaration de @protocol permet d'indiquer que toutes les méthodes qui suivent ce mot clé sont optionnelles. Le mot clé @required indique à l'inverse que toutes les méthodes à suivre sont requises. Par défaut en l'absence de tout mot clé dans la déclaration du @protocol les méthodes sont considérées @required.

Attention si l'on choisit de déclarer des méthodes @optional, il faut alors penser dans la classe qui appelle ces méthodes de vérifier que les méthodes sont implémentées avant de les appeler. Sans quoi votre programme risque de planter, en appelant une méthode qui n'est pas implémentée

Pour effectuer cette vérification, on peut utiliser les méthodes -(BOOL)respondsToSelector:(SEL)selector du protocole NSObject (auquel la classe éponyme NSObject se conforme, et donc toutes les classes dérivant de NSObject donc toutes les classes Cocoa).

Pour pouvoir appeler cette méthode sur notre delegate, et ainsi vérifier que notre delegate répond à une méthode donnée, il faut déjà s'assurer… qu'il réponde au moins justement à la méthode -(BOOL)respondsToSelector:(SEL) avant toute chose. Pour cela, il faut s'assurer que notre delegate se conforme au protocole NSObject (car c'est lui qui définit cette méthode).

En combinant toutes ces informations, nous arrivons donc à un exemple comme ceci :

@class CriteriaChooserViewController;
@protocol CriteriaChooserDelegate <NSObject>
// indiquer que notre protocole lui-même se conforme au protocole <NSObject> pour pouvoir appeler respondsToSelector:
-(void)criteriaChooser:(CriteriaChooserViewController*)critChooser didChooseCriteria:(NSString*)crit; // méthode requise
@optional // les 2 méthodes suivantes sont optionnelles, elles peuvent ne pas être implémentées
-(NSColor*)criteriaChooser:(CriteriaChooserViewController*)critChooser colorForCriteriaAtIndex:(int)index; // si pas implémenté, noir
-(NSImage*)criteriaChooser:(CriteriaChooserViewController*)critChooser imageForCriteriaAtIndex:(NSString*); // si pas implémenté, aucune image
@end
 
@interface CriteriaChooserViewController : UIViewController {
  id<CriteriaChooserDelegate> delegate;
  IBOutlet UIButton* crit0Btn;
  IBOutlet UIButton* crit1Btn;
  IBOutlet UIButton* crit2Btn;
  IBOutlet UIButton* crit3Btn;
}
@property(nonatomic,assign) id<CriteriaChooserDelegate> delegate;
...
@end

Et dans l'implémentation de CriteriaChooserViewController :

-(void)viewDidLoad {
  if ([delegate respondsToSelector:@selector(criteriaChooser:colorForCriteriaAtIndex:)])
  {
    // comme c'est une méthode optionnelle, il faut en effet tester si elle est implémentée avant de l'appeler
    [crit0Btn setTextColor:[delegate criteriaChooser:self colorForCriteriaAtIndex:0] forControlState:UIControlStateNormal];
    [crit1Btn setTextColor:[delegate criteriaChooser:self colorForCriteriaAtIndex:1]  forControlState:UIControlStateNormal];
    [crit2Btn setTextColor:[delegate criteriaChooser:self colorForCriteriaAtIndex:2]  forControlState:UIControlStateNormal];
    [crit3Btn setTextColor:[delegate criteriaChooser:self colorForCriteriaAtIndex:3]  forControlState:UIControlStateNormal];
  }
  if ([delegate respondsToSelector:@selector(criteriaChooser:imageForCriteriaAtIndex:)]) {
  {
    // de même ici penser à vérifier avant d'appeler
    [crit0Btn setImage:[delegate criteriaChooser:self imageForCriteriaAtIndex:0] forControlState:UIControlStateNormal];
    [crit1Btn setImage:[delegate criteriaChooser:self imageForCriteriaAtIndex:1] forControlState:UIControlStateNormal];
    [crit2Btn setImage:[delegate criteriaChooser:self imageForCriteriaAtIndex:2] forControlState:UIControlStateNormal];
    [crit3Btn setImage:[delegate criteriaChooser:self imageForCriteriaAtIndex:3] forControlState:UIControlStateNormal];
}
 
-(IBAction)critBtnClicked:(UIButton*)sender {
  NSStirng* crit = [sender titleForControlState:UIControlStateNormal];
  [delegate criteriaChooser:self didChooseCriteria:crit];
}

Comparaison avec l'existant et conclusion

Au final, si on regarde un peu comment marchent les delegate déjà existants sous Cocoa, comme le delegate d'une UITableView, il est bien défini comme étant de type id<UITableViewDelegate>, et UITableViewDelegate est bien un @protocol qui définit toutes les méthodes auxquelles le delegate de la UITableView, quel que soit son type (un UIViewController, un UITableViewController, un NSObject quelconque), doit savoir répondre.

Le delegate d'une tableview peut donc être vraiment n'importe quel objet (pas obligé d'être un UIViewController par exemple)… et de n'importe quel type (pas d'obligation d'hériter de machin ou truc)… du moment que cet objet se conforme au protocole donc implémente les méthodes du @protocol qui vont bien.
On remarque également que les méthodes sont du genre tableView:(UITableView*)aTableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath qui ont le même modèle que notre vc2:(VC2*)vc2 didChangeCriteria:(NSString*)newCrit !

A l'issue de ce tutoriel nous avons donc créé notre propre @protocol et implémenté notre propre delegate personnalisé, et ainsi pu appliquer le mécanisme de délégation, déjà présent dans beaucoup de classes Cocoa type comme UITableView, à nos propres classes !