尽管 iOS 的地图服务被大家广为诟病,但 Apple 每年都会持续改进,iOS 9 也不例外,MapKit 和 Core Location 迎来一大波更新。
其中最有用的一个改进就是在地图上增加了行程导航,本章将学习这些新特性:
这一章,Café Transit 的示例应用程序是为所有的咖啡爱好者开发的。它可以帮助你寻找那些称赞的咖啡。目前,它只显示附近的的一小撮咖啡馆。而当你完成了这一章,App 会显示大量的有用的信息,包括每个咖啡店,评级,定价信息和开放时间。还会提供到指定咖啡店的行程导航信息,以及告诉你什么时候出发和什么时候到达。
熟悉下 Demo 程序
iOS 9 之前你只能通过编程的方式 开启/关闭 地图上的特定建筑物。iOS 9 介绍了三个新的 Boolen 属性,让你 开启/关闭 地图上的 compass -罗盘, scale bar -比例尺, traffic -交通流量
我们可以为 Café Transit 开启比例尺,在 setupMap()
里开启
mapView.showsScale = true
随着对地图的缩放,比例尺也会跟着变化
在 iOS 9 中,苹果用 pinTintColor
替代了 pincolor
,新的属性允许你将标记大头针设置成自己喜欢的颜色了(原属性只能设红、绿、紫三种颜色)
我们来将五星评价的餐厅在地图上设为黄色,其余的设为棕色
if annotation.coffeeshop.rating.value == 5 { annotationView!.pinTintColor = UIColor(red:1, green:0.79, blue:0, alpha:1) } else { annotationView!.pinTintColor = UIColor(red:0.419, green:0.266, blue:0.215, alpha:1) }
咖啡馆在地图上以大头针形式标注,且当你点击大头针 annotation view
时,会显示一个标注信息 callout ,展示关于该咖啡馆的额外的信息
在 iOS 9 之前,你想要在 annotation view 里添加一个自定义 View 并不是一件容易的事情。但现在 iOS 9 让事情变得简单了。 MKAnnotationView
现在有一个新属性 detailCalloutAccessoryView
来展示这个 callouts ,并且该 View 并没什么限制。
Callouts 将根据你的自定义视图的大小来调整自己的尺寸。你的自定义 callouts 可以利用下面两种方式:
intrinsicContentSize
来定制你需要的尺寸 这里用了 CoffeeShopPinDetailView.XIB 来设计自定义的 Callout ,在 XIB 中我们使用 UIStackView
和 Auto Layout
来布局
自定义的 callouts 并不会铺满整个标注信息视图,他会显示一个标题和四周留白:
没办法修改标题和四周的留白区域
理论学习完了,现在我们来添加自定义的 callout,UI 已经在 CoffeeShopPinDetailView.xib
中设计好了
打开 ViewController.swift 在 mapView(_:viewForAnnotation:)
里加入下面的方法:
let detailView = UIView.loadFromNibNamed(identifier) as! CoffeeShopPinDetailView detailView.coffeeShop = annotation.coffeeshop annotationView!.detailCalloutAccessoryView = detailView
首先从 XIB 文件中载入 CoffeeShopPinDetailView
,然后为其分配当前的 coffeeshop
,最后设置 view 的 detailCalloutAccessoryView
属性即可。
点击 Yelp 按钮会打开 Safari 将你带到该咖啡店在 Yelp 的评价主页,但时钟按钮现在还点不了,稍后我们来实现
我们上面添加的 callouts 包含一个标识,指示当前咖啡馆是否正在营业:
static var timeZone = NSTimeZone(abbreviation: "PST")! /// Calculates whether a coffee shop is currently open for business var isOpenNow: Bool { let calendar = NSCalendar.currentCalendar() let nowComponents = calendar.componentsInTimeZone(CoffeeShop.timeZone, fromDate: NSDate()) ...
isOpenNow
是个计算属性,用来标识当前营业状态。这里使用了 NSDate() 得到当前时间,并转换成咖啡馆所在时区的时间,以此来判断咖啡馆现在是否开始营业了。
很简单不是吗?但我们观察这一句:
static var timeZone = NSTimeZone(abbreviation: "PST")!
这里硬编码了时区 PST,虽然我们的 APP 只包含了三藩的咖啡馆,但如果能根据地理位置自动推断出对应的时区时间岂不是更赞!
iOS 9 为 MKMapItem
和 CLPlacemark
添加了 timeZone
属性,我们利用该属性来得到符合当前地理位置的正确时间。
static func allCoffeeShops() -> [CoffeeShop] { guard let path = NSBundle.mainBundle().pathForResource("sanfrancisco_coffeeshops", ofType: "plist"), let array = NSArray(contentsOfFile: path) as? [[String : AnyObject]] else { return [CoffeeShop]() } // 1 let shops = array.flatMap { CoffeeShop(dictionary: $0) } .sort { $0.name < $1.name } // 2 let first = shops.first! let location = CLLocation(latitude: first.location.latitude, longitude: first.location.longitude) // 3 let geocoder = CLGeocoder() geocoder.reverseGeocodeLocation(location) { (placemarks, _) in if let placemark = placemarks?.first, timeZone = placemark.timeZone { self.timeZone = timeZone } } return shops }
plist
文件得到所有的咖啡馆 timeZone
,并设置为咖啡馆( CoffeeShop
)的 timeZone
现在运行,你会发现每个咖啡馆会基于旧金山时间来显示是否营业,而不是你当地的时间。
在实际项目中,你需要判断每个咖啡馆的地理位置,因为他们可能分布在不同时区
我们当前所有的咖啡馆都在旧金山,所以我们也要假装自己在旧金山。比较幸运的是 Xcode 很容易就能做到这一点
点击 CafeTransit scheme 选择 Edit Scheme
在 iOS 9 之前,得到用户当前位置需要一个相当繁琐的过程,你要创建一个 CLLocationManager
,实现一些代理方法,然后调用 startUpdatingLocation()
,然后随着用户位置移动,会反复调用 location manager delegate 方法。如果一旦达到了期望的精度,你需要调用 stopUpdatingLocation()
来让 location manager 停止工作,不然你的手机电量会很快耗光。
在 iOS 9 将这些繁琐的过程封装成了一个方法: requestLocation()
,他仍然利用了 API 中的 delegate 回调方法,但不需要你手动控制开始结束了。你进需要设置期望的精度,然后 Core Location 会提供给你位置信息。他只调用一次 delegate
并且只返回一个位置。
理论听够了,来看实际例子
在 ViewController.swift 的类声明下添加两个对象:
lazy var locationManager = CLLocationManager() // 用来存储用户位置 var currentUserLocation: CLLocationCoordinate2D?
在 viewDidLoad()
中设置代理和期望精度:
locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
下面我们来实现这个 CLLocationManagerDelegate
代理
// MARK:- CLLocationManagerDelegate extension ViewController: CLLocationManagerDelegate { // 查看该 App 是否有权限查看用户位置信息,如果有,请求用户位置 func locationManager(manager: CLLocationManager, didChangeAuthorizationStatus status: CLAuthorizationStatus) { if (status == CLAuthorizationStatus.AuthorizedAlways || status == CLAuthorizationStatus.AuthorizedWhenInUse) { locationManager.requestLocation() } } // 存储返回的第一个位置坐标 func locationManager(manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { currentUserLocation = locations.first?.coordinate } // 记录错误 func locationManager(manager: CLLocationManager, didFailWithError error: NSError) { print("Error finding location: + /(error.localizedDescription)") } }
现在你需要从其他地方调用 requestLocation()
下面在 ViewController.swift 添加一个私有方法用来获取用户位置:
private func requestUserLocation() { // 在地图上显示用户位置 mapView.showsUserLocation = true // 请求之前判断权限,有权限更新位置,无权限先鉴权 if CLLocationManager.authorizationStatus() == .AuthorizedWhenInUse { locationManager.requestLocation() } else { locationManager.requestWhenInUseAuthorization() } }
注意,当你调用 requestWhenInUseAuthorization()
请求权限时,必须已经提前在 Info.plist 文件中,为 key: NSLocationWhenInUseUsageDescription
设置好了对应的键值。这个键值通常是一个字符串,会随鉴权请求弹窗一起展示给用户。
我们让地图一出现在屏幕上就请求用户位置
override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) requestUserLocation() }
最后我们在 MKMapViewDelegate 中将用户位置传递给选中的 annotation
(咖啡馆大头针标记),为下一节交通导航路线做准备
func mapView(mapView: MKMapView, didSelectAnnotationView view: MKAnnotationView) { if let detailView = view.detailCalloutAccessoryView as? CoffeeShopPinDetailView { detailView.currentUserLocation = currentUserLocation } }
既然已经得到用户位置,现在让我们来添加前往指定咖啡馆的交通搭乘路线,这次在 CoffeeShopPinDetailView.swift 中添加一个 helper 方法
func openTransitDirectionsForCoordinates( coord:CLLocationCoordinate2D) { let placemark = MKPlacemark(coordinate: coord, addressDictionary: coffeeShop.addressDictionary) // 1 let mapItem = MKMapItem(placemark: placemark) // 2 let launchOptions = [MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeTransit] // 3 mapItem.openInMapsWithLaunchOptions(launchOptions) // 4 }
MKPlacemark
用来存储你的坐标, Placemarks
通常有一个相对应的地址,而 coffee shop model 提供了基本的店名(从通讯录中) placemark
初始化一个 MKMapItem
(封装地图上某一点的相关信息) MKLaunchOptionsDirectionsModeDriving
开车 MKLaunchOptionsDirectionsModeWalking
步行 MKLaunchOptionsDirectionsModeTransit
搭乘公共交通 我们在 transitTapped() 中调用这个 helper 方法
@IBAction func transitTapped() { openTransitDirectionsForCoordinates(coffeeShop.location) }
运行,在地图上点击标记的咖啡馆大头针,弹出的 callout 视图中点按 train 图标,你就会直接进入公共交通导航界面
最后一个新特性是,MapKit 允许你查询公共交通行程信息。 MKETAResponse
类在 iOS 9 新增了下面一些有用的属性:
public var expectedTravelTime: NSTimeInterval { get } @available(iOS 7.0, *) public var distance: CLLocationDistance { get } @available(iOS 9.0, *) public var expectedArrivalDate: NSDate { get } @available(iOS 9.0, *) public var expectedDepartureDate: NSDate { get } @available(iOS 9.0, *) public var transportType: MKDirectionsTransportType { get }
这些属性告诉你旅行的距离,时间以及出发时间和到达时间。
同样在地图上点击标记的咖啡馆大头针,弹出的 callout 视图中点按时钟图标,整个 view 会以动画的形式向上展示预估的出发时间和达到时间,下面让我们来实现这种效果:
还是在 CoffeeShopPinDetailView.swift 中,刚才的 helper 方法下面添加:
func requestTransitTimes() { guard let currentUserLocation = currentUserLocation else { return } // 1 let request = MKDirectionsRequest() // 2 let source = MKMapItem(placemark: MKPlacemark(coordinate: currentUserLocation, addressDictionary: nil)) let destination = MKMapItem(placemark: MKPlacemark(coordinate: coffeeShop.location, addressDictionary: nil)) // 3 request.source = source request.destination = destination request.transportType = MKDirectionsTransportType.Transit // 4 let directions = MKDirections(request: request) directions.calculateETAWithCompletionHandler { response, error in if let error = error { print(error.localizedDescription) } else { // 5 self.updateEstimatedTimeLabels(response) } } }
MKDirectionsRequest
实例 MKDirectionsRequest
对象的源地址和目的地址,交通工具类型 request
创建一个 MKDirections
对象实例,并执行 ETA 计算 最后实现点按时钟图标获取行程时间的方法,依然是在 CoffeeShopPinDetailView.swift :
@IBAction func timeTapped() { if timeStackView.hidden { animateView(timeStackView, toHidden: false) requestTransitTimes() } else { animateView(timeStackView, toHidden: true) } }
现在,当你按下时钟(clock)图标,时间视图会向上滑出,并向苹果服务器发送计算请求,最终行程的计算结果会自动更新子时间视图上。