Set Up Core Spotlight with Core Data: Getting Started
Learn how to connect Core Data with Core Spotlight and add search capability to your app using Spotlight. By Warren Burton.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Set Up Core Spotlight with Core Data: Getting Started
30 mins
- Getting Started
- Adding Spotlight to Your Core Data Model
- Configuring Spotlight in Your Persistent Store
- Creating a Spotlight Delegate
- Connecting a Spotlight Delegate
- Describing Searchable Items
- Trying It Out
- Heavy Lifting
- Building a Bridge
- Importing From an API
- Running the Fetch
- Opening a Search Result in Your App
- Including Image Thumbnails with Spotlight
- Deleting Spotlight Data
- Searching Inside the App
- Searching Core Spotlight
- Searchable SwiftUI
- Where to Go From Here?
Many apps you build are containers for your customers’ information, and it’s up to you to provide great ways to let your customer search for that information. However, if the only way to find that information is to open your app and search for it, it makes it difficult for your users. You could instead expose that information to Spotlight and have your app results appear there.
When you have a lot of information that has a complex structure, Core Data is the preferred way of storing your app data. It’s super fast, and as long as you follow a few simple rules, it’s easy to use. The framework has been around for more than 15 years, and Apple engineers have been improving it this whole time.
In this tutorial, you’ll learn how to:
- Upgrade your Core Data model to support Core Spotlight.
- Create a custom
NSCoreDataCoreSpotlightDelegate
to connect your database toCoreSpotlight
. - Start and stop the indexer when needed.
- Use
CoreSpotlight
for searching within your app.
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.
You’ll be using a bug tracking utility app, PointyBug. If you’ve already read Drag and Drop Tutorial for SwiftUI, you’ll be familiar with this app.
The persistent storage of PointyBug uses Core Data, and you’ll be adding extra code to expose your data to Spotlight.
Open the Xcode project in the starter directory. First, ensure that the simulator is set to iPad Pro (11-inch) (3rd generation):
Then, build and run. Rotate the simulator to landscape mode to see both the bug list and the details:
Tap ADD BUG, then tap the image picker button at top right of the detail view to add an image. Nothing happens when you tap the button, which seems like a bug. Fortunately, you can use drag and drop to import an image. Trigger a screenshot from the simulator by using the menu Device ▸ Trigger Screenshot.
The screenshot will appear at the bottom-left corner of the screen. Drag and drop that small image to the detail view:
Drag the arrow image at the bottom to add a pointer to show where the problem is. Next, add a description of Image picker button in navigation bar doesn’t show image picker. Then, press Return. Finally, set the status to Open by tapping the gray “No Tag” icon. Now, you have your first bug report:
Press Command-Shift-H to return to the Home Screen and trigger a save.
What you have is a shoe box app: a place to put bits of useful information. But without a system search, it has limited long-term value for your users. Your next task is to add the code to expose your data to the system.
Adding Spotlight to Your Core Data Model
Your first task is to change your Core Data model to let Spotlight know what parts of your model to index.
In the Project navigator, locate the group PointyBug ▸ Model ▸ Core Data. Select PointyBug.xcdatamodeld:
Select CDBug and then text. In the Data Model inspector, check the box for Index in Spotlight:
You took the first step of connecting your data to Spotlight. The next step is to configure NSPersistentContainer
to allow indexing.
Configuring Spotlight in Your Persistent Store
In Core Data, open CoreDataStack.swift. This class wraps all the code needed to create an NSPersistentContainer
that has all the machinery to work with a Core Data store.
Find makeStore(at:)
. This method creates an NSPersistentStoreDescription
that describes how you want your store to work. Next, locate this line:
storeDescription.type = NSSQLiteStoreType
Below it, add the following:
storeDescription.setOption( true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
This tells the store to use persistent history tracking. Setting this option is necessary for a Spotlight indexed store. The store will now record the changes you make and use those change sets to work out what to index.
In the same file, find isPreviewContext
. This switch tells the persistent store to be memory resident when used in SwiftUI previews. Using NSInMemoryStoreType
means there’s no file mess to clean up. For the same reason, this is also a useful configuration when doing unit testing.
Your next task is to create an instance of NSCoreDataCoreSpotlightDelegate
for the NSPersistentStoreCoordinator
.
Creating a Spotlight Delegate
NSCoreDataCoreSpotlightDelegate
describes a set of methods that enable integration of a Core Data store with Core Spotlight:
In the Project navigator, select Core Data and create a new Swift file named BugSpotlightDelegate.swift. Add the following code to the file:
import CoreData import CoreSpotlight class BugSpotlightDelegate: NSCoreDataCoreSpotlightDelegate { override func domainIdentifier() -> String { return "com.raywenderlich.pointybug.bugs" } override func indexName() -> String? { return "bugs-index" } }
BugSpotlightDelegate
is a subclass of NSCoreDataCoreSpotlightDelegate
. In this subclass, you override a couple of attributes to define some names that establish a connection between your app and Core Spotlight. domainIdentifier
should be a reverse-coded domain that builds on your app’s bundle identifier, com.raywenderlich.pointybug.
That’s all you need for a start. Next, you’ll plug BugSpotlightDelegate
into your store.
Connecting a Spotlight Delegate
You created NSCoreDataCoreSpotlightDelegate
, so now you can attach it to your store. Open CoreDataStack.swift.
Add this property to the top of CoreDataStack
:
private(set) var spotlightIndexer: BugSpotlightDelegate?
Then, add this extension to the end of the file:
extension CoreDataStack { func toggleSpotlightIndexing(enabled: Bool) { guard let spotlightIndexer = spotlightIndexer else { return } if enabled { spotlightIndexer.startSpotlightIndexing() } else { spotlightIndexer.stopSpotlightIndexing() } } }
This is a helper to keep start and stop logic for NSCoreDataCoreSpotlightDelegate
in one place.
Now, locate configureContainer()
. Inside the trailing closure of container.loadPersistentStores
, add the code below at the marker // insert here
:
if !self.isPreviewContext { let coordinator = self.container.persistentStoreCoordinator self.spotlightIndexer = BugSpotlightDelegate( forStoreWith: storeDescription, coordinator: coordinator) self.toggleSpotlightIndexing(enabled: true) }
In this fragment, you create BugSpotlightDelegate
with information about your store. Then, you tell Core Spotlight to start doing its work.
There’s one last step: You need to provide a package of information that describes a searchable item.
Describing Searchable Items
To describe a searchable item, use a CSSearchableItemAttributeSet
from CoreSpotlight
.
Open BugSpotlightDelegate.swift and add the code below to BugSpotlightDelegate
:
override func attributeSet(for object: NSManagedObject) -> CSSearchableItemAttributeSet? { guard let bug = object as? CDBug else { return nil } let attributeSet = CSSearchableItemAttributeSet(contentType: .text) let base = bug.textValue let tags = (bug.tags as? Set<CDTag> ?? []) .compactMap { $0.name } .joined(separator: " ") let idString = "PB \(String(format: "%03d", bug.bugID))" attributeSet.textContent = idString + base + " " + tags attributeSet.displayName = "\(idString): \(bug.primaryTag?.name ?? "")" attributeSet.contentDescription = base return attributeSet }
attributeSet(for:)
is the method that Core Spotlight calls when it’s indexing. BugSpotlightDelegate
calls this method for each NSManagedObject
that needs indexing.
In this code, you perform the following actions:
- Cast the
object
asCDBug
. - Create a
CSSearchableItemAttributeSet
of typetext
. - Set the searchable text in
textContent
. - Set the
displayName
andcontentDescription
that will appear in Spotlight when you search.
Notice how textContent
and displayName
don’t need to be the same thing. The set of strings that leads your customer to this result can be different than what’s displayed in the UI.
This only touches the surface of the CSSearchableItemAttributeSet
API. Read the documentation to determine how to improve search results.
Trying It Out
Now, you’re ready to do some spotlighting. Build and run. When the app finishes launching, press Command-Shift-H to return to the Home Screen. Drag down in the center of your iPad to expose Spotlight. Now, search for button.
You’ll need to scroll down the page and maybe tap “Show More” to see the result, but there it is. Your app data is showing up in the system search:
Tap the result and notice it opens PointyBug, but nothing’s shown. Soon, you’ll find out how to respond to the open event and complete the circle by selecting the result in PointyBug. But first, you need to find out when to stop and start the indexer.
Heavy Lifting
Many apps use REST API endpoints to populate their local databases. You don’t want Spotlight to be indexing while you import the data, as you want all the CPU on parsing and importing.
In this section, you’ll import some data to PointyBug and wrap that call in a stop and start sequence.
Building a Bridge
You’ll be working with a fictional API. The data actually comes from a file in the project, demodata.tsv. The code to read the data from this file is in NetworkController.swift, but for now, you’ll stay focused on the search.
In the Project navigator, open CDBug+Help.swift in the group Core Data. Add this extension to the end of the file:
extension CDBug { static func fromRemoteRepresentation( _ remote: RemoteBug, in context: NSManagedObjectContext ) -> CDBug { let bug = createOrFetchExisting(bugID: remote.bugID, in: context) bug.text = remote.text if let tagname = remote.tagID, let tag = Tag.tagNamed(tagname, in: context) { bug.addToTags(tag) } return bug } }
This code converts RemoteBug
to a Core Data object, CDBug
. This pattern of bridging remote data shields your inner database from changes in the remote API at the cost of some boilerplate code. Notice the code uses the helper method, createOrFetchExisting(bugID:in:)
, to fetch an existing bug if it exists. This prevents creating duplicate bugs when syncing with the server.
Next, in Controller, open BugController.swift. Add this code to the end of the file:
extension BugController { private func makeBugs( with remotes: [RemoteBug], completion: @escaping () -> Void ) { // 1 let worker = dataStack.makeWorkerContext() // 2 worker.perform { var index: Int16 = 10000 // 3 _ = remotes.map { rBug in let dbBug = CDBug.fromRemoteRepresentation(rBug, in: worker) dbBug.orderingIndex = index index += 1 } // 4 try? worker.save() DispatchQueue.main.async { completion() } } } }
The pattern you see is frequently used when working with Core Data and imported data:
- Create a worker context. This is a
NSManagedObjectContext
, which is a child of the main queue context and has its ownDispatchQueue
. - Tell the worker to perform work in its own queue asynchronously.
- Each
RemoteBug
maps to aCDBug
and gets a large ordering index to force the bug to the end of the list. You don’t need to keep the mapped array, as the context holds the new records. - Save the worker context, which merges these changes back to the main queue context and your UI.
By doing hard work in its own queue, you don’t risk locking up the user UI.
Importing From an API
Next, you’ll use this setup code to fetch the remote API. Add this method to the same extension
in BugController
:
func syncWithServer() throws { // 1 let bugfetcher: BugFetchProtocol = NetworkController() // 2 dataStack.toggleSpotlightIndexing(enabled: false) try bugfetcher.fetchBugs { result in switch result { case .success(let remoteBugs): makeBugs(with: remoteBugs) { [self] in assertOrderingIndex() // 3 dataStack.saveContext() // 4 dataStack.toggleSpotlightIndexing(enabled: true) } case .failure(let error): print("oh no! - the remote fetch failed -\(error.localizedDescription)") dataStack.toggleSpotlightIndexing(enabled: true) } } }
In this method, you deal with starting and stopping Spotlight indexing while the app does the hard work:
- Create an object that conforms to
BugFetchProtocol
. By using a protocol interface,BugController
doesn’t care who fetches bugs. This pattern improves testability. - Turn off Spotlight indexing.
- When
makeBugs(with:completion:)
completes, save the main context, moving the imported bugs all the way to theNSPersistentStore
. - Turn indexing back on. Spotlight will figure out what has changed and get to work on indexing the
NSPersistentStore
.
You now need to make a couple of small changes to your Core Data system to help with the data import.
Running the Fetch
The last thing you need to do is make the call to the API. You’ll use a button to trigger this event.
In the Project navigator, in the group Views, open BugListView.swift. Inside an HStack
, near the bottom of the view, locate the comment // Insert first button here
. Add this code at that mark to declare a button:
Button("SYNC") { try? bugController.syncWithServer() } .padding(8) .foregroundColor(.white) .background(Color.orange) .cornerRadius(10, antialiased: true)
The action for the button calls syncWithServer
, which you defined in the previous section. Build and run. You’ll see a shiny new button at the bottom of the main bug list:
Tap the SYNC button and some new bugs will appear in the list:
You can synchronize as many times as you like, but the bug list will only change once. Press Command-Shift-H to trigger a save.
All this work for a few records might seem like overkill, but consider if you have an API returning 1,000 records to your app. Even the latest iPad Pro would take time to import all that data.
Your bug list now has a few items in it, so it’s time to figure out how to use a Spotlight search result to open a corresponding bug in PointyBug.
Opening a Search Result in Your App
You observed earlier that when you tap the search result in Spotlight, PointyBug opens. In this section, you’ll learn how to respond to that event and open the record that corresponds to the search.
First, you need to add some helper methods. In the Project navigator, open CoreDataStack.swift. Then, add this extension at the end of the file:
extension CoreDataStack { func bugWithURI(_ uri: URL) -> Bug? { guard let objectID = viewContext .persistentStoreCoordinator? .managedObjectID(forURIRepresentation: uri) else { return nil } return viewContext.object(with: objectID) as? CDBug } }
You can reference NSManagedObject
instances using a Uniform Resource Identifier (URI). The URI acts like a street address for an object. Here, you add a method to recover a record from the database by using that URI.
Next, in the group Controller, open BugController.swift and add the following extension to the end of the file:
extension BugController { func selectBugWithURI(_ uri: URL) { if let bug = dataStack.bugWithURI(uri) { selectedBug = bug } } }
This method uses bugWithURI
to select the bug referenced by the URI.
In the Project navigator, in the group Views, open AppMain.swift. Add this import to the top of the file:
import CoreSpotlight
Next, add the following method to AppMain
:
func continueActivity(_ activity: NSUserActivity) { if let info = activity.userInfo, let objectIdentifier = info[CSSearchableItemActivityIdentifier] as? String, let objectURI = URL(string: objectIdentifier) { bugController.selectBugWithURI(objectURI) } }
When you tap a Spotlight result, the event that opens PointyBug sends an NSUserActivity
. This method unwraps the userInfo
dictionary in NSUserActivity
to recover the URI for the referenced CDBug
. BugSpotlightDelegate
creates the reference automatically when it calls attributeSet(for:)
.
In the body
of AppMain
, at the marker //add modifier here
, add this code:
.onContinueUserActivity(CSSearchableItemActionType) { activity in continueActivity(activity) }
This modifier is called when PointyBug opens as a result of an NSUserActivity
. Actions that use NSUserActivity
include Handoff and Spotlight. The identifier type is CSSearchableItemActionType
, which might seem odd, as the search result comes from the database of PointyBug. The key takeaway here is that Core Spotlight — not PointyBug — is the owner of the search result.
Build and run. When the app finishes launching, press Command-Shift-H to return to the Home Screen. Search for “cell” in Spotlight. You’ll see the record that matches in Spotlight if you scroll:
Tap the result. PointyBug will open and select that record. Your users now have a full journey back to their content from Spotlight. In the next part, you’ll add some images to the search results.
Including Image Thumbnails with Spotlight
PointyBug uses images to help show where bugs are. In this section, you’ll learn how to add images to your Spotlight results.
The code to render a full-size image already exists, but you need to provide a thumbnail of an appropriate size.
In the Project navigator, in the group Controller, open BugRenderer.swift. Then, add the following to the main class:
static func spotlightExport(_ bug: Bug) -> UIImage? { let renderer = BugRenderer(bug: bug, maxDimension: 270) return renderer.render(forSpotlight: true) }
Here, you ask the renderer to create an image of a maximum size of 270 x 270 pixels. The rendering code is implemented in render(forSpotlight:)
. To keep you focused on Spotlight, all you need to know for now is that UIGraphicsImageRenderer
paints the full size bug image into a rectangle of 270 × 270 pixels.
Next, in the group Core Data, open BugSpotlightDelegate.swift, and locate attributeSet(for:)
. Then, find the following line:
let attributeSet = CSSearchableItemAttributeSet(contentType: .text)
Replace that line with the following:
let attributeSet: CSSearchableItemAttributeSet if let thumb = BugRenderer.spotlightExport(bug), let jpegData = thumb.jpegData(compressionQuality: 0.8) { attributeSet = CSSearchableItemAttributeSet(contentType: .image) attributeSet.thumbnailData = jpegData } else { attributeSet = CSSearchableItemAttributeSet(contentType: .text) }
In this fragment, if possible, you generate a thumbnail image and then set thumbnailData
on a CSSearchableItemAttributeSet
with a content type of .image
. If there’s no image, you fall back to the original text only representation.
Build and run. Then, change the description of PB 001 to Image picker isn’t presented. Press Command-Shift-H to return to the Home Screen. Search for picker in Spotlight. You now have a little thumbnail image to go along with the search result:
You’ve learned how to create Spotlight data. Next, you’ll find out how to delete it.
Deleting Spotlight Data
There are many reasons to delete data — one example is the case of a user logging out of your app. The customer expects to have all their data erased and doesn’t want to see their personal data in a search result.
In the Project navigator, in the group Core Data, open CoreDataStack.swift. Then, add this method to CoreDataStack
:
func deleteSpotlightIndex(completion: @escaping (Error?) -> Void) { toggleSpotlightIndexing(enabled: false) spotlightIndexer?.deleteSpotlightIndex(completionHandler: completion) }
In this method, you stop the indexer and tell it to delete the Spotlight index. You don’t start the indexer again afterward because you’ll soon prove that the indexed data is gone. How you handle restarting the indexer after a delete in your own app will depend on your business case.
Next, in the group Controller, open BugController.swift. Then, add this code to the end of the file:
extension BugController { func deleteIndex() { dataStack.deleteSpotlightIndex { error in if error != nil { // TBD - handle error appropriately } else { print("*** Index erased successfully.") } } } }
This code provides an accessor for deleteSpotlightIndex(completion:)
in CoreDataStack
. The goal with this architecture is to ensure that the UI layer doesn’t know that Core Data exists.
In the group Views, open BugListView.swift. Next, inside the HStack
near the bottom of the view, locate the comment // Insert second button here
. Add the following at the mark to declare a button:
Button("DELETE INDEX") { bugController.deleteIndex() } .padding(8) .foregroundColor(.white) .background(Color.red) .lineLimit(1) .cornerRadius(10, antialiased: true)
Build and run. Now you have an extra button at the bottom of the main list:
That big red DELETE INDEX button sure is inviting. Tap the button, and then press Command-Shift-H to return to the Home Screen. Search for “bug” and you’ll notice nothing appears in the Spotlight search results, apart from a match on the PointyBug app itself.
Searching Inside the App
You’ve done all this work to provide search results for when people are searching outside your app. Now, you’ll learn how to leverage Spotlight inside your app. In this section, you’ll add a search feature that looks up results in the Spotlight index.
The following sections will show you how to do just that.
Searching Core Spotlight
First, you’ll add the machinery to search the Spotlight index. In the Project navigator, within the group Controller, open BugController.swift. Then, add this import to the top of the file:
import CoreSpotlight
Next, add these properties to BugController
:
private var searchQuery: CSSearchQuery? private var spotlightFoundItems: [CSSearchableItem] = []
Then, add the following extension to the end of the file:
extension BugController { private func fetchSearchResults(_ items: [CSSearchableItem]) { let foundBugs = items.compactMap { (item: CSSearchableItem) -> CDBug? in guard let bugURI = URL(string: item.uniqueIdentifier) else { return nil } return dataStack.bugWithURI(bugURI) } bugs = foundBugs } }
Spotlight returns instances of CSSearchableItem
. In this method, you map the uniqueIdentifier
of CSSearchableItem
— which is a Core Data object URI — to the corresponding CDBug
instance. At the end, you update bugs
with the result to update the list in the UI.
The next step is to create a CSSearchQuery
that will perform the search for you. Add this method to the same extension:
private func searchCoreSpotlight(_ term: String) { // 1 let escapedTerm = term .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let queryString = "(textContent == \"\(escapedTerm)*\"cd)" // 2 searchQuery = CSSearchQuery( queryString: queryString, attributes: ["textContent"]) // 3 searchQuery?.foundItemsHandler = { items in DispatchQueue.main.async { self.spotlightFoundItems += items } } // 4 searchQuery?.completionHandler = { error in guard error == nil else { print(error?.localizedDescription ?? "oh no!") return } DispatchQueue.main.async { self.fetchSearchResults(self.spotlightFoundItems) self.spotlightFoundItems.removeAll() } } // 5 searchQuery?.start() }
In this method you set up and start a CSSearchQuery
based on your search term:
- Sanitize your search term, and then construct a query string. The syntax for this string is documented here. The query used here says “Search the textContent attribute with a case and diacritic insensitive search”.
- Instantiate
CSSearchQuery
with that query string and a list of the attributes fromCSSearchableItemAttributeSet
that you want to search. - Append found results to an array, as the
foundItemsHandler
ofCSSearchQuery
may be called many times during a search. - The
completionHandler
ofCSSearchQuery
is only called once. For a happy path, usefetchSearchResults()
to convert the results to records in the PointyBug database and clean up. - Call
start()
to trigger the query.
The last step is to provide access to the search function. Add this final method to the same extension:
func setSearchText(_ term: String) { guard !term.isEmpty else { searchQuery?.cancel() bugs = fetchedResults.fetchedObjects ?? [] return } searchCoreSpotlight(term) }
This method either resets the bug list to an unfiltered state or runs a search. You’re all done with the machinery. Now, all you need to do is add some UI.
Searchable SwiftUI
SwiftUI search is very simple. You declare an element as searchable, and SwiftUI interprets that as needed.
In the Project navigator, in the group Views, open BugListView.swift. Add this property to the top of BugListView
:
@State var searchText = ""
With this, you declare a bindable @State
property to act as storage for the search term.
Next, within the body
of BugListView
, add the following modifiers to the List
at the marker // add searchable here
:
.searchable(text: $searchText) .onChange(of: searchText) { newValue in bugController.setSearchText(newValue) }
You can’t help but love SwiftUI for syntax like this. You declare the List
as searchable
and bind to searchText
. The final modifier, onChange
, relays the search text to the method you created on BugController
.
Build and run. Then, pull down on the main list to reveal the search bar, and search for QA:
You can see all the bugs that are in a QA state.
Congratulations! You’ve created a fully featured search experience for your app. Quit your simulator to give the fans a rest.
Where to Go From Here?
Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.
In this tutorial, you learned all the steps necessary to use Core Spotlight with a Core Data database, including how to:
- Upgrade your Core Data model to support Core Spotlight.
- Create a custom
NSCoreDataCoreSpotlightDelegate
to connect your database toCoreSpotlight
. - Start and stop the indexer when needed.
- Use
CoreSpotlight
for searching within your app.
Hopefully, this tutorial has demonstrated the simplicity and the power of the new API included with iOS 15 and macOS 12, and perhaps it’ll inspire you to both use Core Data and expose your data to Spotlight in clever ways.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
All videos. All books.
One low price.
A Kodeco subscription is the best way to learn and master mobile development — plans start at just $19.99/month! Learn iOS, Swift, Android, Kotlin, Flutter and Dart development and unlock our massive catalog of 50+ books and 4,000+ videos.
Learn more