使用MKMapView在App中嵌入地图

MapKit提供一系列接口,可以直接在View或者Window中嵌入地图,可以使用基础的功能。从iOS 5.1之后,MapKit不再使用Google地图服务,而改用高德地图。

理解地图几何学

MapView包含了一个平面化的地球。为了更加有效地使用地图,你应该理解如何在MapView中指定一个点并且可以把这个点对应到地球表面上的一个点。如果想在地图上添加自定义的内容,比如运动轨迹等,那么理解地图坐标系是很重要的。

地图坐标系

MapKit使用了墨卡托投影,如上图所示。球体坐标被映射到圆柱表面,展开后就生成一个平面地图。
MapKit支持3中基本坐标系来确定地图上的一点:

  • map coordinates :使用latitude(纬度)和longitude(经度)表示地表上的一点。以地球为模型,纬线所在平面是和赤道所在平面平行,经线所在平面和赤道所在平面垂直。根据我们平时所说的东经西经南纬北纬也可以分辨出。 Map coordinate是确定地表位置的主要方法。你可以用CLLocationCoordinate2D结构体表示一个map coordinate值, 用MKCoordinateSpanMKCoordinateRegion表示一个区域。当需要存储真实位置数据时,使用map coordinate是最好的选择。Core Location也使用map coordinate。
  • map points :墨卡托投影中使用的坐标。使用Map points可以大大简化相关计算。当需要在地图上加入自定义的覆盖物,需要计算形状和位置,这时,可以使用map point。使用MKMapPoint结构体表示一个map point值,使用MKMapSizeMKMapRect结构体表示一个区域。
  • points :它是view对象的坐标系的图形单元。在地图上绘制自定义view时,Map points 和 map coordinate 必须先转换成 points。使用CGPoint表示一个point,使用CGSize和CGRect表示一个区域。

注:CGPoint、CGSize和CGRect是我们熟知的结构体,所以理解map coordinate和map point相关的结构体不会太难。

不同坐标系之间的转换

  • Map Coordinates -> Points
- (CGPoint)convertCoordinate:(CLLocationCoordinate2D)coordinate toPointToView:(UIView *)view
- (CGRect)convertRegion:(MKCoordinateRegion)region toRectToView:(UIView *)view 
  • Map Coordinates -> Map Points
MKMapPoint MKMapPointForCoordinate ( CLLocationCoordinate2D coordinate );
  • Map Points -> Map Coordinates
CLLocationCoordinate2D MKCoordinateForMapPoint ( MKMapPoint mapPoint );
MKCoordinateRegion MKCoordinateRegionForMapRect ( MKMapRect rect );
  • Map Points -> Points
//iOS 7.0之后,这两个方法定义在`MKOverLayRenderer`类中,用来代替`MKOverlayView`。
- (CGPoint)pointForMapPoint:(MKMapPoint)mapPoint
- (CGRect)rectForMapRect:(MKMapRect)mapRect
  • Points -> Map Coordinates
- (CLLocationCoordinate2D)convertPoint:(CGPoint)point toCoordinateFromView:(UIView *)view
- (MKCoordinateRegion)convertRect:(CGRect)rect toRegionFromView:(UIView *)view
  • Points -> Map Points
//iOS 7.0之后,这两个方法定义在`MKOverLayRenderer`类中,用来代替`MKOverlayView`。
- (MKMapPoint)mapPointForPoint:(CGPoint)point
- (MKMapRect)mapRectForRect:(CGRect)rect

添加一个MapView到UI中

注意:在编译选项Build Phases -> Link Binary With Libraries中加入MapKit.framework,然后在源文件中#import <MapKit/MapKit.h>

MKMapView是一个功能齐全的接口,支持显示地区数据、响应用户操作、根据App支持自定义。__永远不要给它添加子类。__只需要直接嵌入到UI中就好了。它是有delegate的。它把所有相关的交互发送给delegate,以便于delegate能够做出正确的处理。

你可以通过代码或者IB添加一个MapView到App中:

  • 如果使用IB,只需要拖一个MKMapView到Storyboard中就好了。
  • 如果使用代码的话,先创建一个MKMapView实例,使用initWithFrame:方法进行初始化,然后使用addSubView:添加到视图层级中。

因为MKMapView是一个View,所以你可以像操作其他View那样操作它。比如改变它在视图中的Size或者Position,设置autoresizing,给它添加subView等。map view自身是不透明的容器。你添加的所有subView都是由它们自身的frame属性确定所在位置,不会随着地图的滑动而随之滑动。你如果希望随着地图的滑动而滑动的话,那么就必须使用annotations(注解)或者overlays(覆盖)。

一个新建的Map View仅仅用来显示地图数据,接收用户交互。默认的,一个标准的地图使用3D视角,可以倾斜、旋转,而且还会显示方向。可以通过改变mapType属性,设置显示卫星地图或者卫星图和地图数据混合的地图。还可以通过rotateEnablepitchEnablezoomEnablescrollEnable属性限制用户的操作。如果需要响应交互,那么就使用MapView中的delegate

设置地图的属性

MapView有一些可以进行设置的属性。比如,设置地图的可见区域、是否以3D方式呈现、是否允许用于交互等等。

设置地图的显示部分

首先,看一下MKCoordinateRegion结构体,定义如下:

typedef struct {
    CLLocationCoordinate2D center;
    MKCoordinateSpan span;
} MKCoordinateRegion;

在MKCoordinateRegion结构体中,__span__(跨度)定义了以当前经纬度为中心,显示在MapView中的地图的大小,也就是当前地图的缩放程度。span的表达虽然近似于矩形的宽和高度,但是因为使用map coordinate,因此是以度、分、秒为单位的。两条经线之间的距离会随着纬度的变化而变化。在赤道,经度1度的距离大约有111公里,而在极点,经度1度的距离却为0。如果需要使用来表示span的话,使用MKCoordinateRegionMakeWithDistance方法,就可以用代替

直接赋给region的值通常和最终存储到region的值是不同的。在地图呈现的时候,系统会自动对地图进行缩放调整,以确保地图完全显示在mapView的frame中。

给大家展示一个例子:

CLLocationCoordinate2D centerCoordinate = CLLocationCoordinate2DMake(30.283307, 120.115352);
MKCoordinateSpan span = MKCoordinateSpanMake(0.005, 0.005);
MKCoordinateRegion region = MKCoordinateRegionMake(centerCoordinate, span);
self.mapView.region = region;

NSLog(@"span.latitudeDelta:%f",self.mapView.region.span.latitudeDelta);
NSLog(@"span.longitudeDelta:%f",self.mapView.region.span.longitudeDelta);

控制台输出:

2016-03-10 18:33:30.483 MapKitDemo[11028:1610056] span.latitudeDelta:0.005000
//span的longitudeDelta的值和我初始化时赋的0.005的值有所偏差,这就是系统自动调整所致
2016-03-10 18:33:30.483 MapKitDemo[11028:1610056] span.longitudeDelta:0.005790

MKMapView类中还有一个regionThatFits:方法。在官方文档中有这样一句话:

You can use the regionThatFits: method to determine the region that will actually be set by the map.

意思是说可以用regionThatFits:方法得到被地图设置的真正的region的值。

缩放和平移

缩放和平移地图时,都会改变地图的显示区域,即改变region的值。

  • 平移。改变mapView的centerCoordinate属性或者camera的值实现地图的平移,或者调用setCenterCoordinate:Animated:setCamera:Animated:方法。
CLLocationCoordinate2D mapCenter = myMapView.centerCoordinate;
mapCenter = [myMapView convertPoint:
               CGPointMake(1, (myMapView.frame.size.height/2.0))
               toCoordinateFromView:myMapView];
[myMapView setCenterCoordinate:mapCenter animated:YES];
  • 缩放。改变region的值或者调用setRegion:Animated:方法。在3D地图中,改变camera的高度。放大地图,longitudeDelta和latitudeDelta的值变小。缩小地图,longitudeDelta和latitudeDelta的值变大。就是和相机的聚焦是一个道理。
MKCoordinateRegion theRegion = myMapView.region;
 
// 放大
theRegion.span.longitudeDelta *= 2.0;
theRegion.span.latitudeDelta *= 2.0;
[myMapView setRegion:theRegion animated:YES];

// 缩小
theRegion.span.longitudeDelta /= 2.0;
theRegion.span.latitudeDelta /= 2.0;
[myMapView setRegion:theRegion animated:YES];

显示用户当前位置

在MKMapView中,可以显示用户当前位置。只需要设置showsUserLocation属性的值为YES。MKMapView会通过Core Location来确定用户当前位置,然后在那里加上一个蓝色的圆点,作为标注。这就是我们所说的Annotation,用MKUserLocation类来表示。

MKUserLocation类就是用来显示用户当前位置的特殊标注。你不能给这个类创建实例,而是通过检索MKMapView对象的userLocation属性(它是一个数组类型的数据)。

如果想在用户当前位置显示自定义的Annotation,需要实现mapView:viewForAnnotation:方法。这个方法的返回值是UIView。如果返回nil,那么就会使用系统默认的标注样式。

注意:即使showsUserLocation被设置成YES,MapView也不会将用户当前位置显示在地图中央。showsUserLocation这个属性只是标识是否在用户当前位置显示标注,也就是那个蓝色的点。如果想把用户当前位置显示在地图中央,则需要设置userTrackingMode属性。

如果在mapView初始化时,给region属性赋值的话,mapView会优先显示region属性对应的区域,然后将用户当前位置显示在mapView中央。而如果在showsUserLocation设为YES后,再次设置region的值,则依旧会显示region所对应的区域。

代理方法

为了能响应代理方法,要遵循MKMapViewDelegate,并且设置mapView的delegate属性。

响应region变化

- (void)mapView:(MKMapView *)mapView regionWillChangeAnimated:(BOOL)animated
- (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated

在用户对地图进行缩放或者平移操作时,这两个方法会被频繁调用。因此,这两个方法的实现尽可能轻量化,避免影响用户滑动地图的体验。

请求地图数据

我们在使用地图时,经常会发现有些地方只显示的网格。这些网格就是由于当前请求到的数据不足以显示这个部分的地图数据而出现的。而请求到地图数据以碎片的方式渲染到mapView上。

// 开始从服务器请求地图数据时调用
- (void)mapViewWillStartLoadingMap:(MKMapView *)mapView
// 结束请求地图数据时调用
- (void)mapViewDidFinishLoadingMap:(MKMapView *)mapView
// 由于设备的网络或其他原因导致请求数据失败时调用
- (void)mapViewDidFailLoadingMap:(MKMapView *)mapView withError:(NSError *)error
// 开始渲染地图时调用
- (void)mapViewWillStartRenderingMap:(MKMapView *)mapView
// 结束渲染地图时调用,当所有碎片都成功渲染到mapView时,fullyRendered的值为YES,否则为NO
- (void)mapViewDidFinishRenderingMap:(MKMapView *)mapView fullyRendered:(BOOL)fullyRendered

mapView在显示地图数据时,首先会显示region设置的区域,这时能够看到的只是网格,然后根据region的值请求相应的数据,之后将请求到数据显示到mapView。因此,如果目前介绍的5个方法同时实现的话,被调用的顺序应该是:regionWillChange -> regionDidChange -> startRenderingMap -> startLoadingMap -> stopLoadingMap -> stopRenderingMap。

跟踪用户位置

// 当showsUserLocation的值设为YES时,会被调用
- (void)mapViewWillStartLocatingUser:(MKMapView *)mapView
// 当showsUserLocation的值设为NO时,会被调用
- (void)mapViewDidStopLocatingUser:(MKMapView *)mapView
// 当showsUserLocation被设为YES,且用户位置更新后,会被调用。
// 当userTrackingMode被设为MKUserTrackingModeFollowWithHeading,且设备所指方向改变时,也会被调用
- (void)mapView:(MKMapView *)mapView didUpdateUserLocation:(MKUserLocation *)userLocation
// 尝试定位失败时,会被调用。error包含失败的原因。
- (void)mapView:(MKMapView *)mapView didFailToLocateUserWithError:(NSError *)error
// 当userTrackingMode值改变时,会被调用
- (void)mapView:(MKMapView *)mapView didChangeUserTrackingMode:(MKUserTrackingMode)mode animated:(BOOL)animated

管理Annotation

- (MKAnnotationView *)mapView:(MKMapView *)mapView
            viewForAnnotation:(id<MKAnnotation>)annotation

这个方法的作用和tableView:cellForRowAtIndexPath:是相似的。它会返回MKAnnotationView类型的值。如果返回nil或者没有实现这个方法的话,会使用系统默认的标注样式。

不用每一次调用这个方法都创建一个新的MKAnnotationView,而是通过MKMapViewdequeueReusableAnnotationViewWithIdentifier:方法进行复用,如果不存在对应的annotation时,再创建一个新的MKAnnotationView对象。无论如何,都需要在这个方法里对MKAnnotationView的对象的属性进行设置,以显示需要的内容。

- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray<MKAnnotationView *> *)views

这个方法容易理解。在所有annotation显示完毕后调用它。

- (void)mapView:(MKMapView *)mapView
 annotationView:(MKAnnotationView *)view
calloutAccessoryControlTapped:(UIControl *)control

说道这个方法就不得不说一下MKAnnotationView的calloutView。当点击annotation后,弹出的View叫做calloutView。如果calloutView是继承自UIControl的类,比如UIButton,在它被点击之后就会调用这个方法。而如果calloutView不是继承自UIControl的类,那么就不会调用这个方法。

写了一个简单的例子。我重新写了一个MKAnnotationView,一个蓝色的点,来显示用户当前位置。它的leftCalloutAccessoryView是一个红色的按钮。当点击红色按钮时,`

  • mapView:annotationView:calloutAccessoryControlTapped:被调用,输出AnnotationView’s calloutView was tapped.`。

《使用MKMapView在App中嵌入地图》

- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id<MKAnnotation>)annotation {
    NSString *identifier = @"annotationView";
    MKAnnotationView *annotationView = [mapView dequeueReusableAnnotationViewWithIdentifier:identifier];
    if (annotationView == nil) {
        annotationView = [[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:identifier];
    }
    UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(10, 10, 10, 10)];
    [button setBackgroundColor:[UIColor redColor]];
    button.layer.cornerRadius = 5;
    annotationView.backgroundColor = [UIColor blueColor];
    annotationView.leftCalloutAccessoryView = button;
    [annotationView setFrame:CGRectMake(0, 0, 15, 15)];
    annotationView.layer.cornerRadius = 7.5f;
    annotationView.canShowCallout = YES;
    return annotationView;
}
- (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray<MKAnnotationView *> *)views {
    NSLog(@"AnnotationViews were added.");
}
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view calloutAccessoryControlTapped:(UIControl *)control {
    NSLog(@"AnnotationView's calloutView was tapped.");
}

选定Annotation

- (void)mapView:(MKMapView *)mapView
didSelectAnnotationView:(MKAnnotationView *)view
- (void)mapView:(MKMapView *)mapView
didDeselectAnnotationView:(MKAnnotationView *)view

当一个MKAnnotationView被选中或者被取消选中时,调用这两个方法。

调用系统中的地图App

在iOS 6之后,使用MKMapItem对象来打开地图App。这个类提供openMapsWithItems:launchOptions:类方法和openInMapsWithLaunchOptions:实例方法来打开地图,展示位置和方向。

参考文档:
Displaying Maps
MapKit Framework Reference

点赞