Spotlight and Content Discovery
Comprehensive guide to CoreSpotlight for content indexing, NSUserActivity for handoff and search, and making your app's content discoverable in iOS 26.
Prerequisites
- iOS 9+ for CoreSpotlight (iOS 26 recommended)
- Xcode 26+
Overview
Two Indexing Approaches
- CoreSpotlight - Index any content at any time (comprehensive)
- NSUserActivity - Index content user actually views (usage-based)
When to Use Each
| Feature |
CoreSpotlight |
NSUserActivity |
| Timing |
Any time |
When user views content |
| Scope |
All content |
Viewed content |
| Handoff |
No |
Yes |
| Web indexing |
No |
Yes |
| Ranking |
Default |
Usage-boosted |
CoreSpotlight
Import
import CoreSpotlight
import MobileCoreServices
Basic Indexing
import CoreSpotlight
func indexNote(_ note: Note) {
// Create searchable item attributes
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
attributes.lastUsedDate = note.modifiedAt
attributes.keywords = note.tags
// Optional: thumbnail
if let thumbnailData = note.thumbnailData {
attributes.thumbnailData = thumbnailData
}
// Create searchable item
let item = CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
// Optional: Set expiration
item.expirationDate = Date().addingTimeInterval(30 * 24 * 60 * 60) // 30 days
// Index the item
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error {
print("Indexing failed: \(error)")
}
}
}
Async Indexing
func indexNote(_ note: Note) async throws {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
let item = CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
try await CSSearchableIndex.default().indexSearchableItems([item])
}
Batch Indexing
func indexAllNotes(_ notes: [Note]) async throws {
let items = notes.map { note -> CSSearchableItem in
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
attributes.lastUsedDate = note.modifiedAt
return CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
}
try await CSSearchableIndex.default().indexSearchableItems(items)
}
Rich Attribute Set
func createRichAttributes(for note: Note) -> CSSearchableItemAttributeSet {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
// Basic info
attributes.title = note.title
attributes.contentDescription = note.content
attributes.displayName = note.title
// Dates
attributes.contentCreationDate = note.createdAt
attributes.contentModificationDate = note.modifiedAt
attributes.lastUsedDate = note.lastViewedAt
// Keywords and categorization
attributes.keywords = note.tags
attributes.subject = note.category
// Media (if applicable)
if let imageData = note.thumbnailData {
attributes.thumbnailData = imageData
}
// Contact info (for contact-related content)
attributes.authorNames = [note.author]
attributes.authorEmailAddresses = [note.authorEmail]
// Location (if applicable)
if let location = note.location {
attributes.latitude = location.latitude as NSNumber
attributes.longitude = location.longitude as NSNumber
attributes.namedLocation = location.name
}
// Custom attributes
attributes.identifier = note.id.uuidString
attributes.relatedUniqueIdentifier = note.folder?.id.uuidString
return attributes
}
Deleting from Index
// Delete specific item
func deleteFromIndex(noteId: UUID) async throws {
try await CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: [noteId.uuidString]
)
}
// Delete by domain
func deleteAllNotes() async throws {
try await CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["com.yourapp.notes"]
)
}
// Delete all indexed content
func deleteAllIndexedContent() async throws {
try await CSSearchableIndex.default().deleteAllSearchableItems()
}
Updating Index
func updateNoteInIndex(_ note: Note) async throws {
// Simply re-index with same identifier
// CoreSpotlight replaces existing item
try await indexNote(note)
}
NSUserActivity
Basic Setup
import UIKit
class NoteViewController: UIViewController {
var note: Note!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setupUserActivity()
}
func setupUserActivity() {
let activity = NSUserActivity(activityType: "com.yourapp.viewNote")
// Basic properties
activity.title = note.title
activity.userInfo = ["noteId": note.id.uuidString]
// Enable features
activity.isEligibleForSearch = true // Spotlight search
activity.isEligibleForPrediction = true // Siri suggestions
activity.isEligibleForHandoff = true // Handoff to other devices
// Search attributes
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
activity.contentAttributeSet = attributes
// Keywords
activity.keywords = Set(note.tags)
// Associate with view controller
userActivity = activity
activity.becomeCurrent()
}
}
SwiftUI Integration
struct NoteDetailView: View {
let note: Note
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(note.title)
.font(.largeTitle)
Text(note.content)
}
}
.userActivity("com.yourapp.viewNote") { activity in
activity.title = note.title
activity.userInfo = ["noteId": note.id.uuidString]
activity.isEligibleForSearch = true
activity.isEligibleForHandoff = true
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
activity.contentAttributeSet = attributes
}
}
}
Web Page Integration
func setupWebEligibleActivity() {
let activity = NSUserActivity(activityType: "com.yourapp.viewArticle")
activity.title = article.title
activity.webpageURL = URL(string: "https://yourapp.com/articles/\(article.id)")
activity.isEligibleForSearch = true
activity.isEligibleForPublicIndexing = true // Can appear in public search
activity.isEligibleForHandoff = true
userActivity = activity
activity.becomeCurrent()
}
Correlating with CoreSpotlight
// Use same identifier in both
let uniqueId = note.id.uuidString
// CoreSpotlight item
let spotlightItem = CSSearchableItem(
uniqueIdentifier: uniqueId,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
// NSUserActivity
let activity = NSUserActivity(activityType: "com.yourapp.viewNote")
activity.contentAttributeSet?.relatedUniqueIdentifier = uniqueId
Handling Spotlight Results
In App Delegate
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Handle NSUserActivity
if userActivity.activityType == "com.yourapp.viewNote" {
if let noteId = userActivity.userInfo?["noteId"] as? String {
navigateToNote(id: noteId)
return true
}
}
// Handle CoreSpotlight result
if userActivity.activityType == CSSearchableItemActionType {
if let uniqueId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigateToNote(id: uniqueId)
return true
}
}
return false
}
}
In Scene Delegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
handleUserActivity(userActivity)
}
func handleUserActivity(_ activity: NSUserActivity) {
switch activity.activityType {
case "com.yourapp.viewNote":
if let noteId = activity.userInfo?["noteId"] as? String {
navigateToNote(id: noteId)
}
case CSSearchableItemActionType:
if let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigateToNote(id: uniqueId)
}
default:
break
}
}
}
In SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity("com.yourapp.viewNote") { activity in
handleNoteActivity(activity)
}
.onContinueUserActivity(CSSearchableItemActionType) { activity in
handleSpotlightActivity(activity)
}
}
}
func handleNoteActivity(_ activity: NSUserActivity) {
guard let noteId = activity.userInfo?["noteId"] as? String else { return }
// Navigate to note
router.navigateTo(.note(id: noteId))
}
func handleSpotlightActivity(_ activity: NSUserActivity) {
guard let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return }
router.navigateTo(.note(id: uniqueId))
}
}
Query Indexing Status
Check Index Status
func checkIndexStatus() async throws {
let index = CSSearchableIndex.default()
// Check if indexing is enabled
let status = try await index.fetchLastClientState()
print("Last indexed: \(status)")
}
Client State for Incremental Updates
class IndexManager {
func performIncrementalUpdate() async throws {
let index = CSSearchableIndex.default()
// Get last sync state
let lastState = try await index.fetchLastClientState()
// Fetch changes since last state
let changes = try await fetchChangesSince(lastState)
// Index new/modified items
let newItems = changes.added + changes.modified
if !newItems.isEmpty {
let searchableItems = newItems.map { createSearchableItem(for: $0) }
try await index.indexSearchableItems(searchableItems)
}
// Delete removed items
if !changes.deleted.isEmpty {
try await index.deleteSearchableItems(withIdentifiers: changes.deleted)
}
// Save new state
let newState = createCurrentState()
try await index.beginBatch()
try await index.endBatch(withClientState: newState)
}
}
Spotlight Delegate
Index Maintenance
import CoreSpotlight
class SpotlightDelegate: NSObject, CSSearchableIndexDelegate {
func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexAllSearchableItemsWithAcknowledgementHandler acknowledgementHandler: @escaping () -> Void
) {
// System requested full reindex
Task {
try? await reindexAllContent()
acknowledgementHandler()
}
}
func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexSearchableItemsWithIdentifiers identifiers: [String],
acknowledgementHandler: @escaping () -> Void
) {
// System requested reindex of specific items
Task {
try? await reindexItems(withIds: identifiers)
acknowledgementHandler()
}
}
func data(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String) throws -> Data {
// Provide data for a searchable item (e.g., for preview)
guard let note = NoteManager.shared.find(id: itemIdentifier) else {
throw IndexError.itemNotFound
}
return note.content.data(using: .utf8) ?? Data()
}
func fileURL(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String, inPlace: Bool) throws -> URL {
// Provide file URL for item
throw IndexError.notSupported
}
}
Registering Delegate
@main
struct MyApp: App {
let spotlightDelegate = SpotlightDelegate()
init() {
CSSearchableIndex.default().indexDelegate = spotlightDelegate
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Best Practices
1. Index on Data Changes
class NoteManager {
func save(_ note: Note) async throws {
try await database.save(note)
// Index immediately after save
try? await SpotlightIndexer.shared.index(note)
}
func delete(_ note: Note) async throws {
// Remove from index first
try? await SpotlightIndexer.shared.removeFromIndex(note.id)
try await database.delete(note)
}
}
2. Set Appropriate Expiration
// Short-lived content
item.expirationDate = Date().addingTimeInterval(7 * 24 * 60 * 60) // 7 days
// Long-lived content
item.expirationDate = Date().addingTimeInterval(365 * 24 * 60 * 60) // 1 year
// Never expire
item.expirationDate = nil
3. Use Domain Identifiers
// Group related content
CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes", // Easy bulk operations
attributeSet: attributes
)
// Delete all notes at once
try await index.deleteSearchableItems(withDomainIdentifiers: ["com.yourapp.notes"])
4. Provide Rich Metadata
// Include all relevant attributes
attributes.title = note.title
attributes.contentDescription = note.content
attributes.keywords = note.tags
attributes.lastUsedDate = note.lastViewedAt
attributes.thumbnailData = note.thumbnail
// Better search relevance
5. Handle Edge Cases
func safeIndex(_ note: Note) async {
do {
try await indexNote(note)
} catch {
// Log but don't crash
logger.error("Failed to index note: \(error)")
}
}
6. Test with Spotlight
// In simulator/device:
// 1. Index content
// 2. Pull down on home screen
// 3. Search for indexed content
// 4. Tap result to verify deep linking
Official Resources