What Is Core Spotlight?

If you have not used Core Spotlight before, it is a framework that allows your app to have data indexed and managed on the device. By doing this, your data becomes searchable on device in the Spotlight screen without launching your app. This creates another channel to drive users into your app.

To walk through using Core Spotlight and the changes that have been made in iOS 10, we will build an app that manages a list of employees that work for a company and the contact information for each of those employees. The app will be called Acme Directory. Many people, though, have the contact information of their close co-workers in their device's contact list. If this is the case, then someone using their device might go into multiple apps, the Contacts app and Acme Directory, to find a co-worker's phone number or email address.

It would be nice to be able to search for that person one time and have results from all apps appear. That is exactly what will be accomplished here. When the app is complete, the user will be able to search for "John" and have all contacts named "John" in either the Contacts app or the Acme Directory app appear.

To this point, everything mentioned has existed before iOS 10. But iOS 10 does bring a couple of great new features. First, if the user is searching in Spotlight and our app displays results, there will also be a link to continue searching in the app.

Core Spotlight Search Continuation

This is another way to drive users into our app without deep linking them into the specific results. When implemented properly, the user who taps this link will have the app launched and the text that the user has already entered will be pre-populated in the app's search feature.

Before iOS 10, having search within an app meant that we would also have to write the search functionality. This is really duplicating effort, though, since iOS already knows how to search the app's data. In addition, if care is not taken, the results given from Spotlight and from our app could be different for the same query. iOS 10 has eliminated all of this with the Core Spotlight Search API. Now, the app's data can be searched from within the app using the data that was indexed and given to Core Spotlight.

Core Spotlight App Basics

First, we need our app to simply store and display employee information. Acme is a small company so there are only a few employees and all of their contact information will be loaded into a CoreData database when the app is first launched. The starter version of this app can be found on GitHub in the master branch. It has all of the basics that the app needs but does not use Core Spotlight at all.

There is also a basicSearch branch that demonstrates the search capabilities that were available prior to iOS 10. For more detail on how to index app data and how to handle the user selecting a search result to launch the app, check out last year's iOS 9 Tutorial Series: Search API.

To follow along with this tutorial, the basicSearch branch is the right starting point.

Core Spotlight in iOS 10

Now that the app is already handling searching in app and search results are being selected from the Spotlight screen, enhancements can be made to support the new iOS 10 features.

Continue Search In-App

First, the acme app will support continuing a user's search in app. This will allow the user to easily navigate away from searching on the Spotlight screen to searching in Acme Directory. To handle this, changes will be made to the App Delegate in:

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: ([AnyObject]?) -> Void) -> Bool

This method is already implemented because Acme Directory is already indexing data and allowing users to launch the app by selecting a search result on the Spotlight screen. When a search result is selected, the passed in activityType is CSSearchableItemActionType. Now, the activity type needs to be checked for CSQueryContinuationActionType as that will be the activityType given for continuing a search.

Before handling CSQueryContinuationActionType, though, some refactoring needs to be done. Regardless of the activityType given, a reference to the MasterViewController is needed. The following three lines should be moved to the top of the function, outside of the if-statement.

let splitViewController = self.window!.rootViewController as! UISplitViewController
let masterNavigationController = splitViewController.viewControllers[0] as! UINavigationController
let controller = masterNavigationController.viewControllers[0] as! MasterViewController

Next, an else-if-statement is needed in conjunction with the existing if-statement to check for the new activityType.

else if (userActivity.activityType == CSQueryContinuationActionType) {

Now in iOS 10, we can check for the CSSearchQueryString key inside of the userInfo dictionary that is passed in along with the userActivity. This will give us the String that was used to start the search.

if let searchQuery = userActivity.userInfo?[CSSearchQueryString] as? String {
 // pass the query string to the master view controller which is where we do our searching
 controller.continueSearch(withString: searchQuery)
}
return true

In the previous code block, there is a line that is giving an error. Currently, there is no continueSearch(withString:) function in MasterViewController. Adding that method is the final step.

Acme Directory should now present the app's search UI with the supplied text already filled in and showing results. To do this, navigate to the MasterViewController class, since that is where the searching is done. Navigate to the MasterViewController class and add the following code:

func continueSearch(withString searchString: String) {
 // Make sure we come back to this screen if we need to continue a search
 _ = navigationController?.popToRootViewController(animated: true)
 // Turn our searching UI on
 searchController.isActive = true
 // Set the search text in the search bar to the text that was given to us
 searchController.searchBar.text = searchString
}

There are three things being done here. First, if the app is showing the details of a particular employee when the user continues the search, the app needs to go back to the first screen. Second, the search controller is being turned on so that the user is looking at a screen that is already in "search mode". Third, the text supplied from the user's Spotlight query is placed into the app's search bar. This will trigger the search.

Query Continuation in iOS 10

Build and run the app. Now the search that is started in Spotlight can be continued in the app. If the user searches for "Ste", the results in both places will include "Steve Beyers" and "Mike Stevens".

Core Spotlight Search By Name
In App Search By Name

But what happens if the user is trying to find Software Engineers? Return to Spotlight and search "Sof". With the content that was indexed through the Core Spotlight APIs, the Spotlight screen has no problem finding the data we are looking for. Acme Directory, however, is only comparing the search string with the employee's first name and last name.

Core Spotlight Search By Position
In App Search By Position

This is definitely not ideal. To fix this issue, the app should use the next iOS 10 feature, In-App Search.

In-App Search

Prior to iOS 10, the app would need a much more robust search algorithm to find the desired employees when a search is started. The app would need to know every field that Spotlight can search on and would need to test the same data. Since this app is being written specifically for iOS 10, though, it is time to take advantage of the new enhancements to the Core Spotlight Search API. To make this enhancement, code changes will be made exclusively in MasterViewController.

First, the class needs a new property, var query: CSSearchQuery?. Second, the searchResults property needs to be modified to hold an array of optional Employee objects. It should look like this: var searchResults: [Employee?]?. The reason that the array now needs to hold optional Employee objects is that when the results are returned from the Core Spotlight search, they are not Employee objects, so we will need to convert them to Employee objects. If that fails, we will have nil in the array. This should never happen with the way our app's data is structured, but it is technically possible so the app should handle it to be safe.

Now, the filterContent(search:) method must be updated. And, the first step is to remove all the code since the new approach is completely different. Then, start implementing the new search using CSQuery. There are six steps to implementing the new search.

Step 1:

Cancel previous query. If a search query is already running, it should be cancelled so that a query that is no longer needed does not consume resources. This step is simple.

query?.cancel()
Step 2:

Prepare result set. This function should keep track of it's own results and only return the results back to the class's property after the query is completely finished. This will avoid race conditions that could otherwise easily sneak into the app.

var results = [Employee?]()
Step 3:

Prepare the query. As with database queries, these search queries need to be escaped so that if a user wants to include a "\" in the search string, that can be done without causing problems. There are also a few options that can be added to the search query to determine how extensive the search is. The queryString variable seen below starts with "**" which indicates that the search should be performed on all fields that were indexed. The other important part about the query string is the ending where "cwdt" is added to the query string. Each of these four characters has an important meaning, as you can see below or in Apple's documentation.

  • c: Case Insensitive
  • w: Insensitive to diacritical marks.
  • d: Word-based. In addition, the comparison detects transitions from lower-case to upper-case.
  • t: Performed on the tokenized value. For example, values passed directly from a search field are tokenized.

After preparing the query string, a new CSSearchQuery is made.

let escapedString = text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
let queryString = "**=\"" + escapedString + "*\"cwdt"
query = CSSearchQuery(queryString: queryString, attributes: [])
Step 4:

Handle query results. As the query runs, it will periodically report progress back to the CSSearchQuery's foundItemsHandler. The foundItemsHandler is a closure that receives an Array of CSSearchableItem objects. The app's responsibility is to convert the CSSearchableItems into something that can be displayed on screen. For Acme Directory, that would be an Employee object. Once converted into Employee objects, they should also be stored in the results variable that was set up earlier.

query?.foundItemsHandler = { [weak self](items : [CSSearchableItem]) -> Void in
 // Map each CSSearchableItem result to an Employee object
 let mapResults = items.map({ [weak self](item: CSSearchableItem) -> Employee? in
 guard let `self` = self else { return nil }
 let employee = self.fetchedResultsController.fetchedObjects?.filter( { $0.username == item.uniqueIdentifier } ).first
 return employee
 })
 // add the results found here to the compiled list of results
 results.append(contentsOf: mapResults)
}

Notice that on the line that says return employee, that variable, employee, is an optional. This is why the searchResults property was changed to have an optional Employee.

Step 5:

Handle query completion. When the query is finished running, the CSSearchQuery's completionHandler will be called. The completionHandler will be given an NSError if there was a problem with the search. In the completion handler, the search results need to be moved from a local variable to a property that is stored by the class and the UI needs to be refreshed. For Acme Directory, that means reloading the table view data. This app should also always display employees that are returned in the results in alphabetical order by last name then first. So, first the sorting will be done. Then code execution will be moved onto the main thread where the results will be stored and UI will be updated.

The sorting we are doing may make the app display search results in a different order than the user would see on the Spotlight screen. This is intentional, though. Spotlight uses intelligent algorithms so that the contacts you interact with most often on the Spotlight search screen are the ones that appear first. Acme Directory does not have any information from the user's usage of the Spotlight screen so the most intelligent ordering that we can choose is alphabetical ordering.

query?.completionHandler = { [weak self](err) -> Void in
 // Sort results so that employees always appear alphabetically
 results.sort(isOrderedBefore: { (employee1: Employee?, employee2: Employee?) -> Bool in
 guard let lastName1 = employee1?.lastName, lastName2 = employee2?.lastName else {
 return true
 }
 if (lastName1 == lastName2) {
 guard let firstName1 = employee1?.firstName, firstName2 = employee2?.firstName else {
 return true
 }
 return (firstName1 <= firstName2)
 } else {
 return lastName1 < lastName2
 }
 })
 DispatchQueue.main.async(execute: {
 // store the search results
 self?.searchResults = results
 // reload the table to show the new results
 self?.tableView.reloadData()
 })
}
Step 6:

Execute the query. The query does not begin running automatically. Instead, the code must call start() when the query has been fully configured.

query?.start()

Conclusion

Finally, the code is ready to be tested. Build and run the app then, once again, search "Sof" on the Spotlight screen. Then tap the button to continue the search in app. The results are finally what we want!

In App Search By Position With Good Results

Core Spotlight enhancements will greatly improve the way searching is done from iOS 10 and into the future. The responsibility of writing custom search algorithms has been reduced and developers have been given yet another way to drive customer interest into their apps. Implementing the new Core Spotlight features can also be done pretty quickly, especially if previous Core Spotlight APIs have already been used.

I would encourage you to take the time to think about how this can be used in your app. For example, searching product inventory of a retail application would be a great use of search continuation. And a transit application could take advantage of indexing public transit routes with Core Spotlight to give instant search results in app while making a network call to find more options.

About the Author

Steven Beyers is a Manager in our Philadelphia, PA office. He has been a software engineer for almost 10 years and has been focused on mobile platforms, especially iOS, for the last 6 years. Steven began mobile development on iOS 3.