Streaming is available in most browsers,
and in the Developer app.
-
Showcase app data in Spotlight
Discover how Core Data can surface data from your app in Spotlight with as little as two lines of code. Learn how to make that data discoverable in Spotlight search and to customize how it is presented to people on device. Lastly, we'll show you how to implement full-text search within your app, driven completely with the data indexed by Spotlight.
Resources
Related Videos
WWDC21
-
Download
♪ Bass music playing ♪ ♪ David Stites: Hi, and welcome to "Showcase app data in Spotlight".
My name is David Stites, and I am an engineer on the Core Data team.
In this session, I am excited to show you how to add Spotlight indexing in your app using NSCoreDataCoreSpotlightDelegate.
The agenda for this session is to learn about the NSCoreDataCoreSpotlightDelegate object and why you should use it, setup a simple implementation, learn how to customize that implementation, and lastly, validate the code by adding full-text search.
First, let's look at Core Data and Spotlight.
People are going to create and store a lot of great and important content in your app.
As their use of your app and the size of their data set increases, they're going to want to be able to quickly find that data both inside the app using standard search methods and outside the app, for example, in Spotlight search.
Wouldn't it be great to have data inside your app show up in Spotlight? Well, this is where Core Data can help you.
The NSCoreDataCore SpotlightDelegate object does all the heavy lifting and provides a set of APIs that quickly and efficiently indexes content provided by your app.
You just have to turn it on! Once indexed, search results will also appear in the Spotlight search user interface outside your app.
The Spotlight delegate automatically processes changes to your graph's managed objects and then updates the Spotlight index accordingly.
In addition, it provides robust index management capabilities to interact with the private, on-device-only index and allows you to tailor the index results to your liking.
In fact, any content that is in your persistent store is eligible to be indexed.
The reasons to use the Spotlight delegate are threefold: (1) the Spotlight delegate maintains feature parity with Core Spotlight APIs, (2) it removes a lot of necessary implementation code, and (3) it provides a great additional feature set that we'll be discussing later in this session.
To illustrate my previous point, this is a very simple implementation using the Core Spotlight APIs that only adds items to a search index and reduces it to... this! Two lines! Simple, easy to read and maintain.
I mean, come on, who doesn't prefer less code? Let's take a look at how to get setup and running right away.
This simple example will cover deciding what to index and creating the delegate.
Throughout this session, I will be referring to an app called Tags that I wrote for myself, which is a simple photo tagging application.
This sample app will incorporate many of the APIs I am discussing today.
Prior to adding Spotlight support, you can see that all the tag and photo data is trapped inside Tags as there are no Spotlight search query results for "Natural Bridges State Park".
Let's change that! The first step in any implementation using the NSCoreDataCoreSpotlightDelegate is to decide what you're going to index in Spotlight.
What gets indexed in Spotlight is completely up to you.
In Tags, I've decided to index the userSpecifiedName attribute on the entity Photo and the name attribute on the entity Tag.
To prepare the model for indexing, I've opened the project's Core Data model in Xcode, selected each attribute I want to index, and have ticked the Index in Spotlight checkbox in the attributes inspector.
Our work continues in the Core Data model editor, as it's required to set the Core Data Spotlight display name.
The Core Data Spotlight display name is an NSExpression.
At indexing time, this expression is evaluated with each managed object that has properties indexed by Spotlight and the result is saved.
Later, when the Spotlight search user interface is shown, these stored results are used as the “display name” for the search result.
What is an NSExpression? Well, an expression can be as simple as evaluating a key path, in this case Tag.name.
This object has quite a few more tricks it can do besides evaluating key paths, however.
In this example, it is doing some math for you.
The expression can be even more complex, such as calculating the standard deviation of a set of numbers.
In Tags, the Spotlight display name is set to userSpecifiedName on the entity Photo, and name on the entity Tag.
Now that the model is prepared for indexing, let's create the Spotlight delegate.
Beginning in iOS 15 and macOS Monterey, the initializer forStoreWith: model: is now deprecated.
The new way initialize a Spotlight delegate is using forStoreWith: coordinator:.
By adopting the new designated initializer, it is no longer required to add an instance of the Spotlight delegate to the store options prior to adding the store to the coordinator.
However, you must call startSpotlightIndexing for the Spotlight delegate to start its work.
I want to call out a couple requirements to using the NSCoreDataCoreSpotlightDelegate.
The store type of the store to be indexed must be SQLite and must have persistent history tracking enabled.
And with that, you're done! That's it! You don't need to do anything else and your data will be indexed in Spotlight.
I just demonstrated how easy it is to add Spotlight indexing to my Tags app.
Now that I've described the basics, let's customize that implementation a bit.
The first way to customize the implementation is by defining a domain and index name.
To start off, I'll define a class, TagsSpotlightDelegate, which is a subclass of NSCoreDataCoreSpotlightDelegate.
Now, I'll override domainName and indexName with an implementation.
Overriding these selectors tells Spotlight where to store the indexed data and allows you to better identify it later, especially if you have multiple indices.
If you do not override domainIdentifier, the default domain identifier is the store identifier.
If you do not override indexName, the default index name is nil.
The next step in customizing the Spotlight delegate is defining an attribute set.
In the setup portion of this session, the NSCoreDataCore SpotlightDelegate object defined the attribute set returned to Spotlight for us, simply by ticking the check box Index in Spotlight.
Now, I am going to demonstrate exactly how to specify the attributes that will be used for indexing.
Specifying which attributes that should be indexed allows more explicit control over what's indexed and how it's searched for.
To do that, use CSSearchableItemAttributeSet.
An attribute set contains a number of predefined properties allowing you to specify the metadata to display about the specified managed object when it appears as a search result.
The attributes you choose depend completely on your domain.
You can use predefined properties available in CSSearchableItemAttributeSet or you can define your own properties.
The Tags app uses the predefined properties keywords, displayName, and thumbnailData.
It's important to note that you should only modify an attribute set on one thread at a time as concurrent access to the properties in an attribute set has undefined behavior.
Back in the TagsSpotlightDelegate class, let's see how this works by overriding attributeSet (for object:).
In the override implementation, begin by determining if the object is a Photo type object.
Next, initialize an attributeSet with the content type .image.
Then, set the properties identifier, displayName, and thumbnailData on the attribute set using the appropriate attributes from the Photo object.
Now, append tags from the Photo object tag set to the keywords array on the attribute set.
It is worth mentioning at this point that if your model indexes a relationship, attributeSet (for object:) must be overridden so that it defines what about that relationship in particular is indexed.
Lastly, return the attribute set.
Since the model is also indexing Tag objects, the code needs to handle the case of a Tag.
For that, create an attribute set with the contentType .text, set the display name to the name of the tag, and then return the attribute set.
As a last step, remove the Core Data Spotlight display name that was set in the model editor in a previous step.
Let's go further and define an event loop for starting and stopping indexing.
Earlier, when we setup the Spotlight delegate, startSpotlightIndexing was called immediately after creating the Spotlight delegate.
To give you precise control over when the NSCoreDataCoreSpotlightDelegate is performing indexing work, stopSpotlightIndexing has also been added to the framework.
Using these two selectors in concert gives you the ability to start and stop indexing work as necessary, say, in the case where your app is performing intense CPU or disk activity operations.
Now, let's add some support for being notified when index updates complete.
When a change occurs to an entity or entities that is indexed in Spotlight, that index is updated asynchronously.
In iOS 15 and macOS Monterey, the Core Data framework has added index update notifications.
To be informed when the index update is complete, subscribe to NSCoreDataCoreSpotlightDelegate .indexDidUpdateNotification, which is posted by the Spotlight delegate.
These notifications will be posted after processing a call to save: on NSManagedObjectContext or after the completion of batch operations.
Let's see this in action.
First, check to see if indexing is enabled.
If it is, then register for the indexDidUpdateNotification.
Then, in the handler, inspect the notification, which will have a userInfo dictionary that contains two key-value pairs, similar to a remote change notification: an NSString UUID of the store that for which the Spotlight delegate updated its index, and the persistent history token of the store for which the Spotlight delegate updated its index.
You can use both of these keys to determine if the store you're interested in has been indexed up to the latest persistent history token.
If indexing is not enabled, you can remove yourself as an observer from the notifications.
Prior to this year, the only way to delete data indexed by your app was to either implement the Core Spotlight APIs to remove the index entries or delete the entire client graph in Core Data.
Crucially, new in iOS 15 and macOS Monterey, Core Data has given the developer a new way to manage the Spotlight index without deleting the client graph, which is a great win for user privacy! First, the code will stop indexing.
Then, call deleteSpotlightIndex.
Lastly, handle any resulting error in the completion handler.
Note that calling this method may return errors from lower-layer dependencies, such as Core Data and Core Spotlight, and you should be prepared to handle those.
Now that I've shown you how to customize an implementation of the Spotlight delegate, let's validate our setup by adding full-text search to the Tags app using the Core Spotlight APIs.
The results will be what was previously indexed.
Start by defining an extension for PhotosViewController that adopts the UISearchResultsUpdating protocol and a function updateSearchResults (for controller).
The Tags user interface has a UISearchController.
We'll get the user input from that search controller's search bar.
If the user input is empty, fetch all the images from our data provider and then reload the collection view as there is no search query.
Now let's handle the case where there is a search query.
To start, sanitize the user input string by escaping it.
Next, define a query string using the user's sanitized input string.
Query strings operate on the values associated with a property in a CSSearchableItemAttributeSet object.
In this case, the code will be operating on the Keywords attribute that was set in a previous step.
In the search query, the modifiers c, d, and w are being used.
c is for case insensitive.
d is for diacritic insensitive.
And w is for a word-based search.
Now, create a CSSearchQuery object by specifying the formatted query string that was just created and an array of attribute names that correspond to properties defined by CSSearchableItemAttributeSet.
This search query object manages the criteria to apply when searching app content that you have previously indexed using the Spotlight delegate APIs.
Following that, set the foundItemsHandler.
This handler will be called repetitively with items that match the search query previously defined.
In the completionHandler for the query, which will be called once, check for an error and potentially perform some error handling.
Absent an error, dispatch a block onto the main queue to use our data provider to perform a fetch for the items Spotlight found and load them in the user interface.
Lastly, and most importantly, don't forget to start the query.
Now that the Tags app has a Spotlight delegate indexing its content, the data has been freed from within the app! When I go to Spotlight, and I search for a tag I have previously added, it returns two results: the tag name itself and the specific photo that I tagged with the keyword "Natural Bridges State Park".
Wrapping up, we've learned about the NSCoreDataCoreSpotlightDelegate and how it can help your users find their content inside your app and outside your app in Spotlight search, setup the Spotlight delegate quickly and easily to start indexing without a huge code burden, and customized our Spotlight delegate using some of the new APIs available to you this release.
I hope you've found this information useful and that you'll consider adopting NSCoreDataCoreSpotlightDelegate in your project to help users find their content.
Have a great WWDC! ♪
-
-
2:40 - Creating a NSCoreDataCoreSpotlightDelegate
let spotlightDelegate = NSCoreDataCoreSpotlightDelegate(forStoreWith: description, coordinator: coordinator) spotlightDelegate.startSpotlightIndexing()
-
5:24 - Adding a NSCoreDataCoreSpotlightDelegate to a CoreDataStack
import Foundation import CoreData class CoreDataStack { private (set) var spotlightIndexer: TagsSpotlightDelegate? lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "Tags") guard let description = container.persistentStoreDescriptions.first else { fatalError("###\(#function): Failed to retrieve a persistent store description.") } description.type = NSSQLiteStoreType description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) container.loadPersistentStores(completionHandler: { (_, error) in guard let error = error as NSError? else { return } fatalError("###\(#function): Failed to load persistent stores:\(error)") }) spotlightIndexer = TagsSpotlightDelegate(forStoreWith: description, coordinator: container.persistentStoreCoordinator) container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy container.viewContext.automaticallyMergesChangesFromParent = true do { try container.viewContext.setQueryGenerationFrom(.current) } catch { fatalError("###\(#function): Failed to pin viewContext to the current generation:\(error)") } return container }() }
-
6:24 - Creating TagsSpotlightDelegate
class TagsSpotlightDelegate: NSCoreDataCoreSpotlightDelegate { override func domainIdentifier() -> String { return "com.example.apple-samplecode.tags" } override func indexName() -> String? { return "tags-index" } override func attributeSet(for object: NSManagedObject) -> CSSearchableItemAttributeSet? { if let photo = object as? Photo { let attributeSet = CSSearchableItemAttributeSet(contentType: .image) attributeSet.identifier = photo.uniqueName attributeSet.displayName = photo.userSpecifiedName attributeSet.thumbnailData = photo.thumbnail?.data for case let tag as Tag in photo.tags ?? [] { if let name = tag.name { if attributeSet.keywords != nil { attributeSet.keywords?.append(name) } else { attributeSet.keywords = [name] } } } return attributeSet } else if let object as? Tag { let attributeSet = CSSearchableItemAttributeSet(contentType: .text) attributeSet.displayName = tag.name return attributeSet } return nil } }
-
9:51 - Customizing PhotosViewController with Spotlight delegate functionality
class PhotosViewController: UICollectionViewController { @IBOutlet var generateDefaultPhotosItem: UIBarButtonItem! @IBOutlet var deleteSpotlightIndexItem: UIBarButtonItem! @IBOutlet var startStopIndexingItem: UIBarButtonItem! private var isTagging = false private var spotlightFoundItems = [CSSearchableItem]() private static let defaultSectionNumber = 0 private var searchQuery: CSSearchQuery? var spotlightUpdateObserver: NSObjectProtocol? private lazy var spotlightIndexer: TagsSpotlightDelegate = { let appDelegate = UIApplication.shared.delegate as? AppDelegate return appDelegate!.coreDataStack.spotlightIndexer! }() override func viewDidLoad() { super.viewDidLoad() // ... toggleSpotlightIndexing(enabled: true) } @IBAction func deleteSpotlightIndex(_ sender: Any) { toggleSpotlightIndexing(enabled: false) spotlightIndexer.deleteSpotlightIndex(completionHandler: { (error) in if let err = error { print("Encountered error while deleting Spotlight index data, \(err.localizedDescription)") } else { print("Finished deleting Spotlight index data.") } }) } @IBAction func toggleSpotlightIndexingEnabled(_ sender: Any) { if spotlightIndexer.isIndexingEnabled == true { toggleSpotlightIndexing(enabled: false) } else { toggleSpotlightIndexing(enabled: true) } } private func toggleSpotlightIndexing(enabled: Bool) { if enabled { spotlightIndexer.startSpotlightIndexing() startStopIndexingItem.image = UIImage(systemName: "pause") } else { spotlightIndexer.stopSpotlightIndexing() startStopIndexingItem.image = UIImage(systemName: "play") } let center = NotificationCenter.default if spotlightIndexer.isIndexingEnabled && spotlightUpdateObserver == nil { let queue = OperationQueue.main spotlightUpdateObserver = center.addObserver(forName: NSCoreDataCoreSpotlightDelegate.indexDidUpdateNotification, object: nil, queue: queue) { (notification) in let userInfo = notification.userInfo let storeID = userInfo?[NSStoreUUIDKey] as? String let token = userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken if let storeID = storeID, let token = token { print("Store with identifier \(storeID) has completed ", "indexing and has processed history token up through \(String(describing: token)).") } } } else { if spotlightUpdateObserver == nil { return } center.removeObserver(spotlightUpdateObserver as Any) } } }
-
13:13 - Adding full-text search to PhotosViewController
extension PhotosViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { guard let userInput = searchController.searchBar.text, !userInput.isEmpty else { dataProvider.performFetch(predicate: nil) reloadCollectionView() return } let escapedString = userInput.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") let queryString = "(keywords == \"" + escapedString + "*\"cwdt)" searchQuery = CSSearchQuery(queryString: queryString, attributes: ["displayName", "keywords"]) // Set a handler for results. This will be a called 0 or more times. searchQuery?.foundItemsHandler = { items in DispatchQueue.main.async { self.spotlightFoundItems += items } } // Set a completion handler. This will be called once. searchQuery?.completionHandler = { error in guard error == nil else { print("CSSearchQuery completed with error: \(error!).") return } DispatchQueue.main.async { self.dataProvider.performFetch(searchableItems: self.spotlightFoundItems) self.reloadCollectionView() self.spotlightFoundItems.removeAll() } } // Start the query. searchQuery?.start() } }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.