37

UISearchController Tutorial: Getting Started [FREE]

 4 years ago
source link: https://www.tuicool.com/articles/j6R3Ury
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Update note : Lorenzo Boaro updated this tutorial to Xcode 11, Swift 5 and iOS 13. Tom Elliott wrote the original.

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 UINavigationItem via a UISearchController and allows for quick, responsive filtering of information.

In this tutorial, you’ll build a searchable Candy app based on a standard table view. You’ll add table view search capability, dynamic filtering and 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 how to satisfy your users’ urgent demands.

Ready for some sugar-coated search results? Read on.

Getting Started

Start by downloading the starter project using the Download Materials button at the top or bottom of this tutorial. Once it’s downloaded, open CandySearch.xcodeproj in Xcode.

To keep you focused, the starter project has everything unrelated to searching and filtering already set up for you.

Open Main.storyboard and look at the view controllers contained within:

candy-storyboard-650x333.png

The view controller on the left is the root navigation controller of the app. Then you have:

  1. MasterViewController : This contains the table view that you’ll use to display and filter the candies you’re interested in.
  2. DetailViewController : This displays the details of the selected candy along with its image.

Build and run the app and you’ll see an empty list:

candy-empty-281x500.png

Back in Xcode, the file Candy.swift contains a struct to store the information about each piece of candy you’ll display. This struct has two properties:

  • name : This property has type String and is fairly self-explanatory.
  • category : This is an enum of type Candy.Category , which represents the category each candy belongs to. It also conforms to RawRepresentable so that you can convert it to and from an associated raw value of type String .

When the user searches for a type of candy in your app, you’ll search the name property using the user’s query string. The category will become important near the end of this tutorial, when you implement the scope bar.

Populating the Table View

Open MasterViewController.swift . You’ll manage all the different Candy for your users to search in candies . Speaking of which, it’s time to create some candy!

Note : In this 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 you use will remain the same. This is scalability at its finest!

To populate candies , add the following code to viewDidLoad() after the call to super.viewDidLoad() :

candies = Candy.candies()

Build and run. Since the sample project has already implemented the table view’s data source methods, you’ll see that you now have a working table view:

candy-working-281x500.png

Selecting a row in the table will also display a detail view of the corresponding candy:

candy-detail-281x500.png

So much candy, so little time to find what you want! You need a UISearchBar .

Introducing UISearchController

If you look at UISearchController ‘s documentation, you’ll discover it’s pretty lazy. It doesn’t do any of the work of searching at all. The class simply provides the 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 scary at first, writing custom search functions gives you tight control over how your specific app returns results. Your users will appreciate searches that are intelligent and fast.

If you’ve worked with searching table views in iOS in the past, you may be familiar with UISearchDisplayController . Since iOS 8, Apple has deprecated this class in favor of UISearchController , which simplifies the entire search process.

In MasterViewController.swift , add a new property under candies ‘ declaration:

let searchController = UISearchController(searchResultsController: nil)

By initializing UISearchController with a nil value for searchResultsController , you’re telling the search controller that you want to use the same view you’re searching to display the results. If you specify a different view controller here, the search controller will display the results in that view controller instead.

In order for MasterViewController to respond to the search bar, it must 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 :

extension MasterViewController: UISearchResultsUpdating {
  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.

Setting Up searchController ‘s Parameters

Next, you need to set up a few parameters for your searchController . Still in MasterViewController.swift , add the following to viewDidLoad() , just after the assignment to candies :

// 1
searchController.searchResultsUpdater = self
// 2
searchController.obscuresBackgroundDuringPresentation = false
// 3
searchController.searchBar.placeholder = "Search Candies"
// 4
navigationItem.searchController = searchController
// 5
definesPresentationContext = true

Here’s a breakdown of what you’ve just added:

  1. searchResultsUpdater is a property on UISearchController that conforms to the new protocol, UISearchResultsUpdating . With this protocol, UISearchResultsUpdating will inform your class of any text changes within the UISearchBar .
  2. By default, UISearchController obscures the view controller containing the information you’re searching. This is useful if you’re using another view controller for your searchResultsController . In this instance, you’ve set the current view to show the results, so you don’t want to obscure your view.
  3. Here, you set the placeholder to something that’s specific to this app.
  4. New for iOS 11, you add the searchBar to the navigationItem . This is necessary because Interface Builder is not yet compatible with UISearchController .
  5. Finally, by setting definesPresentationContext on your view controller to true , you ensure that the search bar doesn’t remain on the screen if the user navigates to another view controller while the UISearchController is active.

Filtering With UISearchResultsUpdating

After you set 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 searches for.

Next, add the following computed property to the main MasterViewController :

var isSearchBarEmpty: Bool {
  return searchController.searchBar.text?.isEmpty ?? true
}

isSearchBarEmpty returns true if the text typed in the search bar is empty; otherwise, it returns false .

Still within MasterViewController.swift , add the following method at the end of MasterViewController :

func filterContentForSearchText(_ searchText: String,
                                category: Candy.Category? = nil) {
  filteredCandies = candies.filter { (candy: Candy) -> Bool in
    return candy.name.lowercased().contains(searchText.lowercased())
  }
  
  tableView.reloadData()
}

filterContentForSearchText(_:category:) filters candies based on searchText and puts the results in filteredCandies , which you’ve just added. Don’t worry about the category 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 that the user receives. To do so, you need to return true if you want to include the current candy 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 lowercased() .

Note : Most of the time, users don’t bother with the case of letters when performing a search, so by only comparing the lowercase version of what they type with the lowercase version of the name of each candy, you can easily return a case-insensitive match. Now, you can type “Chocolate” or “chocolate” and either will return a matching candy. How useful is that?! :]

Remember UISearchResultsUpdating ? You left it unimplemented. Well, you’ve just written a method that you want to call when you update the search results. Voilà!

Replace the TODO in updateSearchResults(for:) with the following code:

let searchBar = searchController.searchBar
filterContentForSearchText(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(_:category:) .

Build and run and you’ll notice that there’s now a search bar above the table. You may need to scroll down to see it.

candy-scrolling.gif

However, when you enter search text, you still don’t see any filtered results. What gives?

This is simply because you haven’t written the code to let the table view know when to use the filtered results yet.

Updating the Table View

In the main MasterViewController class of MasterViewController.swift , add a computed property to determine if you are currently filtering results or not:

var isFiltering: Bool {
  return searchController.isActive && !isSearchBarEmpty
}

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, then use either the filtered or the normal candies as the 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.rawValue
  return cell
}

Both methods now use isFiltering , which refers to the isActive property of searchController to determine which array to display.

When the user taps the search field of the search bar, isActive is automatically set to true . If the search controller is active and the user has typed something into the search field, the returned data comes from filteredCandies . Otherwise, the data comes from the full list of items.

Remember 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 now have a functioning Search Bar that filters the rows of the main table. Huzzah!

candy-typing.gif

Play with the app for a bit to see how you can search for various candies.

But wait, there’s still one more problem. When you select a row from the search results list, you may notice the detail view is from 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 the view controller knows which context the user is working with: The full table list or the search results. Here’s how you handle that.

Still in MasterViewController.swift , in prepare(for:sender:) , find the following code:

let candy = candies[indexPath.row]

And replace it with the following:

let candy: Candy
if isFiltering {
  candy = filteredCandies[indexPath.row]
} else {
  candy = candies[indexPath.row]
}

Here, you perform the same isFiltering check as before, but now you’re providing the proper candy object when segueing 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

To give your users another way to filter their results, add a scope bar in conjunction with your search bar to filter items by category. The categories you’ll filter by are the ones you assigned to the candy object when you created candies : Chocolate , Hard and Other .

First, you have to create a scope bar in MasterViewController . The scope bar is a segmented control that narrows a search by only looking in certain scopes. The scope is whatever you define it to be. 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 add another extension that conforms to UISearchBarDelegate . So after UISearchResultsUpdating , which you added earlier, add the following:

extension MasterViewController: UISearchBarDelegate {
  func searchBar(_ searchBar: UISearchBar, 
      selectedScopeButtonIndexDidChange selectedScope: Int) {
    let category = Candy.Category(rawValue:
      searchBar.scopeButtonTitles![selectedScope])
    filterContentForSearchText(searchBar.text!, category: category)
  }
}

You call this delegate method when the user switches the scope in the scope bar. When that happens, you want to redo the filtering. Thanks to RawRepresentable conformance, you create a new category instance that retrieves the specified raw value from the selected scope button title. So you call filterContentForSearchText(_:category:) with the new category.

Now, modify filterContentForSearchText(_:category:) to take the supplied category into account:

func filterContentForSearchText(_ searchText: String,
                                category: Candy.Category? = nil) {
  filteredCandies = candies.filter { (candy: Candy) -> Bool in
    let doesCategoryMatch = category == .all || candy.category == category
    
    if isSearchBarEmpty {
      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 that the scope bar passes, or whether the scope is set to .all . You then check to see if there is text in the search bar and filter the candy appropriately. Now, replace isFiltering with the following:

var isFiltering: Bool {
  let searchBarScopeIsFiltering = 
    searchController.searchBar.selectedScopeButtonIndex != 0
  return searchController.isActive && 
    (!isSearchBarEmpty || searchBarScopeIsFiltering)
}

Here, you update isFiltering to return true when the user selects the scope bar.

You’re almost finished, 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 current category:

func updateSearchResults(for searchController: UISearchController) {
  let searchBar = searchController.searchBar
  let category = Candy.Category(rawValue:
    searchBar.scopeButtonTitles![searchBar.selectedScopeButtonIndex])
  filterContentForSearchText(searchBar.text!, category: category)
}

The only problem left is that the user doesn’t actually see a scope bar yet! Within MasterViewController.swift in viewDidLoad() , add the following code just after the search controller setup:

searchController.searchBar.scopeButtonTitles = Candy.Category.allCases
  .map { $0.rawValue }
searchController.searchBar.delegate = self

Since Candy.Category conforms to CaseIterable , the compiler can automatically synthesize allCases for any RawRepresentable enumeration, adding the titles that match the categories you assigned to your candy objects.

Now, when you type, the selected scope button will appear in conjunction with the search text.

Testing the Scope Bar

Build and run. Try entering some search text and changing the scope.

candy-scope.gif

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-type candy. Hurrah!

There’s still one small problem with the app. You haven’t added a results indicator to tell the user how many results they should expect to see. This is particularly important when no results are returned at all, as it’s difficult for the user to distinguish between no results returned and a delay in receiving the answer due to a slow network connection.

Adding a Results Indicator

To fix this, you’re going to add a footer to your view. The user will see this footer when they filter the list of candies, and it will tell them how many candies are in the filtered array.

Start by opening SearchFooter.swift . Here, you have a simple UIView which contains a label as well as a public API that will receive the number of results returned.

Head back to MasterViewController.swift . searchFooter is an IBOutlet for the search footer that the starter project already set up for you. You can find it in the master scene in Main.storyboard , at the bottom of the screen.

Within MasterViewController.swift , add the following to viewDidLoad() , after the spot where you set up the scope bar:

let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(
  forName: UIResponder.keyboardWillChangeFrameNotification,
  object: nil, queue: .main) { (notification) in
    self.handleKeyboard(notification: notification)
}
notificationCenter.addObserver(
  forName: UIResponder.keyboardWillHideNotification,
  object: nil, queue: .main) { (notification) in
    self.handleKeyboard(notification: notification) 
}

These two observers allow you to control the results indicator, which will move up or down based on the visibility of the system keyboard.

Next, add this method to MasterViewController :

func handleKeyboard(notification: Notification) {
  // 1
  guard notification.name == UIResponder.keyboardWillChangeFrameNotification else {
    searchFooterBottomConstraint.constant = 0
    view.layoutIfNeeded()
    return
  }

  guard 
    let info = notification.userInfo,
    let keyboardFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue 
    else {
      return
  }

  // 2
  let keyboardHeight = keyboardFrame.cgRectValue.size.height
  UIView.animate(withDuration: 0.1, animations: { () -> Void in
    self.searchFooterBottomConstraint.constant = keyboardHeight
    self.view.layoutIfNeeded()
  })
}

Here’s what’s happening with the code you just added:

  1. You first check if the notification is has anything to do with hiding the keyboard. If not, you move the search footer down and bail out.
  2. If the notification identifies the ending frame rectangle of the keyboard, you move the search footer just above the keyboard itself.

In both cases, you manage the distance between the search footer and the bottom of the screen through a constraint represented by an IBOutlet called searchFooterBottomConstraint .

Finally, you need to update the number of results in the search footer when the search input changes. So 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 to add in calls to the searchFooter .

Build and run, perform a few searches and watch as the footer updates.

candy-filter-281x500.png

Where to Go From Here?

Congratulations! You now have a working app that allows you to search directly from the main table view.

You can download the final project using the Download Materials button at the top or bottom of this tutorial.

All kinds of apps use table views, and offering a search option is a nice touch that improves usability. With UISearchBar and the UISearchController , iOS provides much of the functionality out of the box, so there’s no excuse not to use it. The ability to search a large table view is something that today’s users expect; without it, they won’t be happy campers.

In the meantime, if you have any questions or comments, please join the forum discussion below!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK