Indexing with CSSearchableItem

Currently, our indexing works and we can find any content we saw earlier. We can even select results from the Spotlight index and have our app open on the correct page. If you've taken on the challenge to implement the handling of detail pages, your app should be able to handle continuation of any activity we've indexed. Wouldn't it be cool if we could be a bit more proactive about indexing though? Ideally, we would index any new family members or movies right as the user adds them.

This is exactly what CSSearchableItem is good at. The CSSearchableItem class enables you to index content the user might not have seen before. Indexing CSSearchableItem instances is pretty straightforward. The steps involved are similar to how we indexed user activities. To index a searchable item, we will create an instance of CSSearchableItem and provide it with collection attributes that describe the item we will index. These attributes are encapsulated in an instance of CSSearchableItemAttributeSet.

Containing information in CSSearchableItemAttributeSet

It is extremely important that the attributes set is populated correctly because this object describes almost all of the important information for Spotlight. You can associate a title, content description, a thumbnail image, keywords, even ratings, or phone numbers, GPS information, and much, much more. For a full overview of what's possible, please refer to the CSSearchableItemAttributeSet documentation. Every time you are about to create a new item that can be indexed, you should take a look at the documentation to make sure you don't miss any attributes.

The better use you make of the available attributes, the better your content can be indexed and the higher your app will rank. Therefore, it's worth putting a little bit of time and attention into your search attributes because getting it wrong could be a costly mistake, especially considering the available documentation. At a minimum, you should always try to set the title, contentDescription, thumbnailData, rating, and keywords. This isn't always relevant or even possible for the items you're indexing but whenever possible make sure that you set these attributes.

You may have noticed that the NSUserActivity instances we indexed in our app didn't receive any special attributes. We just set a name and some other basic information, but we never added a description or a rating to any of the indexed objects. If you're indexing user activities in your own applications, it's worth noting that user activities can and should have attributes associated with them. All you need to do is set the contentAttributeSet property on the user activity. After we implement indexing through CSSearchableItem, we'll shortly revisit the user activity indexing to make the indexed item richer and also to make sure that CoreSpotlight understands that the user activities and searchable items point to the same underlying index in Spotlight.

Whenever we index items through multiple methods, it's inevitable that we'll run into data duplication. The application we're working on right now indexes every visited screen. So, if a user visits the details page of a movie, we will index a user activity that points to that specific movie. However, we also want to index movies as they are created by our user. To avoid duplicate results in Spotlight search, we will need to add a relatedUniqueIdentifier to the attributes set. Setting this attribute on a user activity we are going to index will ensure that it doesn't add duplicate entries if we were to also add a searchable item with the same identifier to Spotlight.

Let's expand the IndexingFactory with two methods that enable us to generate attribute sets for our searchable items. Putting this functionality in the IndexingFactory as a separate method is a good idea because if we set it up correctly, we can use these methods to generate attributes for both user activities and searchable items. This avoids code duplication and makes it a lot easier to add or remove properties in the future. Add the following methods to the IndexingFactory struct:

static func searchableAttributes(forMovie movie: Movie) -> 
  CSSearchableItemAttributeSet { 
    do { 
        try movie.managedObjectContext?.obtainPermanentIDs(for: [movie]) 
    } catch { 
        print("could not obtain permanent movie id") 
    } 
     
    let attributes = CSSearchableItemAttributeSet(itemContentType: 
      ActivityType.movieDetailView.rawValue) 
    attributes.title = movie.name 
    attributes.contentDescription = "A movie that is favorited by 
      (movie.familyMembers?.count ?? 0) family members" 
    attributes.rating = NSNumber(value: movie.popularity) 
    attributes.identifier = "(movie.objectID.uriRepresentation().absoluteString)" 
    attributes.relatedUniqueIdentifier = "
      (movie.objectID.uriRepresentation().absoluteString)" 
     
    return attributes 
} 
 
static func searchableAttributes(forFamilyMember familyMember: FamilyMember) -> 
  CSSearchableItemAttributeSet { 
    do { 
        try familyMember.managedObjectContext?.obtainPermanentIDs(for: 
          [familyMember]) 
    } catch { 
        print("could not obtain permanent family member id") 
    } 
     
    let attributes = CSSearchableItemAttributeSet(itemContentType: 
      ActivityType.familyMemberDetailView.rawValue) 
    attributes.title = familyMember.name 
    attributes.identifier = "
      (familyMember.objectID.uriRepresentation().absoluteString)" 
    attributes.contentDescription = "Family Member with 
      (familyMember.favoriteMovies?.count ?? 0) listed movies" 
    attributes.relatedUniqueIdentifier = "
      (familyMember.objectID.uriRepresentation().absoluteString)" 
     
    return attributes 
} 

For both objects, we will create a set of attributes that meet Apple's recommendations as closely as possible. We don't have any thumbnail images or keywords that we can add besides the movie name or the name of a family member. Adding these to the keywords is kind of pointless because the title in itself is essentially a keyword that our item will match on.

Note that we use the objectID property as a means of uniquely identifying our objects. However, if we call this factory method before the objects are assigned a permanent ID, we could be in trouble. Objects are assigned a permanent ID when they are saved or if we explicitly tell the managed object context to obtain permanent IDs. We need to do this to ensure that the objectID does not change at a later time. The objectID property is available on all managed objects and is the most reliable and convenient way for us to make sure that we have a unique identifier available.

To create an attribute set, all we have to do now is call the method that matches the object we want to index and we're good to go. Nice, convenient, and simple.

Adding CSSearchableItem instances to the search index

In the FamilyMovies application, we want to add family members and movies to the search index as soon as we add them. We already have a factory method in place that creates the CSSearchableItemAttributeSet instance that describes the item we want to index. However, we can't directly add these to the index. To add information to the search index manually as we want to, we need instances of CSSearchableItem. To create such an instance, we need the following two further pieces of information: a unique identifier and a domain identifier.

The unique identifier is used to uniquely identify your indexed item. It's important that you set this value to something that is actually unique because otherwise Spotlight will overwrite the entry with something else that has the same identifier or you'll get duplicate entries if you combine user activities and search items like we're doing for FamilyMovies.

The domain identifier functions as a namespace. Within any given namespace, all entries must be unique and are identified through their own unique identifier. This identifier must only be unique within their own namespace. Think of this is a street and address. In a certain area, every street name is unique (domain, namespace). Within each street the house number is unique (unique identifier) but the same number can occur in different streets. The domain identifier for your Spotlight entry is not only used to uniquely identify entries, it's also used to perform certain batch actions on the index, such as deleting all indexed items from a certain domain.

The domain identifier, unique identifier, and the attributes together make up a searchable item. The following code adds factory methods to IndexingFactory that will make it simple for our app to add items to the search index:

enum DomainIdentifier: String { 
    case familyMember = "FamilyMember" 
    case movie = "Movie" 
} 
 
static func searchableItem(forMovie movie: Movie) -> CSSearchableItem { 
    let attributes = searchableAttributes(forMovie: movie) 
     
    return searachbleItem(withIdentifier: "
      (movie.objectID.uriRepresentation().absoluteString)", domain: 
      .movie, attributes: attributes) 
} 
 
static func searchableItem(forFamilyMember familyMember: FamilyMember) -> 
  CSSearchableItem { 
    let attributes = searchableAttributes(forFamilyMember: familyMember) 
     
    return searachbleItem(withIdentifier: "(familyMember.objectID)", domain: 
      .familyMember, attributes: attributes) 
} 
private static func searachbleItem(withIdentifier identifier: String, domain: 
  DomainIdentifier, attributes: CSSearchableItemAttributeSet) -> 
  CSSearchableItem { 
    let item = CSSearchableItem(uniqueIdentifier: identifier, 
                                domainIdentifier: domain.rawValue, 
                                attributeSet: attributes) 
     
    return item 
} 

We created an enum that contains the domains that we want to add items for. Note that the searchableItem(withIdentifier:domain:attributes:) is marked as private. We've done this so we're forced to use searchableItem(forFamilyMember:) and searchableItem(forMovie:) instead. These methods are simpler to use because they only take a family member or a movie and if we use only these methods, we can rest assured that we're inserting consistently setup-searchable items to our index.

For starters, we will index family members right after we create them. Update FamilyMembersViewController's implementation of controller(_:didChange:at:for:newIndexPath:) as follows; the updated lines are highlighted. Make sure that you import CoreSpotlight at the top of your file:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, 
                didChange anObject: Any, 
                at indexPath: IndexPath?, 
                for type: NSFetchedResultsChangeType, 
                newIndexPath: IndexPath?) { 
     
    switch type { 
    case .insert: 
        guard let insertIndex = newIndexPath, 
                let familyMember = fetchedResultsController?.object(at: 
                  insertIndex) 
                else { return } 
 
            let item = IndexingFactory.searchableItem(forFamilyMember: 
              familyMember) 
            CSSearchableIndex.default().indexSearchableItems([item], 
              completionHandler: nil) 
         
        tableView.insertRows(at: [insertIndex], 
                             with: .automatic) 
        // existing implementation 
    } 
} 

Because of our factory methods, we can insert a new item in the search index with just a few lines of code. To insert an item into the search index, we obtain an instance of CSSearchableIndex and tell it to index our searchable items. If we like, we can pass a completion handler. This handler is called with an optional error. If the indexing failed, the error could tell us a bit about why Spotlight couldn't index the item and we could retry or take a different action. In our app, we'll assume that the indexing succeeded and we don't want to handle potential errors.

Update the mangedObjectContextDidChange(notification:) method in MoviesViewController as follows; updated parts of the code are highlighted:

func mangedObjectContextDidChange(notification: NSNotification) { 
    guard let userInfo = notification.userInfo 
        else { return } 
     
    if let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<FamilyMember>, 
        let familyMember = self.familyMember , 
          updatedObjects.contains(familyMember) { 
 
        let item = IndexingFactory.searchableItem(forFamilyMember: familyMember) 
        CSSearchableIndex.default().indexSearchableItems([item], 
          completionHandler: nil) 
         
        tableView.reloadData() 
    } 
 
    if let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<Movie> { 
        for object in updatedObjects { 
            let item = IndexingFactory.searchableItem(forMovie: object) 
            CSSearchableIndex.default().indexSearchableItems([item], 
              completionHandler: nil) 
             
            if let familyMember = self.familyMember, 
                let familyMembers = object.familyMembers 
                , familyMembers.contains(familyMember) { 
 
                tableView.reloadData() 
                break 
            } 
        } 
    } 
} 

Right after we add the movie to the current family member, we add it to the Spotlight index. You might argue that we're adding the item too early because we haven't fetched a rating for the movie at that point. This is okay because when the context saves the second with the ratings attached, we automatically add the item to the index again due to the save notification we're observing.

If you run the app now, you should be able to go into Spotlight right after you add a family member or movie, and you should immediately be able to find the freshly added content in Spotlight. If you search for a movie you just added, you'll notice that you can see how many family members have added a certain movie to their favorites list and the rating a movie has. More importantly, there should be only a single entry for each movie because we're using a proper unique identifier.

One final update we need to add to our saveMovie(withName:) method is that it should also take care of updating the family member's entry so we get a correct reading of the number of added favorite movies in Spotlight.

Update your code as follows:

familyMember.favoriteMovies = familyMember.favoriteMovies?.adding(movie) as NSSet? 
 
let item = IndexingFactory.searchableItem(forMovie: movie) 
CSSearchableIndex.default().indexSearchableItems([item], completionHandler: nil) 
 
let familyMemberItem = IndexingFactory.searchableItem(forMovie: movie) 
CSSearchableIndex.default().indexSearchableItems([familyMemberItem], 
  completionHandler: nil) 

The amendment to the code should speak for itself; after we create the movie, we re-index the family member item. One final adjustment we will need to make is to make sure that CoreSpotlight does not mix up our activities and searchable items. We'll update our factory methods for user activities and set them up similar to how we set up the factory methods for searchable items.

Safely combining indexing methods

As we're not associating any unique information with our user activities, Spotlight can't figure out that a family member that's indexed through a user activity is actually the same item we already inserted as a searchable item. To ensure that Spotlight understands this, we'll add two more factory methods that will create an activity item for either a family member or a movie with the correct information associated with them. Add the following methods to the IndexingFactory:

static func activity(forMovie movie: Movie) -> NSUserActivity { 
    let activityItem = activity(withType: .movieDetailView, name: movie.name!, 
      makePublic: false) 
    let attributes = searchableAttributes(forMovie: movie) 
    attributes.domainIdentifier = DomainIdentifier.movie.rawValue 
    activityItem.contentAttributeSet = attributes 
     
    return activityItem 
} 
 
static func activity(forFamilyMember familyMember: FamilyMember) -> NSUserActivity { 
    let activityItem = activity(withType: .movieDetailView, name: 
      familyMember.name!, makePublic: false) 
    let attributes = searchableAttributes(forFamilyMember: familyMember) 
    attributes.domainIdentifier = DomainIdentifier.familyMember.rawValue 
    activityItem.contentAttributeSet = attributes 
     
    return activityItem 
} 

The most important lines to take note of are the ones where a domainIdentifier is set on the attributes constant. Since iOS 10, developers can associate a domainIdentifier with user activities through the contentAttributeSet. By adding a domainIdentifier to the item we're indexing, we've further unified searchable items and user activities. Update the viewDidAppear implementation for MovieDetailViewController as follows:

override func viewDidAppear(_ animated: Bool) { 
  super.viewDidAppear(animated) 
         
  guard let movie = self.movie 
    else { return } 
         
  self.userActivity = IndexingFactory.activity(forMovie: movie) 
         
  self.userActivity?.becomeCurrent() 
} 

We also need to update the viewDidAppear method in MoviesViewController. You should be able to do this on your own; the code will look similar to the preceding snippet, except you're indexing a family member instead of a movie.

Now that all of your app contents are indexed in Spotlight, it's time to discuss some of the methods Spotlight uses to rate your content and the best practices you should keep in mind when you add your app's contents to Spotlight.

Handling searchable item selection

If a user taps on a search result for one of the items you indexed manually, the application(_:continue:restorationHandler:) method is called. This is the same method that's used for user activities, but the internal handling is not quite the same.

For user activity items, we relied on the user activity's title. We can't do the same for our searchable items. Luckily, the user activity instance we get of a user selects one of our searchable items as a special activity type: CSSearchableItemActivityIdentifier. This identifier tells us that we're dealing with an item that's different from our regular user activities, and we can adjust our course of action based on this information. Update your code in AppDelegate as follows:

func application(_ application: UIApplication, continue userActivity: 
  NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool { 
    if let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] 
      as? String, userActivity.activityType == CSSearchableItemActionType { 
        return handleCoreSpotlightActivity(withIdentifier: identifier) 
    } 
     
    // existing implementation.. 
} 
 
func handleCoreSpotlightActivity(withIdentifier identifier: String) -> Bool { 
    guard let url = URL(string: identifier), 
        let objectID =  
          persistentContainer.persistentStoreCoordinator.managedObjectID(
          forURIRepresentation: url), 
        let object = try? persistentContainer.viewContext.existingObject(with: 
          objectID) 
        else { return false } 
     
    if let movie = object as? Movie { 
        return handleOpenMovieDetail(withName: movie.name!) 
    } 
     
    if let familyMember = object as? FamilyMember { 
        return handleOpenFamilyMemberDetail(withName: familyMember.name!) 
    } 
     
    return false 
} 

Once we figure out that we're dealing with a searchable item, we call a special method. This method uses the persistent store to convert the string identifier to a managed object ID and then we use the managed object context to obtain an object. If all of this succeeds, we attempt to cast the fetched object to either a movie or a family member and if this succeeds, we call one of the existing handlers. If none of the casts succeed, we have failed to continue the activity so we return false.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.117.196.217