watchOS With SwiftUI by Tutorials!

Build awesome apps for Apple Watch with SwiftUI,
the declarative and modern way.

Home iOS & Swift Tutorials

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.

4.5 / 5 4 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

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 to CoreSpotlight.
  • Start and stop the indexer when needed.
  • Use CoreSpotlight for searching within your app.
Note: This tutorial requires knowledge of Core Data. You can review the basics of Core Data with the Core Data with SwiftUI Tutorial.

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):

Choose a simulator

Then, build and run. Rotate the simulator to landscape mode to see both the bug list and the details:

Starting point for the app

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 and drop the thumbnail

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:

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:

Project navigator view

Select CDBug and then text. In the Data Model inspector, check the box for Index in Spotlight:

Edit the data model

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.

Illustration describing the parts of a Core Data stack

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:

Core Spotlight structure

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:

  1. Cast the object as CDBug.
  2. Create a CSSearchableItemAttributeSet of type text.
  3. Set the searchable text in textContent.
  4. Set the displayName and contentDescription 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:

Search result

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:

  1. Create a worker context. This is a NSManagedObjectContext, which is a child of the main queue context and has its own DispatchQueue.
  2. Tell the worker to perform work in its own queue asynchronously.
  3. Each RemoteBug maps to a CDBug 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.
  4. 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:

  1. Create an object that conforms to BugFetchProtocol. By using a protocol interface, BugController doesn’t care who fetches bugs. This pattern improves testability.
  2. Turn off Spotlight indexing.
  3. When makeBugs(with:completion:) completes, save the main context, moving the imported bugs all the way to the NSPersistentStore.
  4. 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:

Sync button in the UI

Tap the SYNC button and some new bugs will appear in the list:

New data 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:

Spotlight results

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:

Spotlight results with thumbnail

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:

Delete button in UI

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.

View of the bug

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:

  1. 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”.
  2. Instantiate CSSearchQuery with that query string and a list of the attributes from CSSearchableItemAttributeSet that you want to search.
  3. Append found results to an array, as the foundItemsHandler of CSSearchQuery may be called many times during a search.
  4. The completionHandler of CSSearchQuery is only called once. For a happy path, use fetchSearchResults() to convert the results to records in the PointyBug database and clean up.
  5. 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:

Filtered bugs

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 to CoreSpotlight.
  • 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!

More like this

Contributors

Comments