如何在 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 的完整代碼。