Forums Développement Multimédia

Les formations Mediabox
Les formations Mediabox

De l'utilisation du Map Kit - Intégration de cartes Google Maps - Initiation et astuces

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 lechatnoir (LeChatNoir), le 03 décembre 2010

Hello !

Mon application utilise assez intensivement les cartes Google proposées dans le Map Kit d'iOS (enfin, je l'utilise à 2 endroits 8-)) J'ai déjà passé pas mal d'heures sur le sujet.
Certaines heures très satisfaisantes par les fonctionnalités que le Map Kit peut apporter à une application.
D'autres heures moins glorieuses à tenter de comprendre pourquoi mon appli crash soudainement sans autres explications qu'un vulgaire (et non moins grossier) EXC_BAD_ACCESS :-(

Je vous propose donc un petit tutoriel qui compile pas mal de conseils et d'astuces pour créer des cartes de base avec pas mal de fonctionnalités.

Prérequis Une bonne maitrise d'objective-C et des classes de bases d'iOS.

Synthèse des classes mises à disposition par le Map Kit

Classe Fonction
MKMapView La base : la carte google. Une vue qui permet d'afficher les cartes, de zoomer, d'annoter, etc…
MKCoordinateRegion Une structure de données permettant de définir une région d'une map (latitude, longitude, rayon)
MKAnnotationProtocol Déclare les propriétés de base qu'un objet devra comporter pour représenter une annotation. Une annotation est un point remarquable qu'on peut ajouter à une map
MKAnnotationView La vue qui va gérer l'affichage de l'annotation sur la map. Accessoirement, gère également l'affichage de la pop up quand l'utilisateur “tape” sur l'annotation.
MKPinAnnotationView Une sous-classe de la précédente qui propose le pin's comme image par défaut mkpinannotation.jpg
MKPlacemark Une classe qui permet de gérer des lieux, au sens administratif du terme, à savoir pays/région/département/code postal etc…
MKReverseGeocoder Une classe permettant de faire du reverse geocoding, c'est à dire, retrouver une adresse “humaine” à partir de coordonnées. Le résultat sera donc un placemark

Il existe bien sûr d'autres classes mais celles-ci vont déjà pas mal nous occuper !

Quelques notions

Principe de fonctionnement des delegate et callback méthodes

La plupart des classes présentées au précédent chapitre fonctionnent sur le principe du delegate/call back. Pourquoi ? Parce que la plupart des actions effectuées par les vues ou les objets sont asynchrones. L'objet va devoir aller solliciter un serveur externe (Google) avant de pouvoir renvoyer une réponse.

Dans l'ordre :

  • on instancie une classe dans un objet,
  • on indique à l'objet son “delegate”,
  • on lance les actions mises à disposition par l'objet,
  • le delegate implémente les fonctions de callback qui seront appelées par l'objet lorsqu'il aura fait ses actions.


Les fonctions de callback sont “déclarées” dans le protocol associé.

Exemple concret avec la classe de base : MKMapView

  • instanciation de la classe :
    MKMapView * map=[[MKMapView alloc] initWithFrame:aFrame]; 
  • affecter le delegate :
    map.delegate=self; /* par exemple... */ 
  • on lance une action :
    [map setRegion:maRegion animated:YES]
  • on attend les retours à travers les méthodes (que j'appelle “méthodes de callback”) définies dans le delegate (dans notre exemple, self, c'est à dire, l'objet qui a créé la map). Ces fonctions sont déclarées dans le protocole MKMapViewDelegate.

Voilà pour le principe de base qui n'est pas exclusivement réservé au Map Kit d'ailleurs. C'est un principe très utilisé en Objective-C.

Geocoding

  • Le geocoding (Forward Geocoding in english) est une mécanique qui permet de transformer une adresse en coordonnées géographiques (latitude-longitude).
  • Le geocoding inverse (Reverse Geocoding in english) est la mécanique inverse : “J'ai une latitude/longitude, à quelle adresse je me trouve ?”

Google fournit les 2 mécaniques mais Apple n'en propose qu'une dans son Map Kit (le Reverse). Si vous voulez faire du Forward Geocoding, passez votre chemin ! Dommage…

MKMapView - on se lance

Le b.a.ba

Alors voilà, vous voulez afficher une map dans une vue de votre application. C'est partit ! Il vous faut :

  • Ajouter le framework MapKit.framework dans le groupe des frameworks liés,
  • Importer le header correspondant dans le controleur de la vue qui va créer la map :
    #import <MapKit/MapKit.h>
  • Créer votre MKMapView. Deux solutions, soit via IB, soit par programme.
    • Sous IB : déplacez simplement l'objet MKMapView sur la vue qui vous intéresse (ou comme vue autonome).
    • Par programme :
      MKMapView * map=[[MKMapView alloc] initWithFrame:<uneFrame>];
      [votreVue addSubviw:map];
  • relier votre MKMapView à son delegate :
    • soit en la reliant par outlet sous IB,
    • soit en l'affectant par code
      maMapView.delegate=monDelegate;
  • déclarer que le delegate répond bien au protocole MKMapViewDelegate
    • dans le header du delegate, on doit avoir
      @interface ClasseDeMonDelegate : UIViewController (par ex) <MKMapViewDelegate>
  • et enfin, définir dans ce delegate les méthodes du protocole. 2 méthodes de base :
    - (void)mapViewDidFinishLoadingMap:(MKMapView *)mapView;
    - (void)mapViewDidFailLoadingMap:(MKMapView *)mapView withError:(NSError *)error;

Et voilà ! Notre map s'affiche, elle va télécharger ses “tuiles” comme une grande, l'utilisateur peut se déplacer dedans, zoomer, dézoomer, la map va s'adapter en téléchargeant les tuiles correspondantes ; et à chaque fois, prévenir notre delegate que le chargement est terminé ou qu'il a échoué.
As simple as that 8-)
On a bien sûr tout un arsenal de fonctions au niveau du delegate qui nous permettent d'être informés de tout ce qu'il se passe : l'utilisateur se déplace dans la map, il zoom, sa position actuelle (réelle !) a changé, etc…
On pourra profiter de mapViewWillStartLoadingMap et DidFinishLoadingMap pour gérer un indicateur de chargement.
La map est dotée de quelques propriétés qui permettent de la paramétrées. Un exemple simple : le type de carte ⇒

map.mapType=MKMapTypeHybrid /* affichera une carte satellite + plan */
Concernant l'allocation et la liaison de la map avec son delegate, je vous conseille fortement la solution IB. Une vue créée par programme peut parfois générer des bugs (que je détaillerai plus loin).

Déplaçons et "zoomons"

Au chargement de votre map précédemment créée, on va afficher la bonne vieille carte du monde Google. Vous voudrez rapidement (si si, je vous jure !) afficher une région plus précise que le monde entier… Encore une fois, c'est plutot simple. On va utiliser les méthodes d'instance de notre map :

- (void)setCenterCoordinate:(CLLocationCoordinate2D)coordinate animated:(BOOL)animated;
- (void)setRegion:(MKCoordinateRegion)region animated:(BOOL)animated;
  • la première permet de centrer la carte sur les coordonnées qu'on lui indique, sans changer le zoom.
  • la seconde permet la même chose mais en indiquant un niveau de zoom.

MKCoordinateRegion est ton amie puisque c'est une structure de données faite pour ça !

typedef struct {
   CLLocationCoordinate2D center;
   MKCoordinateSpan span;
} MKCoordinateRegion;
La fonction MKCoordinateRegionMakeWithDistance() est bien pratique pour créer une MKCoordinateRégion 8-)

Un "zoli" effet de prise de hauteur puis plongeon

On a un bel exemple de ce genre d'animation dans un exemple Apple. On se fait 2 fonctions dans le controller qui détient notre map :

- (void)zoomToWorld:(NSDictionary *)location
    MKCoordinateRegion current = map.region;
    MKCoordinateRegion zoomOut = { { (current.center.latitude + [[location valueForKey:@"lat"] doubleValue])/2.0 , (current.center.longitude + [[location valueForKey:@"lat"] doubleValue])/2.0 }, {90, 90} };
    [map setRegion:zoomOut animated:YES];
}
 
- (void)zoomToLocation:(NSDictionary *)location 
{
	CLLocationCoordinate2D coord;
	coord.latitude=[[location objectForKey:@"lat"] doubleValue];
	coord.longitude=[[location objectForKey:@"lon"] doubleValue];
	MKCoordinateRegion region=MKCoordinateRegionMakeWithDistance(coord,1000,1000);
	[map setRegion:region animated:YES];
}

Ensuite, il suffit d'appeler ces 2 fonctions de manière décalée dans le temps via par exemple :

[self performSelector:@selector(zoomToWorld:) withObject:coordinate afterDelay:0.1];
[self performSelector:@selector(zoomToLocation:) withObject:coordinate afterDelay:1.5];
Premier avertissement en or pour vous épargner des misères : avant de désallouer une map, toujours mettre son delegate à nil !!! Sans quoi, crash quasi garanti.
En effet, si la map est en train de charger ses “tuiles” (chose qu'on ne maitrise pas) et que vous la désallouez, sachez que vous courrez au EXC_BAD_ACCESS… Encore une fois, si vous avez allouez via IB, vous ne courrez pas ce genre de risque. Puisqu'une désallocation de la map associée signifie presque certainement que vous désallouez le reste…

Attention également quand vous passez en background… des messages genre “void -[MKTileCache synchronize](MKTileCache*, objc_selector*) called while in background!” ne sont pas de bon augure !

Une carte sans annotation, c'est un peu comme un mac sans pomme...

Pour les annotations, on suivra à peu près le même principe :

  • on crée une annotation,
  • on l'ajoute à notre MKMapView,
  • on définit les méthodes qui vont nous permettre de customiser notre annotation dans le delegate de la mapView (et qui seront appelées par notification (ou callback))

MKAnnotation protocol

Il n'y a pas de classe MKAnnotation. Pour créer une annotation, on va donc créer un objet quelconque dérivé de NSObject par exemple et on va juste le rendre conforme au protocole MKAnnotation.
Pour être conforme, on va déclarer les propriétés suivantes :

  • les coordonnées (CLLocationCoordinate2D) ⇒ coordinate
  • un titre ⇒ title,
  • un sous titre ⇒ subtitle.

Seules les coordonnées sont obligatoires. On définira les setter et getter idoines. Si vous voulez stocker d'autres infos dans une annotation (et vous le voudrez surement), no problemo.
On prendra soin de déclarer que notre classe est conforme au protocole dans son header en ajoutant <MKAnnotation>.

Si vous comptez afficher une info-bulle quand l'utilisateur “tape” sur l'annotation, RENSEIGNEZ title ! Sinon, boum !

MKAnnotationView

Bon maintenant qu'on a notre (ou nos) annotation(s), y a plus qu'à les afficher.
Ca se fait grosso modo en 2 étapes :

  • on les ajoute simplement à notre map,
  • on définit les fonctions de callback que la map va appeler à travers son delegate pour gérer les paramètres d'affichage.

Allons-y !

/* Ici, on va ajouter notre annotation */
  MaClasseConformeAMKAnnotationProtocol * monAnnotation=[[MaClasseConformeAMKAnnotationProtocol alloc] init];
  monAnnotation.coordinate={latitude,longitude}; /* pratique comme écriture... */
  monAnnotation.title=@"Exemple d'annotation";
  [maMap addAnnotation:monAnnotation];
  [monAnnotation release]; /* la map fait un retain sur l'annotation. On peut releaser tranquille */
/* Ajout annotation ok */
 
/* Ici, on va définir les fonctions de callback que la map va appeler quand elle détecte qu'elle doit afficher
l'annotation - ben oui, si l'annotation est en France et que la carte affiche l'espagne, elle va pas afficher l'annotation... */
- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id <MKAnnotation>)annotation
{
    /* Afin de ne pas saturer la mémoire, on va utiliser ici un mécanisme de réutilisation des MKAnnotationView */
    static NSString *AnnotationViewID = @"annotationViewID";
    MKAnnotationView *annotationView =[mapView dequeueReusableAnnotationViewWithIdentifier:AnnotationViewID];
    /* si pas en stock, on alloue */
    if (annotationView == nil)
    {
        annotationView = [[[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:AnnotationViewID] autorelease]; 
/* on pourra utiliser MKPinAnnotationView pour avoir un pin's */
    }
    /* sinon, on recycle */
    else {
	annotationView.annotation = annotation;
    }
 
    /* si on veut customiser notre annotation */
    annotationView.image=[UIImage imageNamed:@"CASiteClip.png"];
    /* si on veut que notre annotation produise un pop up quand l'utilisateur "tape" dessus */		
    [annotationView setCanShowCallout:YES];
    /* ici, on veut que le pop up dispose d'un bouton à droite... */	
    UIButton * disclosure=[UIButton buttonWithType:UIButtonTypeCustom];
    UIImage * customDisclosure=[UIImage imageNamed:@"CACustomDisclosure"];
    disclosure.frame=CGRectMake(0, 0, customDisclosure.size.width, customDisclosure.size.height);
    [disclosure setImage:customDisclosure forState:UIControlStateNormal];
    annotationView.rightCalloutAccessoryView = disclosure;
    /* on peut décaler l'annotation de qques pixels */
    annotationView.centerOffset=unOffset;
    /* et voilà ! on retourne la toute nouvelle MKAnnotationView à notre map */
    return annotationView;
}
 
/* Ici, on va définir la fonction que la map appellera quand un utilisateur "tape" sur notre annotation */
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view calloutAccessoryControlTapped:(UIControl *)control {
	/* on dispose de toutes les propriétés de notre MKAnnotationView et de notre MKAnnotation 
	   par ex, view.annotation.coordinate.latitude 
           ou view.annotation.title */
}

Voilà notre map parée de sa belle annotation :mrgreen:

Si on veut qu'une annotation affiche son pop up direct sans interraction, on peut le faire comme ça :
- (void)mapViewDidFinishLoadingMap:(MKMapView *)mapView
{
    /* ici, on choisit uniquement la dernière annotation. Mais on pourrait boucler sur toutes... */
    if ([mapView annotations])
	[mapView selectAnnotation:[[mapView annotations] lastObject] animated:NO];
}

Et si j'en ai plusieurs des annotations !?

Aller, cadeau, une super astuce qui marche du tonnerre : une fonction qui cadre automatiquement la map pour que toutes les annotations soient visibles !

-(void)zoomToFitMapAnnotations:(MKMapView*)mapView
{
	if([mapView.annotations count] == 0)
		return;
 
	CLLocationCoordinate2D topLeftCoord;
	topLeftCoord.latitude = -90;
	topLeftCoord.longitude = 180;
 
	CLLocationCoordinate2D bottomRightCoord;
	bottomRightCoord.latitude = 90;
	bottomRightCoord.longitude = -180;
 
	for(MaClassePersoDeMesAnnotationsAMoi * annotation in mapView.annotations)
	{
		topLeftCoord.longitude = fmin(topLeftCoord.longitude, annotation.coordinate.longitude);
		topLeftCoord.latitude = fmax(topLeftCoord.latitude, annotation.coordinate.latitude);	
		bottomRightCoord.longitude = fmax(bottomRightCoord.longitude, annotation.coordinate.longitude);
		bottomRightCoord.latitude = fmin(bottomRightCoord.latitude, annotation.coordinate.latitude);
	}
 
	MKCoordinateRegion region;
	region.center.latitude = topLeftCoord.latitude - (topLeftCoord.latitude - bottomRightCoord.latitude) * 0.5;
	region.center.longitude = topLeftCoord.longitude + (bottomRightCoord.longitude - topLeftCoord.longitude) * 0.5;
	region.span.latitudeDelta = fabs(topLeftCoord.latitude - bottomRightCoord.latitude) * 1.2; // Add a little extra space on the sides
	region.span.longitudeDelta = fabs(bottomRightCoord.longitude - topLeftCoord.longitude) * 1.2; // Add a little extra space on the sides
	region = [mapView regionThatFits:region];
	[mapView setRegion:region animated:YES];
}

A l'envers, à l'endroit...

Le Geocoding se fait à l'endroit (on a une adresse, on veut des coordonnées) ou à l'envers (on a des coordonnées, on veut une adresse).
La Map Kit ne permet de geocoder qu'à l'envers via le MKReverseGeocoder. Mais avant de l'aborder, parlons un peu des…

MKPlacemark

Bon là, on est dans du simple. C'est un objet avec des attributs d'adresse :

  • addressDictionary ⇒ en rapport avec Carnet d'@ (adresse d'un contact)
  • thoroughfare ⇒ la rue
  • subThoroughfare ⇒ la rue si plus d'info
  • locality ⇒ ville, village
  • subLocality ⇒ précision sur ville, village
  • administrativeArea ⇒ je vous laisse voir la doc à partir de maintenant… Non mais :-/
  • subAdministrativeArea
  • postalCode
  • country
  • countryCode

On pourrait très bien utiliser une sous-classe de MKPlacemark, y ajouter les propriétés coordinate et title et s'en servir pour une annotation… Par exemple :-)
Mais on va surtout s'en servir avec le…

MKReverseGeocoder

Ta da ! Ca tue comme nom 8-)
Vous l'aurez compris, cette classe va permettre de retrouver une adresse à partir de coordonnées. Elle fonctionne comme les autres, c'est à dire qu'on lui demande de faire des trucs et on définit des méthodes de callback pour qu'elle nous renvoie les infos. Un ch'tit exemple :

CLLocationCoordinate2D coordinate;
coordinate.latitude=uneLatitude;
coordinate.longitude=uneLongitude;
MKReverseGeocoder * geocoder=[[MKReverseGeocoder alloc] initWithCoordinate:coordinate];
/* qui sait y qu'il doit prévenir ? */
geocoder.delegate=self;
/* Va chercher le chien :-) */
[newSearch start];

Et voilà notre geocoder qui lance sa recherche. Il va nous prévenir grâce à :

/* oh, un placemark !!! */
- (void)reverseGeocoder:(MKReverseGeocoder *)geocoder didFindPlacemark:(MKPlacemark *)placemark

ou alors…

/* Aie, ca fait mal... */
- (void)reverseGeocoder:(MKReverseGeocoder *)geocoder didFailWithError:(NSError *)error

A vous d'imaginer la suite une fois que vous avez votre placemark !

On ne sait pas trop quel est le contenu du contrat qui lie Apple et Google. Toujours est il qu'il y a un gros problème sur le reverseGeocoder… A certaines heures dans un pays donné, les requêtes vont échouées. A une autre heure, elles passeront sans problème… Ca semble être lié aux limites imposées par Google (histoire que personne aille leur piquer toute leur base :-)) mais il n'empêche que c'est très variable… A manier avec précaution donc, à savoir, mieux vaut ne pas être totalement tributaire d'un reverse geocoding…

Et maintenant, que vais je faire...

J'en ai fini avec mon tuto sur le Map Kit. iOS 4 apporte une nouveauté : l'overlay. C'est la possibilité d'ajouter des formes à une map (par exemple des lignes, des cercles, etc). Je ne l'ai pas encore expérimenté. Mais alors, mais alors…

Comment afficher un itinéraire ?!

Allez-vous me dire ?
Avec les overlays sans doute. Mais il y a une autre solution, plus simple à mettre en oeuvre : lancer l'application Plan avec tous ce qu'il faut comme paramètres.
Je vous donne un exemple prémaché qui a fait ses preuves :

/* je suis dans la méthode appelée lorsque l'utilisateur "tape" une annotation */
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view calloutAccessoryControlTapped:(UIControl *)control {
  // Open map application
  NSString * url=[NSString stringWithFormat:@"http://maps.google.com/maps?daddr=%f,%f&saddr=%f,%f"
					   view.annotation.coordinate.latitude,
					   view.annotation.coordinate.longitude,
                                           userLoc.latitude,userLoc.longitude];
  [[UIApplication sharedApplication] openURL:[NSURL URLWithString:url]];

Bon là, je fais mon fainéant car il aurait fallut encoder correctement ma NSString (avec les carctères %, etc d'une URL).
Dans cet exemple, quand l'utilisateur “tape” une annotation, on lance un itinéraire entre sa position et l'annotation dans l'application Plan. Cool non ?
Encore mieux, je vous indique un super lien qui vous permettra de connaitre toutes les astuces de ce système de lancement de Plan : http://mapki.com/wiki/Google_Map_Parameters

Astuces et Synthèse (ou synthèse des astuces...)

  • Pas de Forward Geocoding natif… Il faut passer par des web services (ceux de Google par exemple mais il en existe d'autres) pour pouvoir convertir une adresse en coordonnées.
  • Pour obtenir une adresse à partir de coordonnées, utilisez le MKReverseGeocoding.
  • Préférez créer vos maps sous IB que par programme.
  • Toujours mettre le delegate d'une MKMapView=nil avant de faire un release de la map.
  • ne cherchez pas de classe MKAnnotation, il n'y en a pas. A la place, définissez une classe perso telle que
    @interface MaClasseAnnotation : NSObject <MKAnnotation>

    et définissez les propriétés coordinate (obligatoire), title (optionelle) et subtitle (optionnelle).

  • Pour faire en sorte qu'une annotation affiche sa pop up sans interaction, on le fait dans mapViewDidFinishLoading avec la méthode selectAnnotation:animated:
  • pour afficher un itinéraire, on fait bosser “Plan”

Conclusion

C'est fini ! J'espère que ça vous aidera.
A+
LeChatNoir