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.

4.7 (6) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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.