(原文出處:https://www.raywenderlich.com/157864/uisearchcontroller-tutorial-getting-started)
注:本指南已經由 Tom Elliott
適配 Xcode 9,Swift 4,以及 iOS 11。原版教程編寫者是 Andy Pereira
。
劃過複雜混亂的列表既慢又使人心煩。當資料來源巨大的時候,提供搜尋功能搜尋指定條目是對使用者十分重要的功能。UIKit 提供了 UISearchBar,允許你無縫整合到 UINavigationItem ,並可快速響應資訊過濾。
在本教程中,你將基於標準 TableView 構建一個可搜尋的 Candy app。你將賦予 tableView 搜尋和動態過濾能力,以及新增範圍欄,這些全部依賴 UISearchController 的特性。在最後,我們將討論如何讓你的 App 更加友好,以及更滿足使用者的需要。
準備好了麼?開始吧!
開始
下載初始專案原始碼並開啟。此時已經設定了一個導航控制器。在 Xcode 專案導航,選擇專案 CandySearch,然後選擇 target CandySearch,然後找signing 欄目中,配置你的開發者資訊。編譯執行 App,你將看到一個空列表:
回到 Xcode,檔案 Canday.swift 中包含一個結構體 Candy,這是你要顯示在列表中的元素。這個結構體有兩個屬性:category 和 name。
當使用者在 App 中搜尋糖果時,使用的是 name 欄位作為搜尋條件。在本教程快結束的時候,你將看到 category 字串作為 Scope Bar 來實現分類。
構建 Table View
開啟 MasterViewController.swift
。candies
屬性將用來儲存所有糖果物件,供使用者搜尋。說到這兒,是時候建立糖果物件啦!在本教程中,你只需要建立有限數量的物件,用來演示 search bar 的工作;在正式 App 中,你可能有數千個物件用於搜尋。但是不管是有數千物件用於搜尋,還是數個物件用於搜尋,使用方法是不變的。伸縮性很好。
新增以下程式碼在viewDidLoad
,用於構建你的糖果物件陣列:
candies = [
Candy(category:"Chocolate", name:"Chocolate Bar"),
Candy(category:"Chocolate", name:"Chocolate Chip"),
Candy(category:"Chocolate", name:"Dark Chocolate"),
Candy(category:"Hard", name:"Lollipop"),
Candy(category:"Hard", name:"Candy Cane"),
Candy(category:"Hard", name:"Jaw Breaker"),
Candy(category:"Other", name:"Caramel"),
Candy(category:"Other", name:"Sour Chew"),
Candy(category:"Other", name:"Gummi Bear"),
Candy(category:"Other", name:"Candy Floss"),
Candy(category:"Chocolate", name:"Chocolate Coin"),
Candy(category:"Chocolate", name:"Chocolate Egg"),
Candy(category:"Other", name:"Jelly Beans"),
Candy(category:"Other", name:"Liquorice"),
Candy(category:"Hard", name:"Toffee Apple")
]
複製程式碼
編譯執行你的專案。因為 delegate 和 dataSource 已經被實現,所以此時 tableView 已經存在資料:
在 table 中隨意選擇一行將展示該糖果的詳情:
糖果有很多,查詢起來需要一些時間,所以你需要一個 UISearchBar。
介紹 UISearchController
如果你看過 UISearchController 的文件,你會發現這是個懶惰的物件。關於搜尋的工作其實它啥也沒做。這個類提供了一組使用者所期待的那種標準的互動操作方式。
UISearchController 通過代理協議連線讓 App 知道使用者的輸入。具體的字串匹配和結果過濾過濾必須由你來完成。
雖然這有點嚇人,但編寫自定義搜尋功能讓你嚴格控制在 App 中的返回結果,你的使用者將開心的用上智慧、快速的搜尋。
如果你之前編寫過搜尋功能,你也許會熟悉UISearchDisplayController
。從 iOS8 開始,這個類已經被標記為廢棄。UISearchController 被推薦使用且簡化了整個的搜尋流程。
不幸的是,截止到本文編寫,Interface Builder 還並不支援 UISearchController,所以你必須使用程式碼構建你的 UI。
在 MasterViewController.swift
檔案中,增加一個新的屬性:
let searchController = UISearchController(searchResultsController: nil)
複製程式碼
如果使用 nil
初始化 UISearchController
,那麼搜尋結果也使用相同的檢視來進行現實。如果這裡指定了一個非空的 View Controller
,那它將被用於顯示搜尋結果。
響應搜尋框使用者輸入的資訊,需要給 MasterViewController
實現 UISearchResultUpdating
協議定義的方法。給 MasterViewController.swift
增加以下擴充套件:
extension MasterViewController: UISearchResultsUpdating {
// MARK: - UISearchResultsUpdating Delegate
func updateSearchResults(for searchController: UISearchController) {
// TODO
}
}
複製程式碼
updateSearchResults(for:)
是唯一一個需要你的類實現的 UISearchResultUpdating
協議方法。我們等下就會把細節填滿。
接下來,需要設定一些引數給 searchController
。仍然是在MasterViewController.swift
,在viewDidLoad()
的super.viewDidLoad()
呼叫之後:
// Setup the Search Controller
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.placeholder = "Search Candies"
navigationItem.searchController = searchController
definesPresentationContext = true
複製程式碼
總結一下這些程式碼都做了什麼:
-
UISearchController
的searchResultsUpdater
屬性指向一個UISearchResultsUpdating
協議。響應使用者在UISearchBar
中的輸入由這個協議完成。 -
預設情況下,
UISearchController
彈出來的時候,檢視的背景是模糊的。這是因為我們傳遞了別的ViewController
作為searchResultsController
。在我們剛剛的程式碼中,我們使用了相同的ViewController
用於搜尋結果返回,所以檢視的背景沒有模糊。 -
設定佔位符文字。
-
在 iOS 11 中,新增
searchBar
到NavigationItem
。目前在Interface Builder
還不能直接操作。 -
設定了
ViewController
的definesPresentationContext
為true
,確保當UISearchController
為活躍狀態時,使用者導航到了新的ViewController
(如從搜尋結果), 搜尋欄還在螢幕最上方。
過濾搜尋結果
設定完之後 SearchController,需要新增一些程式碼使它工作。首先增加下面這個屬性給MasterViewController:
var filteredCandies = [Candy]()
複製程式碼
這個屬性將儲存使用者搜尋用的 candies
資料集合。
接下來,新增下面這些輔助方法給 MasterViewController
類:
// MARK: - Private instance methods
func searchBarIsEmpty() -> Bool {
// Returns true if the text is empty or nil
return searchController.searchBar.text?.isEmpty ?? true
}
func filterContentForSearchText(_ searchText: String, scope: String = "All") {
filteredCandies = candies.filter({( candy : Candy) -> Bool in
return candy.name.lowercased().contains(searchText.lowercased())
})
tableView.reloadData()
}
複製程式碼
searchBarIsEmpty()
是一個便利方法。filterContentForSearchText(_:scope:)
方法根據searchText
文字過濾candies
陣列,然後將過濾後的結果生成filterdCandies
陣列。不要擔心scope
引數,下一節我們來出來它。
filter()
方法接受一個閉包(candy: Candy) -> Bool
。在這個閉包中,我們判斷陣列是不是我們想要的,如果是,返回true
,否則返回false
,我們根據返回結果生成陣列。
我們使用lowercased()
方法把文字先轉換成小寫。使用contains(_:)
方法對文字內容進行判斷。
註釋:大多數時候,使用者不想去刻意區分大小寫版本的搜尋結果,所以我們對使用者輸入的內容和蛋糕名稱都進行小寫處理然後比較。你輸入`Chocolate`或者`chocolate`都應該能找到蛋糕。這很有用,對吧?
複製程式碼
還記得 UISearchResultsUpdating
協議麼?你留了一個TODO
在updateSearchResults(for:)
方法裡面。現在補充一個方法呼叫,更新搜尋結果。
替換 updateSearchResults(for:)
方法中的TODO
為filterContentForSearchText(_:scope:)
方法呼叫:
filterContentForSearchText(searchController.searchBar.text!)
複製程式碼
現在,不管使用者何時增加或者刪除搜尋框中的文字,UISearchController
將通知MasterViewController
,通過updateSearchResults(for:)
方法。在其中呼叫filterContentForSearchText(_:scope:)
對搜尋結果進行過濾。
編譯然後執行 App 現在,滾動到下面,你將看到在搜尋框下面的列表。
當輸入搜尋文字時,你什麼返回結果也看不到。這是因為你沒有寫處理返回資料的程式碼給 TableView。
更新 TableView
回到 MasterViewController.swift,增加一個方法在過濾時候呼叫:
func isFiltering() -> Bool {
return searchController.isActive && !searchBarIsEmpty()
}
複製程式碼
替換tableView(_:numberOfRowsInSection:)
方法:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if isFiltering() {
return filteredCandies.count
}
return candies.count
}
複製程式碼
這裡沒有做很多變動;簡單的檢查使用者是否在搜尋狀態下,然後決定使用正常資料來源或者搜尋的資料來源並更新 tableView。
接下來,替換tableView(_:cellForRowAt:)
:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let candy: Candy
if isFiltering() {
candy = filteredCandies[indexPath.row]
} else {
candy = candies[indexPath.row]
}
cell.textLabel!.text = candy.name
cell.detailTextLabel!.text = candy.category
return cell
}
複製程式碼
這兩個方法都使用了 isFiltering()
,來決定載入哪個資料來源。
當使用者點選搜尋框時候,active
屬性自動被設定為true
。然後從 filteredCandies
陣列載入資料。正常的時候是載入完整資料的。
回顧search controller
的展示結果的處理過程,我們所做的只是根據狀態提供正確的資料來源。
此時編譯並執行 App。現在已經可以過濾 SearchBar 的搜尋內容啦!
雖然現在列表的內容顯示正確,但詳情頁展示的資料有誤。我們這就來修復它。
傳遞資料給詳情檢視
當想要傳遞資料給詳情檢視控制器,你需要確保檢視控制器知道使用者是從哪個檢視控制器進行的操作:所有資料列表,還是搜尋返回結果列表。仍然在MasterViewController.swift
,在prepare(for:sender:),找到以下程式碼:
let candy = candies[indexPath.row]
Then replace it with the following:
let candy: Candy
if isFiltering() {
candy = filteredCandies[indexPath.row]
} else {
candy = candies[indexPath.row]
}
複製程式碼
這裡執行相同的isFiltering() 方法進行過濾。當使用者執行操作的時候,你需要提供正確的 Candy 傳遞給詳情檢視控制器。
此時編譯並執行程式碼,不管使用者是從資料列表檢視還是從搜尋結果檢視操作,App 都可以正確的導航至詳情檢視了。
建立一個範圍欄過濾返回結果
如果你想給使用者提供另一種過濾返回結果的方式,你可以新增一個範圍欄結合搜尋欄對搜尋結果進行分類。這裡分類的依據是甜品的種類:Chocolate,Hard,以及 Other。首先你必須在MasterViewController
中建立一個範圍欄。範圍欄是一個分段控制元件,通過它限制搜尋範圍。範圍是你所定義的。這裡是甜品的種類。範圍是可以自定義的,你可以使用型別、範圍或者其他完全不同的東西。使用範圍欄就像實現一個代理方法一樣容易。
在 MasterViewController.swift
中,你需要增加實現了UISearchBarDelegate
協議的擴充套件:
extension MasterViewController: UISearchBarDelegate {
// MARK: - UISearchBar Delegate
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
filterContentForSearchText(searchBar.text!, scope: searchBar.scopeButtonTitles![selectedScope])
}
}
複製程式碼
當使用者切換範圍欄上的不同分類時,該方法會被呼叫。這時你需要呼叫filterContentForSearchText(_:scope:)
。
現在修改filterContentForSearchText(_:scope:)
方法,將範圍考慮在裡面:
func filterContentForSearchText(_ searchText: String, scope: String = "All") {
filteredCandies = candies.filter({( candy : Candy) -> Bool in
let doesCategoryMatch = (scope == "All") || (candy.category == scope)
if searchBarIsEmpty() {
return doesCategoryMatch
} else {
return doesCategoryMatch && candy.name.lowercased().contains(searchText.lowercased())
}
})
tableView.reloadData()
}
複製程式碼
現在的過濾邏輯是先匹配的分類,然後過濾掉所有名字不包含使用者輸入到搜尋框文字的物件。現在更新 isFilteing()
方法,以適配範圍欄被選擇時返回正確的結果
func isFiltering() -> Bool {
let searchBarScopeIsFiltering = searchController.searchBar.selectedScopeButtonIndex != 0
return searchController.isActive && (!searchBarIsEmpty() || searchBarScopeIsFiltering)
}
複製程式碼
已經接近成功了,但範圍過濾機制還不能很好的工作。還需要修改擴充套件中的updateSearchResults(for:)
,傳遞選擇的分類:
func updateSearchResults(for searchController: UISearchController) {
let searchBar = searchController.searchBar
let scope = searchBar.scopeButtonTitles![searchBar.selectedScopeButtonIndex]
filterContentForSearchText(searchController.searchBar.text!, scope: scope)
}
複製程式碼
最後一個問題是使用者還不能看到範圍欄。我們把目光移動到search controller
初始化的地方。在MasterViewController.swift
的viewDidLoad()
方法中,新增以下程式碼在生成 candies 陣列之前:
// Setup the Scope Bar
searchController.searchBar.scopeButtonTitles = ["All", "Chocolate", "Hard", "Other"]
searchController.searchBar.delegate = self
複製程式碼
這會給搜尋條新增範圍欄,範圍欄中的標題來自甜品的分類。同樣的,包括一個「所有」分類,選擇這個分類,在搜尋的時候,顯示全部的分類內容。現在當你在搜尋框輸入搜尋文字,返回結果會包括所選擇的分類。
編譯並執行 App,輸入一些搜尋文字,然後切換範圍試試看。
增加一個指示器
為了解決這一問題,我們將新增一個底部檢視到我們的頁面中。當過濾狀態下它將顯示,並告訴使用者關於搜尋結果的資訊。開啟 SearchFooter.swift
。這就是我們要用的底部檢視,它包含一個 Label 和介面。
回到MasterViewController.swift
。你已經設定了底部檢視的IBOutlet,它在 Main.storyboard檔案中。接下來在 viewDidLoad()
方法中設定它:
// Setup the search footer
tableView.tableFooterView = searchFooter
複製程式碼
這將自定義 tableView 的底部檢視。接下來,你需要更新它的資訊在使用者執行搜尋的時候。替換 tableView(_:numberOfRowsInSection:)
方法的程式碼:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if isFiltering() {
searchFooter.setIsFilteringToShow(filteredItemCount: filteredCandies.count, of: candies.count)
return filteredCandies.count
}
searchFooter.setNotFiltering()
return candies.count
}
複製程式碼
到此,底部檢視新增完畢。
編譯並執行App,執行搜尋,觀察底部資訊的更新。點選鍵盤上的「搜尋」,隱藏鍵盤然後可以看到底部檢視。