Stuart Breckenridge

Searchable App Content with Core Spotlight

Nothing to see in search...yet!

The Core Spotlight API allows developers to add their app’s content to the on-device index on iOS devices, allowing that content to be searched and accessed directly using the search bar on the home screen. In this post, we’ll add functionality that will both add and delete content to the on-device index, in addition to restoring application state when the user accesses the app via a Core Spotlight search result.

First, let’s discuss what is stored in the on-device index.

Elements in the on-device index are CSSearchableItems. A CSSearchableItem is initialised with a uniqueIdentifier, an optional domainIdentifier, a CSSearchableItemAttributeSet, and, optionally, an expiryDate. The CSSearchableItemAttributeSet contains the properties that are displayed to the user in the search results, for example, the title and the thumbnailData.

In the example code that follows:

  • We will, at the request of the user, add each version of OS X in the OSX.history array to the on-device index.
  • Each version of OS X will correspond to a single CSSearchableItem.
  • The CSSearchableItemAttributeSet of each CSSearchableItem will contain the name of the OS, the OS icon, and the OS description.
  • We will provide a method to remove the CSSearchableItems from the on-device using their domainIdentifier.
  • When the user access the app via a Core Spotlight search, we will highlight the OS version that was selected when the app is restored to the foreground.

Adding to the On-Device Index

To keep things simple, a new UIBarButtonItem has been added to the navigation bar which will call the following method:

@IBAction func presentSpotlightOptions(sender: AnyObject)

To spare you the boiler plate, this method will present a UIAlertController to the user allowing them either Add to Core Spotlight, Remove from Core Spotlight, or dismiss the controller.

To create CSSearchableItems, their respective CSSearchableItemAttributeSets, and add them to the index, we will extend the functionality of the OSX class with a new method:

func addHistoryToCoreSpotlight(result:CoreSpotlightResult)

In this method, we initially create a temporary [CSSearchableItem] array, and then enumerate over the OSX.history array. For each dictionary entry in the OSX.history array, a CSSearchableItemAttributeSet is created and provided with the name of the OS, the description of the OS, and the icon image of the OS, for its respective title, contentDescription, an array of keywords, and thumbnailData properties.

Following the creation of the CSSearchableItemAttributeSet, we initialise a CSSearchableItem and provide it with the entry’s index as the uniqueIdentifier, a static domainIdentifier of com.osxhistory.indexedItems, and the aforementioned CSSearchableItemAttributeSet. Each CSSearchableItem is added to the temporary [CSSearchableItem] array.

The code for this is shown below:

var itemsToAdd = [CSSearchableItem]() 
        
for (index, os) in history.enumerate()
{
	let uniqueID = index 

	let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeText as String) 
	attributeSet.title = os["name"] as? String! 
	attributeSet.contentDescription = os["description"] as? String! 
	attributeSet.thumbnailData = UIImagePNGRepresentation(UIImage(named: (os["image"] as? String!)!)!) 
	attributeSet.keywords = ["OS X", (os["name"] as? String!)!, (os["version"] as? String!)!] 

	let searchableItem = CSSearchableItem(uniqueIdentifier: String(uniqueID), domainIdentifier: "com.osxhistory.indexedItems", attributeSet: attributeSet) 
	searchableItem.expirationDate = NSDate().dateByAddingTimeInterval(600) 

	itemsToAdd.append(searchableItem) 
}

In the example above the CSSearchableItem has been set with an expirationDate of 10 minutes from the point it was created. This means that 10 minutes after the data is indexed, it will be automatically removed.

Once this is complete, we are ready to have the content indexed and to do that, we call the following method:

CSSearchableIndex.defaultSearchableIndex().indexSearchableItems(itemsToAdd) { (error) in
	if error != nil
	{
		NSOperationQueue.mainQueue().addOperationWithBlock({
			completionHandler(error: error!)
		})
	} else{
		NSOperationQueue.mainQueue().addOperationWithBlock({
			completionHandler(error: nil)
		})
	}
}

In the implementation above the CoreSpotlightResult1 completionHandler is called on the main thread after the CSSearchableItems have been journaled by the index. We respond to the CoreSpotlightResult by displaying a success or error message depending whether an NSError is provided by the block.

With all this work complete we can now search for our app’s content using the Spotlight search bar.

Result!

Restoring State

Without any additional code, tapping on an OS X History result will open the app and do nothing. That’s not very interesting! Let’s do something quite contrived.

In the app delegate, add the following code:

func application(application: UIApplication, continueUserActivity userActivity: NSUserActivity, restorationHandler: ([AnyObject]?) -> Void) -> Bool {
        
        if userActivity.activityType == CSSearchableItemActionType {
            let uniqueId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String
            let navigationController = window?.rootViewController as? UINavigationController
            let viewController = navigationController?.topViewController as? ViewController
            viewController?.restoreState(Int(uniqueId!)!)
        }
        
        return true
    }

This method lets the app delegate know that data is available to restore state or continue an activity. What we’re doing above is extracting the uniqueId of the CSSearchableItem (which was its index in the OSX.history array) and then passing it to a new method—restoreState(row:Int)—on the ViewController.

The restoreState(row:Int) method will scroll to indexPath of the selected search result and then, magically, spin the OS X icon.

// Create an `indexPath` and scroll to the `indexPath.row`.
let path = NSIndexPath(forRow: row, inSection: 0)
osXTableView.scrollToRowAtIndexPath(path, atScrollPosition: .Top, animated: false)

// Rotate the image view of the cell at the indexPath
let cell = osXTableView.cellForRowAtIndexPath(path) as! OSXCell
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.toValue = M_PI * 2.0
rotation.duration = 1.0
rotation.repeatCount = 1
rotation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
cell.imageView?.layer.addAnimation(rotation, forKey: "rotationAnimation")

Removing from the On-Device Index

To remove data from the index, we can tap our UIBarButtonItem again, and select Remove from Core Spotlight. This will then call the following method on the OSX class:

func removeHistoryFromCoreSpotlight(completionHandler: CoreSpotlightResult)
    {
        CSSearchableIndex.defaultSearchableIndex().deleteSearchableItemsWithDomainIdentifiers(["com.osxhistory.indexedItems"]) { (error) in
            if error != nil
            {
                NSOperationQueue.mainQueue().addOperationWithBlock({
                    completionHandler(error: error!)
                })
            } else{
                NSOperationQueue.mainQueue().addOperationWithBlock({
                    completionHandler(error: nil)
                })
            }
        }
    }

This removes all CSSearchableItems with the domainIdentifier of com.osxhistory.indexedItems. In short, all our OS X entries will be removed from the index. Like the addHistoryToCoreSpotlight method, the CoreSpotlightResult block is called at the conclusion of the method and will pass an error if there has been a problem deleting the data.2

Wrapping Up

In this post, using the Core Spotlight API, we’ve achieved the following:

  • Adding data to the on-device index
  • Removing data from the on-device index
  • Restoring state by reading the NSUserActivity data passed to the app delegate

Updated app code is available on GitHub.

  1. typealias CoreSpotlightResult = (error:NSError?) -> () ↩︎

  2. If you wish to see errors, try running this code in the iPhone 4s simulator. ↩︎


Supported by