(原文出处: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: Candyif 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 BarsearchController.searchBar.scopeButtonTitles = ["All", "Chocolate", "Hard", "Other"]searchController.searchBar.delegate = self复制代码
这会给搜索条添加范围栏,范围栏中的标题来自甜品的分类。同样的,包括一个「所有」分类,选择这个分类,在搜索的时候,显示全部的分类内容。现在当你在搜索框输入搜索文本,返回结果会包括所选择的分类。
编译并运行 App,输入一些搜索文本,然后切换范围试试看。
增加一个指示器
为了解决这一问题,我们将添加一个底部视图到我们的页面中。当过滤状态下它将显示,并告诉用户关于搜索结果的信息。打开 SearchFooter.swift
。这就是我们要用的底部视图,它包含一个 Label 和接口。
回到MasterViewController.swift
。你已经设置了底部视图的IBOutlet,它在 Main.storyboard文件中。接下来在 viewDidLoad()
方法中设置它:
// Setup the search footertableView.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,执行搜索,观察底部信息的更新。点击键盘上的「搜索」,隐藏键盘然后可以看到底部视图。