如何在 iOS 中实现 T9 搜索

已发表: 2022-03-11

几年前,我和我的 iOS/Android 团队一起开发了一款名为“BOG mBank - Mobile Banking”的应用程序。 该应用程序中有一个基本功能,您可以使用手机银行功能为您自己的手机后付费余额或任何联系人的手机余额充值。

在开发此模块时,我们注意到在 Android 版本的应用程序中查找特定联系人比在 iOS 版本中要容易得多。 为什么? 这背后的关键原因是 T9 搜索,这是 Apple 设备所缺少的。

让我们解释一下 T9 是什么,为什么它可能没有成为 iOS 的一部分,以及 iOS 开发人员在必要时如何实现它。

T9是什么?

T9是一种用于手机的预测文本技术,特别是那些包含物理 3x4 数字键盘的手机。

数字键盘上的 T9 搜索图示

T9 最初由 Tegic Communications 开发,名称代表Text on 9 keys

你可以猜到为什么 T9 可能永远不会进入 iOS。 在智能手机革命期间,T9 输入变得过时,因为现代智能手机依赖于全键盘,这得益于其触摸屏显示器。 由于 Apple 从来没有任何带物理键盘的手机,并且在 T9 的全盛时期也没有从事手机业务,因此 iOS 省略了这项技术是可以理解的。

T9 仍然用于某些不带触摸屏的廉价手机(所谓的功能手机)。 然而,尽管大多数 Android 手机从未配备物理键盘,但现代 Android 设备支持 T9 输入,可用于通过拼写试图呼叫的联系人的姓名来拨打联系人。

T9 预测输入实例

在带有数字小键盘的手机上,每次按下一个键 (1-9)(在文本字段中时),该算法都会返回一个猜测,以猜测在该点按下的键最有可能出现哪些字母。

Xcode 截图

例如,要输入单词“the”,用户将按 8,然后按 4,然后按 3,显示屏将显示“t”,然后是“th”,然后是“the”。 如果打算使用不太常见的词“fore”(3673),则预测算法可以选择“Ford”。 按“next”键(通常是“*”键)可能会调出“dose”,最后调出“fore”。 如果选择了“fore”,那么下次用户按下序列3673时,fore将更有可能是第一个显示的单词。 但是,如果要输入“Felix”一词,则在输入 33549 时,显示屏会显示“E”,然后显示“De”、“Del”、“Deli”和“Felix”。

这是输入单词时字母发生变化的示例。

在 iOS 中以编程方式使用 T9

因此,让我们深入研究此功能并为 iOS 编写一个简单的 T9 输入示例。 首先,我们需要创建一个新项目。

我们项目所需的先决条件是基本的:在 Mac 上安装 Xcode 和 Xcode 构建工具。

要创建一个新项目,请在 Mac 上打开您的 Xcode 应用程序并选择“创建一个新的 Xcode 项目”,然后为您的项目命名,并选择要创建的应用程序的类型。 只需选择“单一视图应用程序”,然后按下一步。

Xcode 截图

如您所见,在下一个屏幕上,您需要提供一些信息。

  • 产品名称:我命名为T9Search
  • 团队。 在这里,如果您想在真实设备上运行此应用程序,您将必须拥有一个开发者帐户。 就我而言,我将为此使用自己的帐户。

注意:如果您没有开发者帐户,您也可以在模拟器上运行它。

  • 组织名称:我将其命名为 Toptal
  • 组织标识符:我将其命名为“com.toptal”
  • 语言:选择斯威夫特
  • 取消选中“使用核心数据”、“包含单元测试”和“包含 UI 测试”

按下下一步按钮,我们就可以开始了。

简单的架构

如您所知,当您创建一个新应用程序时,您已经拥有MainViewController类和Main.Storyboard 。 当然,出于测试目的,我们可以使用这个控制器。

在我们开始设计之前,让我们首先创建所有必要的类和文件,以确保我们已设置并运行所有内容以移动到工作的 UI 部分。

在您的项目中的某个地方,只需创建一个名为“ PhoneContactsStore.swift ”的新文件,在我的例子中,它看起来像这样。

T9 搜索存储板和架构

我们的首要任务是创建一个包含各种数字键盘输入的地图。

 import Contacts import UIKit fileprivate let T9Map = [ " " : "0", "a" : "2", "b" : "2", "c" : "2", "d" : "3", "e" : "3", "f" : "3", "g" : "4", "h" : "4", "i" : "4", "j" : "5", "k" : "5", "l" : "5", "m" : "6", "n" : "6", "o" : "6", "p" : "7", "q" : "7", "r" : "7", "s" : "7", "t" : "8", "u" : "8", "v" : "8", "w" : "9", "x" : "9", "y" : "9", "z" : "9", "0" : "0", "1" : "1", "2" : "2", "3" : "3", "4" : "4", "5" : "5", "6" : "6", "7" : "7", "8" : "8", "9" : "9" ]

而已。 我们已经实现了包含所有变体的完整地图。 现在,让我们继续创建我们的第一个名为“ PhoneContact ”的类。

您的文件应如下所示:

图片替代文字

首先,在这个类中,我们需要确保我们有一个 AZ + 0-9 的 Regex 过滤器。

private let regex = try! NSRegularExpression(pattern: "[^ az()0-9+]", options: .caseInsensitive)

基本上,用户具有需要显示的默认属性:

 var firstName : String! var lastName : String! var phoneNumber : String! var t9String : String = "" var image : UIImage? var fullName: String! { get { return String(format: "%@ %@", self.firstName, self.lastName) } }

确保您已覆盖hashisEqual以指定您的自定义逻辑以进行列表过滤。

此外,我们需要使用 replace 方法来避免字符串中除了数字之外的任何内容。

 override var hash: Int { get { return self.phoneNumber.hash } } override func isEqual(_ object: Any?) -> Bool { if let obj = object as? PhoneContact { return obj.phoneNumber == self.phoneNumber } return false } private func replace(str : String) -> String { let range = NSMakeRange(0, str.count) return self.regex.stringByReplacingMatches(in: str, options: [], range: range, withTemplate: "") }

现在我们需要另一种称为calculateT9的方法来查找与fullnamephonenumber相关的联系人。

 func calculateT9() { for c in self.replace(str: self.fullName) { t9String.append(T9Map[String(c).localizedLowercase] ?? String(c)) } for c in self.replace(str: self.phoneNumber) { t9String.append(T9Map[String(c).localizedLowercase] ?? String(c)) } }

实现PhoneContact对象后,我们需要将联系人存储在内存中的某个位置。 为此,我将创建一个名为PhoneContactStore的新类。

我们将有两个本地属性:

fileprivate let contactsStore = CNContactStore()

和:

fileprivate lazy var dataSource = Set<PhoneContact>()

我使用Set来确保在填写此数据源期间没有重复。

 final class PhoneContactStore { fileprivate let contactsStore = CNContactStore() fileprivate lazy var dataSource = Set<PhoneContact>() static let instance : PhoneContactStore = { let instance = PhoneContactStore() return instance }() }

如您所见,这是一个 Singleton 类,这意味着我们将其保存在内存中,直到应用程序运行。 有关单例或设计模式的更多信息,您可以在此处阅读。

我们现在非常接近完成 T9 搜索。

把它们放在一起

在访问 Apple 上的联系人列表之前,您需要先请求许可。

 class func hasAccess() -> Bool { let authorizationStatus = CNContactStore.authorizationStatus(for: CNEntityType.contacts) return authorizationStatus == .authorized } class func requestForAccess(_ completionHandler: @escaping (_ accessGranted: Bool, _ error : CustomError?) -> Void) { let authorizationStatus = CNContactStore.authorizationStatus(for: CNEntityType.contacts) switch authorizationStatus { case .authorized: self.instance.loadAllContacts() completionHandler(true, nil) case .denied, .notDetermined: weak var wSelf = self.instance self.instance.contactsStore.requestAccess(for: CNEntityType.contacts, completionHandler: { (access, accessError) -> Void in var err: CustomError? if let e = accessError { err = CustomError(description: e.localizedDescription, code: 0) } else { wSelf?.loadAllContacts() } completionHandler(access, err) }) default: completionHandler(false, CustomError(description: "Common Error", code: 100)) } }

在我们授权访问联系人之后,我们可以编写从系统中获取列表的方法。

 fileprivate func loadAllContacts() { if self.dataSource.count == 0 { let keys = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactThumbnailImageDataKey, CNContactPhoneNumbersKey] do { let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) request.sortOrder = .givenName request.unifyResults = true if #available(iOS 10.0, *) { request.mutableObjects = false } else {} // Fallback on earlier versions try self.contactsStore.enumerateContacts(with: request, usingBlock: {(contact, ok) in DispatchQueue.main.async { for phone in contact.phoneNumbers { let local = PhoneContact() local.firstName = contact.givenName local.lastName = contact.familyName if let data = contact.thumbnailImageData { local.image = UIImage(data: data) } var phoneNum = phone.value.stringValue let strArr = phoneNum.components(separatedBy: CharacterSet.decimalDigits.inverted) phoneNum = NSArray(array: strArr).componentsJoined(by: "") local.phoneNumber = phoneNum local.calculateT9() self.dataSource.insert(local) } } }) } catch {} } }

我们已经将联系人列表加载到内存中,这意味着我们现在可以编写一个简单的方法:

  1. findWith - t9String
  2. findWith - str
 class func findWith(t9String: String) -> [PhoneContact] { return PhoneContactStore.instance.dataSource.filter({ $0.t9String.contains(t9String) }) } class func findWith(str: String) -> [PhoneContact] { return PhoneContactStore.instance .dataSource.filter({ $0.fullName.lowercased() .contains(str.lowercased()) }) } class func count() -> Int { let request = CNContactFetchRequest(keysToFetch: []) var count = 0; do { try self.instance.contactsStore.enumerateContacts( with: request, usingBlock: {(contact, ok) in count += 1; }) } catch {} return count }

而已。 我们完了。

现在我们可以在UIViewController中使用 T9 搜索。

 fileprivate let cellIdentifier = "contact_list_cell" final class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! @IBOutlet weak var searchBar: UISearchBar! fileprivate lazy var dataSource = [PhoneContact]() fileprivate var searchString : String? fileprivate var searchInT9 : Bool = true override func viewDidLoad() { super.viewDidLoad() self.tableView.register( UINib( nibName: "ContactListCell", bundle: nil ), forCellReuseIdentifier: "ContactListCell" ) self.searchBar.keyboardType = .numberPad PhoneContactStore.requestForAccess { (ok, err) in } } func filter(searchString: String, t9: Bool = true) { } func reloadListSection(section: Int, animation: UITableViewRowAnimation = .none) { } }

过滤器方法实现:

 func filter(searchString: String, t9: Bool = true) { self.searchString = searchString self.searchInT9 = t9 if let str = self.searchString { if t9 { self.dataSource = PhoneContactStore.findWith(t9String: str) } else { self.dataSource = PhoneContactStore.findWith(str: str) } } else { self.dataSource = [PhoneContact]() } self.reloadListSection(section: 0) }

Reload List 方法实现:

 func reloadListSection(section: Int, animation: UITableViewRowAnimation = .none) { if self.tableView.numberOfSections <= section { self.tableView.beginUpdates() self.tableView.insertSections(IndexSet(integersIn:0..<section + 1), with: animation) self.tableView.endUpdates() } self.tableView.reloadSections(IndexSet(integer:section), with: animation) }

这是我们简短教程的最后一部分, UITableView实现:

 extension ViewController: UITableViewDelegate, UITableViewDataSource, UISearchBarDelegate { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { return tableView.dequeueReusableCell(withIdentifier: "ContactListCell")! } func numberOfSections(in tableView: UITableView) -> Int { return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.dataSource.count } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let contactCell = cell as? ContactListCell else { return } let row = self.dataSource[indexPath.row] contactCell.configureCell( fullName: row.fullName, t9String: row.t9String, number: row.phoneNumber, searchStr: searchString, img: row.image, t9Search: self.searchInT9 ) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 55 } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { self.filter(searchString: searchText) } }

包起来

我们的 T9 搜索教程到此结束,希望您发现它在 iOS 中简单易行。

但你为什么要这样做? 为什么 Apple 一开始就没有在 iOS 中包含 T9 支持? 正如我们在介绍中指出的那样,T9 几乎不是当今手机的杀手级功能——它更像是一种事后的想法,是对带有机械数字键盘的“愚蠢”手机时代的回归。

但是,仍然有一些正当理由应该在某些场景下实现 T9 搜索,无论是为了一致性,还是为了提高可访问性和用户体验。 更令人高兴的是,如果你是那种怀旧的人,玩转 T9 输入可能会唤起你对学生时代的美好回忆。

最后,您可以在我的 GitHub 存储库中找到在 iOS 中实现 T9 的完整代码。