Donate content to Spotlight and open it using NSUserActivity

Donate content to Spotlight and open it using NSUserActivity

Learn how to expose the content of your app to Spotlight.

In this article, we’ll explore how to make your app content discoverable in Spotlight and navigate users directly to detailed views using a combination of CSSearchableItem and NSUserActivity.

Previously, we looked at how to achieve this using AppIntents, specifically with the IndexedEntity protocol and an OpenIntent that enables navigation.

Make your app content show on Spotlight
Learn how to index the content of your app to make it searchable via spotlight

This time, we’ll use a different approach that relies on traditional Spotlight integration that complements the AppIntents and IndexedEntity  strategy we explored earlier, offering a flexible way to support Spotlight search, especially for apps targeting a wide range of iOS versions.

Before we start

To address the topics we are going to leverage on a simple sample project: a book library app that lets you keep track of your books on your shelf.

This starter project contains:

  • View folder:
    • BooksListView - shows the stored books as a list
    • BookDetailView - presents the details of a stored book
    • AddBookView - handles the addition of a new one
    • WebView - rendering the file
  • Model folder, collecting all the model files
    • Book - defines the book type
    • DataModel - data persisted using SwiftData
  • Manager folder
    • NavigationManager - handles navigation within the app
    • DataManager - handles the data operations
  • The BooksShelfCustomIntentApp file - the entry point of the app
  • Intents folder - collecting all files related to intents
    • BookEntity - handling the AppEntity for the Book model
    • OpenBookIntent - handling the AppIntent that allows opening a specific book
    • ShorcutsProviders - handling the AppShortcutsProvider conforming type that enables the invocation of the intents via vocal commands
    • SearchIntent - handling the AppIntent that allows to perform research inside your app
  • Widget folder - collecting all files related to the Control action.

The NavigationManager and the shared Modelcontainer are initialized at the app entry point. Launch the app and start storing your books; they will be needed to launch and test.

Step 1 - Define a Searchable Attribute Set

To make your app’s content discoverable in Spotlight, you first need to create a CSSearchableItemAttributeSet that specifies the metadata describing each book in Spotlight.

// 1.
extension Book {
    // 2. 
    var attributeSet: CSSearchableItemAttributeSet {
        // 3.
        let attributes = CSSearchableItemAttributeSet(contentType: .text)
        attributes.title = title
        attributes.contentDescription = author
        return attributes
    }
}
  1. Create an extension of Book .
  2. Implement a computed CSSearchableItemAttributeSet type, the attributeSet property feeding it with the metadata the book can be searched by.
  3. Initialize a CSSearchableItemAttributeSet object using the init(contentType:) to implement attributes that improve search accuracy in Spotlight. Spotlight search uses the title, subtitle, and image from an entity’s display representation. To improve search accuracy and ensure other attributes are searchable, provide a custom attribute set for the data you donate. Here, we’re populating the title and description fields, which Spotlight uses for display and matching queries.
However, this is not the only approach you can use. You could also conform the BookEntity type to IndexedEntity protocol and leverage the attributeSet property of a CSSearchableItemAttributeSet type enabled by the protocol.

Step 2 - Index the Item with CSSearchableItem

Once we have our metadata ready, we need to index the item so it becomes searchable.

In DataManager, we add the indexing logic to the book creation method:

import Foundation
import SwiftData
import CoreSpotlight

@MainActor
class DataManager {
    
    static func collectAllBooks() async throws -> [Book] { ... }
    
    static func collectBooks(for identifiers: [UUID]) async throws -> [Book] { ... }
    
    static func collectBooks(matching string: String) async throws -> [Book] { ... }
    
    static func createNewBook(book: Book) async throws {
        ...
        // 1. 
        let index = CSSearchableIndex(name: "MyIndex")
        // 2.
        let searchableItem = CSSearchableItem(
            uniqueIdentifier: book.id.uuidString,
            domainIdentifier: "antonella.BooksShelfSpotlightNsUserActivity.Book",
            attributeSet: book.attributeSet
        )
        // 3.
        try await index.indexSearchableItems([searchableItem])
    }
    
    static func deleteBook(book: Book) async throws { ... }
}
  1. Initialize a CSSearchableIndex object, it will handle the on-device index for the app’s searchable content.To initialize the index we used the init(name:) that allows to create an index with a name of your choice. One of the advantages of creating a custom index is that you can also specify the type of data protection you wan to apply to the content donated to Spotlight using the init(name:protectionClass:) constructor. If there is no need to specify the name of your index, you can use the default() one.
  2. Create a CSSearchableItem object. This class handles the details of your app-specific content that someone might search for on their devices: it will uniquely identify the book created, associate it with a domainIdentifier, and set the related metadata that Spotlight will use to look for it later.
  3. When the item is ready, index it using indexSearchableItems(_:completionHandler:) method on the index created before.

What we need to do now is updating the index items when an item is deleted.

import Foundation
import SwiftData
import CoreSpotlight

@MainActor
class DataManager {
    
    static func collectAllBooks() async throws -> [Book] { ... }
    
    static func collectBooks(for identifiers: [UUID]) async throws -> [Book] { ... }
    
    static func collectBooks(matching string: String) async throws -> [Book] { ... }
    
    static func createNewBook(book: Book) async throws { ... }
    
    static func deleteBook(book: Book) async throws { 
        ...
        // 1. 
        let index = CSSearchableIndex(name: "MyIndex")
        try? await index.deleteSearchableItems(withIdentifiers: [book.id.uuidString])
    }
}
  1. Initialize a CSSearchableIndex object, it will handle the on-device index for the app’s searchable content.
  2. Call the deleteSearchableItems(withIdentifiers:completionHandler:) method to delete the book based on its id.

Step 3 - Handle Deep Linking with NSUserActivity

Now that the content is indexed, we want users to tap a result and land directly in the detail view of the selected book. This is where NSUserActivity comes in.

Update your BooksListView to respond to NSUserActivity events triggered by Spotlight:

import SwiftUI
import SwiftData
import CoreSpotlight

struct BooksListView: View {
    ...
    var body: some View {
        
        @Bindable var navigation = navigation
        
        NavigationStack(path: $navigation.navigationPath) {
            ...
        }
        ...
            // 1. 
            .onContinueUserActivity(CSSearchableItemActionType) { activity in
                // 2. 
                if let bookID = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, let matchedBook = books.first(where: { $0.id.uuidString == bookID }) {
                   // 3.
                   navigation.navigationPath.append(matchedBook)
                }
            } 
        }
    }
    
    private func deleteBook(at offsets: IndexSet) { ... }
}
  1. Use the onContinueUserActivity(_:perform:) modifier to receive NSUserActivity instances. Set the activityType parameter on the type of activity that needs to trigger the performing action. In our case is a CSSearchableItemActionType .
  2. In the closure to be performed when the activityType matches with the one specified, check if the activity’s userInfo contains the identifier of a book.
  3. If it does, append the book to the navigation path, taking the user straight to the detailed view.
0:00
/0:41

Final Result

You can download the final version of the project here:

To recap, to make your app content discoverable and navigable from Spotlight without leveraging the IndexedEntity , you need to: define a CSSearchableItemAttributeSet that specifies the metadata describing each item you want to donate to Spotlight; then, you need to create CSSearchableItem feeding the metadata collected in the CSSearchableItemAttributeSet ; and last, handling deep linking with NSUserActivity to respond when users tap on Spotlight results and navigate them directly to the corresponding detail view in your app.

This approach gives you full control over how your app’s content integrates with Spotlight and provides a smooth, native experience for users searching on their devices.