Note: This tutorial has been updated to Xcode 9 beta, Swift 4, and iOS 11 by Tom Elliott. The original tutorial was written by Andy Pereira.
Scrolling through massive lists of items can be a slow and frustrating process for users. When dealing with large datasets, it’s vitally important to let the user search for specific items. UIKit includes UISearchBar
, which seamlessly integrates with UITableView
and allows for quick, responsive filtering of information.
In this UISearchController tutorial, you’ll build a searchable Candy app based on a standard table view. You’ll add table view search capability and dynamic filtering, and add an optional scope bar, all while taking advantage of UISearchController
. In the end, you’ll know how to make your apps much more user friendly and satisfy your users’ urgent demands.
Ready for some sugar-coated search results? Read on.
Getting Started
Download the starter project from the tutorial here and open the project. The app has already been set up with a navigation controller. In the Xcode project navigator, select the project file, CandySearch, then select the target CandySearch, and in the signing section update the team to your own development team. Build and run the app, and you’ll see an empty list:
Back in Xcode, the file Candy.swift contains a struct to store the information about each piece of candy you’ll be displaying. This struct has two properties: the category and name of the candy.
When the user searches for a candy in your app, you’ll be searching the name property using the user’s query string. You’ll see how the category string will become important near the end of this UISearchController tutorial when you implement the Scope Bar.
Populating the Table View
Open MasterViewController.swift. The candies
property will be where you manage all the different Candy
objects for your users to search. Speaking of which, it’s time to create some candy!
In this UISearchController tutorial, you only need to create a limited number of values to illustrate how the search bar works; in a production app, you might have thousands of these searchable objects. But whether an app has thousands of objects to search or just a few, the methods used will remain the same. Scalability at its finest!
To populate your candies
array, add the following code to viewDidLoad()
, after the call to super.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")
]
Build and run your project. Since the table view’s delegate and dataSource methods have already been implemented, you’ll see that you now have a working table view:
Selecting a row in the table will also display a detail view of the corresponding candy:
So much candy, so little time to find what you want! You need a UISearchBar
.
Introducing UISearchController
If you look at the UISearchController
documentation, you’ll discover it’s pretty lazy. It doesn’t do any of the work of searching at all. The class simply provides a standard interface that users have come to expect from their iOS apps.
UISearchController
communicates with a delegate protocol to let the rest of your app know what the user is doing. You have to write all of the actual functionality for string matching yourself.
Although this may seem a tad scary at first, writing custom search functions gives you tight control over how results are returned specifically in your app. Your users will appreciate searches that are intelligent — and fast.
If you’ve worked with searching table views iOS in the past, you may be familiar with UISearchDisplayController
. Since iOS 8, this class has been deprecated in favor of UISearchController
, which simplifies the entire search process.
Unfortunately, Interface Builder does not support UISearchController
at the time of this writing, so you’ll have to create your UI programmatically.
In MasterViewController.swift, add a new property under the candies
array declaration:
let searchController = UISearchController(searchResultsController: nil)
By initializing UISearchController
with a nil
value for the searchResultsController
, you tell the search controller that you want use the same view you’re searching to display the results. If you specify a different view controller here, that will be used to display the results instead.
To allow MasterViewController
to respond to the search bar, it will have to implement UISearchResultsUpdating
. This protocol defines methods to update search results based on information the user enters into the search bar.
Still in MasterViewController.swift, add the following class extension, outside of the main MasterViewController
class:
extension MasterViewController: UISearchResultsUpdating {
// MARK: - UISearchResultsUpdating Delegate
func updateSearchResults(for searchController: UISearchController) {
// TODO
}
}
updateSearchResults(for:)
is the one and only method that your class must implement to conform to the UISearchResultsUpdating
protocol. You’ll fill in the details shortly.
Next, you’ll need to set up a few parameters for your searchController
. Still in MasterViewController.swift, add the following to viewDidLoad()
, after the call to super.viewDidLoad()
:
// Setup the Search Controller
searchController.searchResultsUpdater = self
searchController.dimsBackgroundDuringPresentation = false
definesPresentationContext = true
tableView.tableHeaderView = searchController.searchBar
Here’s a rundown of what you just added:
searchResultsUpdater
is a property onUISearchController
that conforms to the new protocolUISearchResultsUpdating
. This protocol allows your class to be informed as text changes within theUISearchBar
.- By default,
UISearchController
will dim the view it is presented over. This is useful if you are using another view controller forsearchResultsController
. In this instance, you have set the current view to show the results, so you do not want to dim your view. - By setting
definesPresentationContext
on your view controller totrue
, you ensure that the search bar does not remain on the screen if the user navigates to another view controller while theUISearchController
is active. - Finally, you add the
searchBar
to your table view’stableHeaderView
. This is necessary as Interface Builder is not yet compatible withUISearchController
.
UISearchResultsUpdating and Filtering
After setting up the search controller, you’ll need to do some coding to get it working. First, add the following property near the top of MasterViewController
:
var filteredCandies = [Candy]()
This property will hold the candies that the user is searching for.
Next, add the following helper methods to the main MasterViewController
class:
// 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()
is fairly self-explanatory. filterContentForSearchText(_:scope:)
filters the candies
array based on searchText
and will put the results in the filteredCandies
array you just added. Don’t worry about the scope
parameter for now; you’ll use that in a later section of this tutorial.
filter()
takes a closure of type (candy: Candy) -> Bool
. It then loops over all the elements of the array, and calls the closure, passing in the current element, for every one of the elements.
You can use this to determine whether a candy should be part of the search results presented to the user. To do so, you need to return true
if the current candy is to be included in the filtered array, or false
otherwise.
To determine this, you use contains(_:)
to see if the name of the candy contains searchText
. But before doing the comparison, you convert both strings to their lowercase equivalents using the lowercased()
method.
Remember UISearchResultsUpdating
? You left a TODO in updateSearchResults(for:)
. Well, you’ve now just written a method that should be called when we want to update the search results. Voilà!
Replace the TODO in updateSearchResults(for:)
with a call to filterContentForSearchText(_:scope:)
:
filterContentForSearchText(searchController.searchBar.text!)
Now, whenever the user adds or removes text in the search bar, the UISearchController
will inform the MasterViewController
class of the change via a call to updateSearchResults(for:)
, which in turn calls filterContentForSearchText(_:scope:)
.
Build and run the app now, and you’ll notice that there is now a Search Bar above the table.
However, if you enter some search text you still don’t see any filtered results. What gives? This is simply because you haven’t yet written the code to let the table view know when to use the filtered results.
Updating the Table View
Back in MasterViewController.swift, add a method to determine if you are currently filtering results or not:
func isFiltering() -> Bool {
return searchController.isActive && !searchBarIsEmpty()
}
Next, replace tableView(_:numberOfRowsInSection:)
with the following:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if isFiltering() {
return filteredCandies.count
}
return candies.count
}
Not much has changed here; you simply check whether the user is searching or not, and use either the filtered or normal candies as a data source for the table.
Next, replace tableView(_:cellForRowAt:)
with the following:
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
}
Both methods now use isFiltering()
, which refers to the active
property of searchController
to determine which array to display.
When the user clicks in the search field of the Search Bar, active
will automatically be set to true
. If the search controller is active, and the user has actually typed something into the search field, the data returned is taken from the filteredCandies
array. Otherwise, the data comes from the full list of items.
Recall that the search controller automatically handles showing and hiding the results table, so all your code has to do is provide the correct data (filtered or non-filtered) depending on the state of the controller and whether the user has searched for anything.
Build and run the app. You’ve got a functioning Search Bar that filters the rows of the main table. Huzzah!
Play with the app for a bit to see how you can search for various candies.
Changing the Search Bar Appearance
This works great, but have you noticed the color of the search bar? It uses the default iOS colors. And while those are lovely, they don’t really scream “candy”, do they? :]
In AppDelegate.swift, add the following to the bottom of application(_:didFinishLaunchingWithOptions:)
, just before the return
statement:
UISearchBar.appearance().barTintColor = .candyGreen
UISearchBar.appearance().tintColor = .white
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).tintColor = .candyGreen
Here, you use the UIAppearance
API to set the bar tint color of your search bar to candy green, its tint color to white and then finally the tint color of any UITextField
elements contained within a UISearchBar
also to candy green.
Build and run the app, and perform another search.
Notice how your search bar is now green, and the cancel button is white. Isn’t it beautiful? :]
There’s still one more problem. When you select a row from the search results list, you may notice that the detail view shown is of the wrong candy! Time to fix that.
Sending Data to a Detail View
When sending information to a detail view controller, you need to ensure that the view controller knows which context the user is working with: the full table list, or the search results.
Still in MasterViewController.swift, in prepare(for:sender:)
, find the following code:
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]
}
Here you performed the same isFiltering()
check as before, but now you’re providing the proper candy object when performing a segue to the detail view controller.
Build and run the code at this point and see how the app now navigates correctly to the Detail View from either the main table or the search table with ease.
Creating a Scope Bar to Filter Results
If you wish to give your users another way to filter their results, you can add a Scope Bar in conjunction with your search bar in order to filter items by category. The categories you will filter on are the ones you assigned to the Candy
object when you created the candies array: Chocolate, Hard, and Other.
First, you have to create a scope bar in MasterViewController
. The scope bar is a segmented control that narrows down a search by only searching in certain scopes. A scope is really what you define it as. In this case it’s a candy’s category, but scopes could also be types, ranges, or something completely different.
Using the scope bar is as easy as implementing one additional delegate method.
In MasterViewController.swift, you’ll need to add another extension that conforms to UISearchBarDelegate
. After the UISearchResultsUpdating
extension which you added earlier, add the following:
extension MasterViewController: UISearchBarDelegate {
// MARK: - UISearchBar Delegate
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
filterContentForSearchText(searchBar.text!, scope: searchBar.scopeButtonTitles![selectedScope])
}
}
This delegate method is called when the user switches the scope in the scope bar. When that happens, you want to redo the filtering, so you call filterContentForSearchText(_:scope:)
with the new scope.
Now modify filterContentForSearchText(_:scope:)
to take the supplied scope into account:
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()
}
This now checks to see if the category of the candy matches the category passed in via the scope bar (or if the scope is set to “All”). You then check to see if there is text in the search bar, and filter the candy appropriately. You also need to update isFiltering()
to return true
when the scope bar is selected.
func isFiltering() -> Bool {
let searchBarScopeIsFiltering = searchController.searchBar.selectedScopeButtonIndex != 0
return searchController.isActive && (!searchBarIsEmpty() || searchBarScopeIsFiltering)
}
You’re almost there, but the scope filtering mechanism doesn’t quite work yet. You’ll need to modify updateSearchResults(for:)
in the first class extension you created to send the currently selected scope:
func updateSearchResults(for searchController: UISearchController) {
let searchBar = searchController.searchBar
let scope = searchBar.scopeButtonTitles![searchBar.selectedScopeButtonIndex]
filterContentForSearchText(searchController.searchBar.text!, scope: scope)
}
The only problem left is that the user doesn’t actually see a scope bar yet! To add the scope bar, move to the spot in the code just after the search controller setup. In viewDidLoad()
within MasterViewController.swift, add the following code just before the assignment to candies
:
// Setup the Scope Bar
searchController.searchBar.scopeButtonTitles = ["All", "Chocolate", "Hard", "Other"]
searchController.searchBar.delegate = self
This will add a scope bar to the search bar, with the titles that match the categories you assigned to your candy objects. You also include a catch-all category called “All” that you will use to ignore the candy category in the search altogether.
Now, when you type, the selected scope button will be used in conjunction with the search text.
Build and run your app. Try entering some search text, and changing the scope.
Type in “caramel” with the scope set to All. It shows up in the list, but when you change the scope to Chocolate, “caramel” disappears because it’s not a chocolate. Hurrah!
There is still one small problem with the app. We don’t indicate to the user how many results they should expect to see. This is particularly important when there are no results returned at all, as it can be difficult for the user to distinguish between something such as no results returned and a slow network connection fetching the answer.
Adding a Results Indicator
To fix this, we’re going to add a footer to our view. This will be visible when filtering is applied to our list of candies and will tell the user how many candies are in the filtered array. Open SearchFooter.swift. Here you have a simple UIView
which contains a label and has a public API to be notified about the number of results returned.
Head back to MasterViewController.swift. At the top of the class you already set up an IBOutlet
for the search footer, which can be seen in the master scene in Main.storyboard at the bottom of the screen. Add the following to viewDidLoad()
, after the spot where you set up the scope bar:
// Setup the search footer
tableView.tableFooterView = searchFooter
This sets your custom search footer view as the table view’s footer view. Next, you need to update it on the number of results when the search input changes. Replace tableView(_:numberOfRowsInSection:)
with the following:
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
}
All you’ve done here is add in calls to the searchFooter
.
Build and run the application, perform a few searches and watch the footer update as the searches return. You’ll need to hit the search button to dismiss the keyboard and see the search footer.
Where To Go From Here?
Congratulations – you now have a working app that allows you to search directly from the main table view.
Here is a downloadable sample project with all of the code from this UISearchController tutorial.
Table views are used in all kinds of apps, and offering a search option is a nice touch for usability. With UISearchBar
and the UISearchController
, iOS provides much of the functionality out of the box so there’s no excuse for not using it. The ability to search a large table view is something that today’s users expect; when they find it isn’t present, they won’t be happy campers.
I hope to see you adding search capabilities to your table view apps in the future. If you have any questions or comments, please join the forum discussion below!
The post UISearchController Tutorial: Getting Started appeared first on Ray Wenderlich.