spotlight-discovery
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
More from bluewaves-creations/bluewaves-skills
photographer-testino
Generate images in Mario Testino's glamorous vibrant style. Use when users ask for Testino style, high fashion glamour, bold saturated colors, warm luxurious photography, dynamic sensual energy.
35photographer-lindbergh
Generate images in Peter Lindbergh's iconic black and white style. Use when users ask for Lindbergh style, raw authentic beauty, emotional B&W portraits, supermodel aesthetic, or unretouched natural photography.
30photographer-lachapelle
Generate images in David LaChapelle's surreal pop art style. Use when users ask for LaChapelle style, pop surrealism, hyper-saturated colors, theatrical staging, baroque maximalism, kitsch aesthetic.
24epub-creator
Create production-quality EPUB 3 ebooks from markdown and images with automated QA, formatting fixes, and validation. Use when creating ebooks, converting markdown to EPUB, or compiling chapters into a publishable book. Handles markdown quirks, generates TOC, adds covers, and validates output automatically.
22photographer-vonunwerth
Generate images in Ellen von Unwerth's playful vintage style. Use when users ask for von Unwerth style, playful sensuality, vintage film noir, whimsical feminine photography, retro glamour, narrative storytelling.
19photographer-ritts
Generate images in Herb Ritts' sculptural black and white style. Use when users ask for Ritts style, classical Greek aesthetic, sculptural body photography, California golden hour, minimalist athletic portraits.
18