iOS 9 by Tutorials 笔记(十二)

Chapter 12: Contacts

在 iOS 9 之前,开发者只能使用 C API 来访问 iOS 设备上的通讯录,随着 iOS 9 的推出,Apple 彻底废除了之前的做法,介绍了两种全新的面向对象的高级框架来管理用户通讯录(ContactsContactsUI

本章将展示如何使用这两个框架:

  1. 使用 ContactsUI 框架显示和选择联系人
  2. 添加联系人到用户的通讯录中
  3. 搜索用户通讯录并使用 NSPredicate 来过滤

Getting started

本章的 Start Demo 也很简单,主界面是一个 tableView ,每行 cell 显示一个联系人信息,包括:联系人头像、名字、邮箱地址

App 初始化的时候会提供几个联系人的信息以供显示,下面来完成我们的第一个任务:使用 ContactsUI 框架来显示联系人的详细细节信息。

App 的主要类:

  • FriendsViewController.swift UITableViewController
  • Friend.swift Model 类,代表每个联系人
  • FriendCell.swift 管理着每个 cell 的显示效果

Displaying a contact

第一步为 cell 加一个 Disclosure Indicator,新增的标识告诉用户可点击进入详情页面

在显示用户详细信息之前,我们需要将 Friend 实例转换成一个 CNContact

Convert friends to CNContacts

Contacts 框架将每个联系人看做是 CNContact 类的一个实例,包含了很多联系人属性,如 givenNamefamilyNameemailAddressesimageData

现在来做转换,在 Friend.swiftimport Contacts,添加一个 extension

extension Friend {  
  var contactValue: CNContact {
// 1  
    let contact = CNMutableContact()
    // 2
    contact.givenName = firstName
    contact.familyName = lastName
    // 3
    contact.emailAddresses = [
      CNLabeledValue(label: CNLabelWork, value: workEmail)
    ]
// 4
    if let profilePicture = profilePicture {
      let imageData =
        UIImageJPEGRepresentation(profilePicture, 1)
      contact.imageData = imageData
}
// 5
    return contact.copy() as! CNContact
  }
}

首先创建了一个 CNMutableContact 实例(CNContact 的可变子类),接着更新了相关属性(从 Friend 结构体之前定义的常量中获取相关属性)。emailAddresses 是一个 CNLabeledValue 对象的数组,意味着每个 email 都对应着一个标签 label,有许多这样的标记,暂且这里设定为 CNLabelWork。最后我们返回一个不可变的拷贝对象(CNContact

CNContact 是线程安全的,而 CNMutableContact 不是

Showing the contact's information

接着实现点击联系人列表进入详情页面,在 FriendsViewController.swift 中导入

import Contacts  
import ContactsUI  

添加 UITableViewDelegate 方法:

//MARK: UITableViewDelegate
extension FriendsViewController {  
  override func tableView(tableView: UITableView,
    didSelectRowAtIndexPath indexPath: NSIndexPath) {
      tableView.deselectRowAtIndexPath(indexPath,
        animated: true)
      // 1
      let friend = friendsList[indexPath.row]
      let contact = friend.contactValue
      // 2
      let contactViewController =
        CNContactViewController(forUnknownContact: contact)
      contactViewController.navigationItem.title = "Profile"
      contactViewController.hidesBottomBarWhenPushed = true
      // 3
      contactViewController.allowsEditing = false
      contactViewController.allowsActions = false
      // 4
      navigationController?.pushViewController
        (contactViewController, animated: true)
  }
}

观察注释 2 ,实例化了一个 CNContactViewController,这是 ContactsUI 框架用来展示联系人信息用的。这里用到了 forUnknownContact 来初始化是因为该联系人并不存在于 iOS 的通讯录中,随后通过 contactViewController 的相关属性对 navigation bartab bar 做了些配置

运行,选中某个 cell,ContactsUI 框架会展示选中联系人的信息:

如果我们要添加更多的好友,可以使用 ContactsUI 类中的 CNContactPickerViewController 来让用户从联系人中选择添加到 App

Picking your friends

我们在 SB 中给 FriendsViewController 加一个 AddButtonUIBarButtonItem

为这个 AddButton 创建一个关联(target-action)的方法

@IBAction func addFriends(sender: UIBarButtonItem) {
  let contactPicker = CNContactPickerViewController()
  presentViewController(contactPicker, animated: true, completion: nil)
}

现在点击 AddButton 会展示一个 CNContactPickerViewController,此时如果你选中任意一个联系人,只会将你带到详情页面,并不能将选中的联系人添加回主页面列表

要解决这个问题,需要利用到 CNContactPickerDelegate

Conforming to CNContactPickerDelegate

CNContactPickerDelegate 有五个可选方法,目前我们只对 contactPicker(_:didSelectContacts:) 感兴趣,当你实现了该方法,CNContactPickerViewController 就会知道你想要支持多选中,下面让我们来实现下

extension FriendsViewController: CNContactPickerDelegate {  
  func contactPicker(picker: CNContactPickerViewController,
    didSelectContacts contacts: [CNContact]) {
    // TODO
  }
}

最后一个参数 contacts,存储着选中的多个联系人信息(CNContact 数组)。回顾一下, 在 FriendsViewController 中我们使用的数据源是数组:friendsList[Friend])。所以这里转换一下,将 [CNContact] -> [Friend],我们将这种转换放到 model 中来实现

Friend 再添加一个初始方法,可通过传入一个 CNContact 来初始化一个 Friend

init(contact: CNContact){  
  firstName = contact.givenName
  lastName = contact.familyName
  workEmail = contact.emailAddresses.first!.value as! String
  if let imageData = contact.imageData{
    profilePicture = UIImage(data: imageData)
  } else {
    profilePicture = nil
  }
}

contact.emailAddresses.first! 代表一个 CNLabeledValue 对象,所以通过 .value 来提取具体的值。

有了 [CNContact] -> [Friend] 转换方法,我们来实现这个代理方法:

extension FriendsViewController: CNContactPickerDelegate {  
  func contactPicker(picker: CNContactPickerViewController,
    didSelectContacts contacts: [CNContact]) {
    let newFriends = contacts.map { Friend(contact: $0) }
    for friend in newFriends {
       if !friendsList.contains(friend){
         friendsList.append(friend)
        }
    }
    tableView.reloadData()
  }
}

将选中的 [CNContact] 转化成 [Friend],再填加到数据源数组 friendsList

最后别忘了在 addFriends 方法中设置 delegate

contactPicker.delegate = self  

运行,现在可以选中多个联系人了

选择完毕后按 Done,添加了几个朋友回到了主界面

注意这里有个问题!如果你选中的联系人没有 email,那么 App 会崩溃掉,这是因为之前我们在 Friend 的初始化方法 init(contact:) 中对 email 地址使用了强制解包,没有 email 的 contact 就会崩溃。

有没有办法只允许用户选择存在 email 的联系人呢?当然可以,在 presentViewController(_:animated:completion:): 之前先筛选一下子呗:

contactPicker.predicateForEnablingContact = NSPredicate(format: "emailAddresses.@count > 0")  

属性 predicateForEnablingContact 让你决定筛选哪些联系人可以被选定,这里我们限定了 email 不为空

现在运行,单击添加按钮,你会看到 email 不存在的联系人都变灰色了(不可选中状态)

现在你可以很自然地从通讯录中创建好友了

Saving friends to the user's contacts

接下来我们实现:当用户在 table view cell 上向左滑动,会显示一个 Create Contact 操作按钮来将当前对应的联系人添加到系统通讯录中

先来实现 UI 层面上效果,在 FriendsViewController.swift 的 delegate 中添加:

override func tableView(tableView: UITableView,  
  editActionsForRowAtIndexPath indexPath: NSIndexPath)
  -> [UITableViewRowAction]? {
  let createContact = UITableViewRowAction(style: .Normal,
    title: "Create Contact") { rowAction, indexPath in
    tableView.setEditing(false, animated: true)
    // TODO: Add the contact
  }
  createContact.backgroundColor = BlueColor
  return [createContact]
}

上面的代码创建了一个名称为 Create Contactrow action,背景是蓝色的

在你访问或修改用户通讯录之前,最重要的事情是记得先向用户申请权限,Contacts 框架里已经内建了权限功能,你不能在没有授权的情况下访问或修改通讯录

之前我们使用 CNContactPickerViewController 时并没有向用户鉴权,是因为使用 CNContactPickerViewController 的时候,你的 App 并不直接参与访问或修改通讯录。

Asking for permission

我们来完善上面的代码,在 TODO 的地方申请通讯录权限:

override func tableView(tableView: UITableView,  
  editActionsForRowAtIndexPath indexPath: NSIndexPath)
  -> [UITableViewRowAction]? {
  let createContact = UITableViewRowAction(style: .Normal,
    title: "Create Contact") { rowAction, indexPath in
    // 将系统默认的左滑删除关掉了
    tableView.setEditing(false, animated: true)
    // 申请通讯录权限
    let contactStore = CNContactStore()
    contactStore.requestAccessForEntityType(CNEntityType.Contacts) {
      userGrantedAccess, _ in
      guard userGrantedAccess else {
        self.presentPermissionErrorAlert()
        return
      }
    }
  }
  createContact.backgroundColor = BlueColor
  return [createContact]
}

在上面的代码中,我们首先创建了 CNContactStore 的实例,表示用户的通讯录,接着通过 requestAccessForEntityType(:completion:) 来向用户申请权限,而用户最终的反馈结果将以 completion handler 闭包的形式通过一个 Bool 参数 userGrantedAccess 传回来。最后为了更好的用户体验,当 userGrantedAccess 为 NO 的时候,我们会弹一个 Alert 说明理由,并引导用户去 Setting 里重新分配权限。

关于 presentPermissionErrorAlert

func presentPermissionErrorAlert() {  
  dispatch_async(dispatch_get_main_queue()) {
    let alert =
      UIAlertController(title: "Could Not Save Contact",
        message: "How am I supposed to add the contact if " +
        "you didn't give me permission?",
        preferredStyle: .Alert)

    let openSettingsAction = UIAlertAction(title: "Settings",
      style: .Default, handler: { alert in
        UIApplication.sharedApplication()
          .openURL(
            NSURL(string: UIApplicationOpenSettingsURLString)!)
    })

    let dismissAction = UIAlertAction(title: "OK",
      style: .Cancel, handler: nil)

    alert.addAction(openSettingsAction)
    alert.addAction(dismissAction)
    self.presentViewController(alert, animated: true,
      completion: nil)
  }
}

这里我们使用了 dispatch_async(dispatch_get_main_queue()) 是因为 requestAccessForEntityType(:completion:)completion handler 会在后台线程中执行,因此弹窗这种 UI 操作还是要回主线程

弹窗的第一个 UIAlertAction 使用 UIApplicationOpenSettingsURLString key 来打开 Settings

运行一下,左滑 cell 选择 Create Contact 创建联系人,弹出

选择 Don't Allow,guard 判断并执行一个弹窗操作,引导用户去 Setting 里授权

按下 Setting,弹窗将会把你呆到 Settings 界面

Saving friends to contacts

下面处理授权通过的情况下如何将好友保存到通讯录

func saveFriendToContacts(friend: Friend) {  
  // 1
  let contact = friend.contactValue.mutableCopy()
    as! CNMutableContact
  // 2
  let saveRequest = CNSaveRequest()
  // 3
  saveRequest.addContact(contact,
    toContainerWithIdentifier: nil)
  do {
    // 4
    let contactStore = CNContactStore()
    try contactStore.executeSaveRequest(saveRequest)
    // Show Success Alert
    dispatch_async(dispatch_get_main_queue()) {
      let successAlert = UIAlertController(title: "Contacts Saved",
        message: nil, preferredStyle: .Alert)
      successAlert.addAction(UIAlertAction(title: "OK",
        style: .Cancel, handler: nil))
      self.presentViewController(successAlert, animated: true,
        completion: nil)
      }
  } catch {
    // Show Failure Alert
    dispatch_async(dispatch_get_main_queue()) {
      let failureAlert = UIAlertController(
        title: "Could Not Save Contact",
        message: "An unknown error occurred.",
        preferredStyle: .Alert)
      failureAlert.addAction(UIAlertAction(title: "OK",
        style: .Cancel, handler: nil))
      self.presentViewController(failureAlert, animated: true,
        completion: nil)
    }
  } 
}

因为 addContact:toContainerWithIdentifier: 需要一个 CNMutableContact 作为参数,所以第一步先做个转换;第二步创建了 CNSaveRequest 对象,我们利用该对象来传递增加、更新或删除联系人等操作信息给通讯录(CNContactStore);第三步告诉 CNSaveRequest 你想要增加一个联系人到通讯录中;最后执行保存操作

无论 contactStore.executeSaveRequest(saveRequest) 成功还是失败,都会弹窗提醒用户

回到 tableView(_:editActionsForRowAtIndexPath:) 在 guard block 后保存当前索引对应的 friend

let friend = self.friendsList[indexPath.row]  
self.saveFriendToContacts(friend)  

重置模拟器运行,现在你可以左划 cell 将当前联系人添加到系统通讯录中了

细心的童鞋或许已经发现:可以重复添加联系人到通讯录中,下面来修正:

Checking for existing contacts

为了去重,我们在保存联系人之前先判断一下,在 saveFriendToContacts(_:): 一开始添加

//1
let contactFormatter = CNContactFormatter()  
//2
let contactName = contactFormatter  
  .stringFromContact(friend.contactValue)!
//3
let predicateForMatchingName = CNContact  
  .predicateForContactsMatchingName(contactName)
//4
let matchingContacts = try! CNContactStore()  
  .unifiedContactsMatchingPredicate(predicateForMatchingName,
    keysToFetch: [])
//4
guard matchingContacts.isEmpty else {  
  dispatch_async(dispatch_get_main_queue()) {
    let alert = UIAlertController(
      title: "Contact Already Exists", message: nil,
      preferredStyle: .Alert)
    alert.addAction(UIAlertAction(title: "OK", style: .Cancel,
      handler: nil))
    self.presentViewController(alert, animated: true,
      completion: nil)
  }
  return
}
  1. CNContactFormatter 根据本机环境(通讯录)来定义联系人的样式,有点类似于 NSDateFormatter 做的事情
  2. 使用这种 formatter 来得到一个联系人的姓名
  3. 用上面获得的联系人姓名创建一个谓词
  4. 利用这个谓词来获取所有匹配姓名的联系人,注意这一步返回的是一个 [CNContact] 数组
  5. 判断一下,如果有重复的联系人就给用户弹个警告窗,直接返回了

unifiedContactsMatchingPredicate(_:keysToFetch:) 方法中,我们为参数 keysToFetch 传入了一个空数组,因为当时我们并不需要访问或修改获取到的联系人。但是假如要访问获取到的联系人 first name,你就要添加 CNContactGivenNameKeykeysToFetch 数组中去。


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