iOS 9 by Tutorials 笔记(十四)

Chapter 14 Location and Mapping

尽管 iOS 的地图服务被大家广为诟病,但 Apple 每年都会持续改进,iOS 9 也不例外,MapKit 和 Core Location 迎来一大波更新。

其中最有用的一个改进就是在地图上增加了行程导航,本章将学习这些新特性:

  • 自定义地图外观的新方法
  • 行程导航
  • 估计行程的时间
  • 使用 Core Location 进行单个位置更新

这一章,Café Transit 的示例应用程序是为所有的咖啡爱好者开发的。它可以帮助你寻找那些称赞的咖啡。目前,它只显示附近的的一小撮咖啡馆。而当你完成了这一章,App 会显示大量的有用的信息,包括每个咖啡店,评级,定价信息和开放时间。还会提供到指定咖啡店的行程导航信息,以及告诉你什么时候出发和什么时候到达。

Getting started

熟悉下 Demo 程序

  • ViewController.swift
    • setupMap() 设置地图并限定显示区域
    • addMapData() 从 model 中载入地图注释信息
  • CoffeeShop.swift 咖啡馆的 model 信息,并负责从 plist 载入信息
  • CoffeeShopPinDetailView.swiftCoffeeShopPinDetailView.xib 负责表示自定义的注释,该注释将会显示评分、价格信息和营业时间

Customizing maps

iOS 9 之前你只能通过编程的方式开启/关闭地图上的特定建筑物。iOS 9 介绍了三个新的 Boolen 属性,让你开启/关闭地图上的 compass-罗盘, scale bar-比例尺,traffic-交通流量

我们可以为 Café Transit 开启比例尺,在 setupMap() 里开启

mapView.showsScale = true  

随着对地图的缩放,比例尺也会跟着变化

Customizing map pins

在 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)
}

Customizing annotation callouts

咖啡馆在地图上以大头针形式标注,且当你点击大头针 annotation view 时,会显示一个标注信息 callout,展示关于该咖啡馆的额外的信息

在 iOS 9 之前,你想要在 annotation view 里添加一个自定义 View 并不是一件容易的事情。但现在 iOS 9 让事情变得简单了。MKAnnotationView 现在有一个新属性 detailCalloutAccessoryView 来展示这个 callouts,并且该 View 并没什么限制。

Managing callout size

Callouts 将根据你的自定义视图的大小来调整自己的尺寸。你的自定义 callouts 可以利用下面两种方式:

  • 在你的自定义视图中使用 Auto Layout 来布局
  • 你可以覆盖 intrinsicContentSize 来定制你需要的尺寸

这里用了 CoffeeShopPinDetailView.XIB 来设计自定义的 Callout,在 XIB 中我们使用 UIStackViewAuto Layout 来布局

自定义的 callouts 并不会铺满整个标注信息视图,他会显示一个标题和四周留白:

没办法修改标题和四周的留白区域

Adding a custom callout accessory view

理论学习完了,现在我们来添加自定义的 callout,UI 已经在 CoffeeShopPinDetailView.xib中设计好了

打开 ViewController.swiftmapView(_:viewForAnnotation:) 里加入下面的方法:

let detailView = UIView.loadFromNibNamed(identifier) as!  
  CoffeeShopPinDetailView
detailView.coffeeShop = annotation.coffeeshop  
annotationView!.detailCalloutAccessoryView = detailView  

首先从 XIB 文件中载入 CoffeeShopPinDetailView,然后为其分配当前的 coffeeshop,最后设置 view 的 detailCalloutAccessoryView 属性即可。

点击 Yelp 按钮会打开 Safari 将你带到该咖啡店在 Yelp 的评价主页,但时钟按钮现在还点不了,稍后我们来实现

Supporting time zones

我们上面添加的 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 为 MKMapItemCLPlacemark 添加了 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
  }
  1. 根据 plist 文件得到所有的咖啡馆
  2. 找出第一个咖啡馆的地理位置:经纬度
  3. 将经纬度转码成地理信息,在回调闭包得到 timeZone ,并设置为咖啡馆(CoffeeShop)的 timeZone

现在运行,你会发现每个咖啡馆会基于旧金山时间来显示是否营业,而不是你当地的时间。

在实际项目中,你需要判断每个咖啡馆的地理位置,因为他们可能分布在不同时区

Simulating your location

我们当前所有的咖啡馆都在旧金山,所以我们也要假装自己在旧金山。比较幸运的是 Xcode 很容易就能做到这一点

点击 CafeTransit scheme 选择 Edit Scheme

勾选 Allow Location Simulation

Making a single location request

在 iOS 9 之前,得到用户当前位置需要一个相当繁琐的过程,你要创建一个 CLLocationManager,实现一些代理方法,然后调用 startUpdatingLocation(),然后随着用户位置移动,会反复调用 location manager delegate 方法。如果一旦达到了期望的精度,你需要调用 stopUpdatingLocation() 来让 location manager 停止工作,不然你的手机电量会很快耗光。

在 iOS 9 将这些繁琐的过程封装成了一个方法:requestLocation(),他仍然利用了 API 中的 delegate 回调方法,但不需要你手动控制开始结束了。你进需要设置期望的精度,然后 Core Location 会提供给你位置信息。他只调用一次 delegate 并且只返回一个位置。

理论听够了,来看实际例子

Adding a location manager

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
  } 
}

Requesting transit directions

既然已经得到用户位置,现在让我们来添加前往指定咖啡馆的交通搭乘路线,这次在 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
}
  1. 创建一个 MKPlacemark 用来存储你的坐标,Placemarks 通常有一个相对应的地址,而 coffee shop model 提供了基本的店名(从通讯录中)
  2. 用第一步的 placemark 初始化一个 MKMapItem(封装地图上某一点的相关信息)
  3. 指定导航模式,这里一共有三种可供设置:
    • MKLaunchOptionsDirectionsModeDriving 开车
    • MKLaunchOptionsDirectionsModeWalking 步行
    • MKLaunchOptionsDirectionsModeTransit 搭乘公共交通
  4. 以指定的导航模式打开地图,并显示导航线路

我们在 transitTapped() 中调用这个 helper 方法

@IBAction func transitTapped() {
  openTransitDirectionsForCoordinates(coffeeShop.location)
}

运行,在地图上点击标记的咖啡馆大头针,弹出的 callout 视图中点按 train 图标,你就会直接进入公共交通导航界面

Querying transit times

最后一个新特性是,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)
    }
  } 
}
  1. 一旦确认用户位置,初始化一个 MKDirectionsRequest 实例
  2. 创建两个 MKMapItem 实例,一个表示用户位置,另一个表示咖啡馆的位置。这里没有用到 addressDictionary 是因为我们只需要经纬度信息。
  3. 设置 MKDirectionsRequest 对象的源地址和目的地址,交通工具类型
  4. 用上面的 request 创建一个 MKDirections 对象实例,并执行 ETA 计算
  5. 最终结果将以闭包形式返回,如果收到一个成功的响应,则根据 response 更新在 view 上更新出发时间和到达时间

最后实现点按时钟图标获取行程时间的方法,依然是在 CoffeeShopPinDetailView.swift

@IBAction func timeTapped() {
  if timeStackView.hidden {
    animateView(timeStackView, toHidden: false)
    requestTransitTimes()
  } else {
    animateView(timeStackView, toHidden: true)
  }
}

现在,当你按下时钟(clock)图标,时间视图会向上滑出,并向苹果服务器发送计算请求,最终行程的计算结果会自动更新子时间视图上。


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!