As part of a feature set called Continuity, Apple launched Handoff in iOS 8. Handoff allows users to start an activity on one device and then continue it on another. In iOS 9, Apple introduced the ability to index these user activities in Spotlight.
There are several advantages to this because apps that support Handoff hardly need to do anything to support Spotlight indexing and vice versa. So, even though this chapter is all about search and Spotlight, you will also learn something about enabling Handoff for your app.
The philosophy behind user activities is that whenever your user interacts with your app, you create an instance of NSUserActivity. For Spotlight, these activities revolve solely around viewing content. Any time your user looks at a piece of content in your app is an excellent time to create a user activity and have Spotlight index it. After the user activity is added to the index, the user will be able to find it through Spotlight; when they tap on it, your app can take the user straight to the relevant section of the app, allowing the user to resume their activity.
In the previous chapters, you worked on an app called MustC. The MustC app collects data about people and their favorite movies. The app also keeps track of a rating for each of the movies. This content is great to index in Spotlight, so let's add some indexing to it.
A few additions were made since the last time you worked on this app. The app now contains a tab bar. There is a tab for the list of family members, and there is one that lists all of the movies that are added to the app. Selecting a movie will display a list of family members that have added this movie to their favorites.
An app such as MustC is a great candidate for indexing. You can add the separate family members, the tabs from the navigation bar, and the movies to Spotlight to make them searchable from anywhere within iOS. You will start off with the most straightforward content to track. You will add the family members and movie tabs to Spotlight when a user visits them.
To do this, you will use user activities, so you should create an activity whenever a user opens one of the two tabs. There are two places where you could implement the user activities:
- viewDidAppear()
- viewDidLoad()
If you create the user activity in viewDidAppear, the user activity is pushed every time the user switches tabs or navigates back to the root view controller from another view controller. Even though it doesn't cost much to index a user activity, it seems like it's overkill to index a tab every single time a user sees it.
Even though this train of thought isn't wrong, the best way to go about indexing tabs in Spotlight is actually to create the activity in the viewDidAppear() method. If you put this logic in viewDidLoad(), it will only be executed once even though the idea of user activities is that they describe each action a user performs. In this case, inserting the same activity over and over again is the desired behavior because it accurately reflects what the user is doing inside the app.
When your app implements user activities, you should always specify them by adding an NSUserActivityTypes array to your app's Info.plist that contains the user activities your app will handle. Add this key to Info.plist for MustC and add a single item name, com.familymovies.openTab, to the activity types array.
Let's take a look at some code that indexes the family members tab. You should add it to FamilyMembersViewController:
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let userActivity = NSUserActivity(activityType: "com.familymovies.openTab") userActivity.title = "Family Members" userActivity.isEligibleForSearch = true userActivity.isEligibleForPublicIndexing = true self.userActivity = userActivity self.userActivity?.becomeCurrent() }
The preceding code shows how to create a straightforward user activity. The activity you just created only has a title because there isn't much else to associate with it. The most important thing to note is the isEligibleForSearch property. This property tells the system that the user activity that is about to be set as the current activity should be indexed for searching. Other similar properties are isEligibleForHandoff and isEligibleForPublicIndexing. You used the isEligibleForPrediction property in Chapter 16, Streamlining Experiences with Siri.
It's a great idea for the activity you just created to be eligible for public indexing, so that property is set to true. Doing this will make the activity show up in the search results of a lot more people, given that enough people interact with it. Marking an activity as eligible for handoff allows a user to continue an activity they started on one device on another device. Since this app only works on iOS and you don't need to take multiple users with multiple devices into account, you don't have to set this property to true.
Finally, the user activity you just created is set as the current activity. This makes sure that iOS registers the activity and adds it to the Spotlight index. It won't be made available publicly right away due to the threshold of people that interact with this activity that must be reached before Apple will push it to all users. Even though it won't be indexed publicly, it will appear in search results locally for the current user.
If you build and run your application, the family members tab should be the first tab to appear. This means that a user activity for that view is created and indexed immediately. After opening the app, go ahead and open Spotlight by swiping down on the home screen and perform a search for the word family.
You'll notice that the activity you just added is listed under the MustC header in the Spotlight search results, as shown in the following screenshot:
Pretty neat, right? You were able to add a simple entry in Spotlight's search index with very minimal effort. You should be able to add a similar implementation to the MustC app to have it index the Movies page. Go ahead and add a modified version of the snippet you added to FamilyMembersViewController to MoviesListViewController.
Now that both tabs show up in Spotlight, how do you make sure that the correct tab is opened when the user selects a result from Spotlight? The answer lies in one of the AppDelegate methods. When your app is brought to the foreground because a user selected your app as a Spotlight search result, the application(_:continueUserActivity:restorationHandler:) method is called. This is the same method that gets called when a user executes one of their Siri Shortcuts, so if you need a little refresher on how this method works, make sure to go back to Chapter 16, Streamlining Experiences with Siri.
Currently, there are only two Spotlight entries that the user can select. They either want to see the family members tab or the movies tab. The implementation of application(_:continueUserActivity:restorationHandler:) should inspect the user activity that it received to determine which tab should be displayed. Once this is established, a reference to the app's UITabBarController should be obtained and the correct tab should become active.
Since each tab contains a navigation controller, you should always make sure to pop the relevant navigation controller to its root view controller. This is needed because the user might have been looking at a detail view controller, and the app would show this detail view controller instead of the root view controller if you don't ensure that the navigation controller pops to its root view controller. Add the following code to AppDelegate to add this functionality:
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { // 1 guard let tabBar = window?.rootViewController as? UITabBarController else { return false } // 2 let tabIndex: Int? if userActivity.title == "Family Members" { tabIndex = 0 } else if userActivity.title == "Movies" { tabIndex = 1 } else { tabIndex = nil } guard let index = tabIndex else { return false } // 3 guard let navVC = tabBar.viewControllers?[index] as? UINavigationController else { return false } // 4 navVC.popToRootViewController(animated: false) tabBar.selectedIndex = index return true }
The preceding code first obtains a reference to the tab bar controller. After doing this, the second step is to determine the correct tab index for the selected user activity. Then, the navigation view controller associated with the tab bar is grabbed. The fourth and final step is to pop the navigation controller to its root view controller and to make the correct tab bar index the currently-selected tab bar item.
At this point, two of the screens in MustC are indexed in Spotlight. While this is great, there are a couple more screens that can be implemented. Doing this will make the implementation for application(_:continueUserActivity:restorationHandler:) more complicated than it is now.
Manually creating and resuming user activities for each screen in an app is tedious and involves quite a lot of boilerplate code. You can solve this problem by utilizing an activity factory. A typical pattern in apps is to use a specific helper object called a Factory. The sole purpose of a factory is to act as an object that is responsible for creating objects of a particular type. This dramatically reduces boilerplate code and increases maintainability. Create a new file called IndexingFactory.swift in the Helpers folder and add the following implementation:
import Foundation struct IndexingFactory { enum ActivityType: String { case openTab = "com.familymovies.openTab" case familyMemberDetailView = "com.familymovies.familyMemberDetailView" case movieDetailView = "com.familymovies.movieDetailView" } static func activity(withType type: ActivityType, name: String, makePublic: Bool) -> NSUserActivity { let userActivity = NSUserActivity(activityType: type.rawValue) userActivity.title = name userActivity.isEligibleForSearch = true userActivity.isEligibleForPublicIndexing = makePublic return userActivity } }
Note that this object contains an enum with three different activity types. Don't forget to add the two new activity types to the NSUserActivityTypes list in the app's Info.plist. IndexingFactory has a single static method in it. This method takes a couple of configuration arguments and uses these to create and return a new user activity instance. Let's see this factory in action. You can add the following code to the MoviesViewController class:
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) guard let familyMemberName = familyMember?.name else { return } self.userActivity = IndexingFactory.activity(withType: .familyMemberDetailView, name: familyMemberName, makePublic: false) self.userActivity?.becomeCurrent() }
The preceding snippet is a lot smaller than creating a user activity from scratch in every viewDidAppear. Also, if you decide to make changes to the way user activities are created and configured, it will be easier to refactor your code because you will only have to change a single method. Add a comparable implementation of the preceding method to the MovieDetailViewController class so movie pages will also be indexed by Spotlight when the user visits them.
This wraps up simple indexing with NSUserActivity. Next up, you will learn how to index content with the CSSearchableItem class and how you can use it to index content the user hasn't seen yet. You'll also see how you can associate more sophisticated data with your searchable items and how Spotlight handles updating and re-indexing contents.
Since AppDelegate can't handle opening user activity types that should take the user straight to a family member or movie, it's a good exercise for you to try implementing this logic yourself. For a correct implementation, you'll need to add a new find method to the Movie and FamilyMember classes, and you'll have to instantiate view controllers straight from a storyboard. If you get stuck implementing this, don't hesitate to look at the finished code for this chapter in the code bundle.