Apple has been pushing app functionality beyond the confines of the app itself over the years and it is a trend I have been watching with interest, especially this WWDC making App Intents one of the most important framework for developers. Recently, I decided to explore a feature I had previously overlooked: Spotlight.

CoreSpotlight is the framework to work with Spotlight integration that allows you to make content from your app searchable directly from the iOS Spotlight search.

The Use Case

My app, Fusion, is a music app that offers four different types of radio stations from Apple Music:

  • Discovery,
  • Personal,
  • Love, and
  • Heartbreak.

My goal was to make these stations searchable directly from Spotlight, each with its own custom description and keywords. This way, users can quickly find and jump to their favorite stations without even opening the app.

Before I dive into the implementation details, let me give you a roadmap of what I will cover in this post

I will start by creating a secure index for the stations, then define custom attributes for each station type. Then, I create searchable items, update Spotlight index with items, and finally, handle user interactions when they select a station from Spotlight search results. By the end, I hope you have a better understanding of how to make your app's content discoverable through Spotlight.

Creating a Secure Index

At the heart of Spotlight integration is CSSearchableIndex. I think of it as a container where I store all the information I want Spotlight to know about. A mini-database of the app's content that Spotlight can quickly search through.

Since I am dealing with users' personal stations, keeping their music preferences private and secure is important. I started by creating an secure index:

let secureIndex = CSSearchableIndex(name: "FusionIndex", protectionClass: .complete)

The .complete protection class ensures that the index is encrypted and inaccessible when the device is locked.

Defining Station Attributes

Next, I created a structure StationSpotlightAttributes to hold the custom descriptions and keywords for each station type. It allows me to customize how each station type appears in Spotlight search results.

struct StationSpotlightAttributes {
  let contentDescription: String
  let keywords: [String]

  static func getAttributes(for type: StationType) -> Self {
    switch type {
      case .discovery:
        StationSpotlightAttributes(
          contentDescription: "Discover new music tailored to your taste!",
          keywords: ["music", "radio", "station", "discovery", "discover", "new music"]
        )
      case .personal:
        StationSpotlightAttributes(
          contentDescription: "Your personal radio station based on your listening history.",
          keywords: ["music", "radio", "station", "personal", "favorites", "personal station"]
        )
      case .love:
        StationSpotlightAttributes(
          contentDescription: "A curated selection of love songs just for you!",
          keywords: ["music", "radio", "station", "love", "romance", "love station"]
        )
      case .heartbreak:
        StationSpotlightAttributes(
          contentDescription: "Songs to help you through tough times.",
          keywords: ["music", "radio", "station", "heartbreak", "emotional", "heartbreak station"]
        )
    }
  }
}

By defining these attributes separately, I can manage and update them without digging through my main station logic, especially when I add more stations.

Creating Searchable Items

CSSearchableItem are the individual entries that make up the Spotlight index. Each CSSearchableItem represents a piece of content from the app to make it searchable. In Fusion's case, each item represents a radio station.

I extended CSSearchableItem to create these items from my StationForYou objects:

extension CSSearchableItem {
  static func create(from station: StationForYou) -> CSSearchableItem {
    let attributeSet = CSSearchableItemAttributeSet(contentType: .content)
    attributeSet.title = station.station.name

    let attributes = StationSpotlightAttributes.getAttributes(for: station.type)
    attributeSet.contentDescription = attributes.contentDescription
    attributeSet.keywords = attributes.keywords + [station.station.name]

    if let artworkURL = station.station.artwork?.url(width: 300, height: 300) {
      attributeSet.thumbnailURL = artworkURL
    }

    return CSSearchableItem(
      uniqueIdentifier: station.station.id.rawValue,
      domainIdentifier: "com.rudrankriyam.fussion.stations",
      attributeSet: attributeSet
    )
  }
}

Updating and Indexing

Next, we have to index our items to Spotlight index. The indexSearchableItems(_:) method takes the CSSearchableItemobjects and adds them to uesr's Spotlight index. Once indexed, these items become discoverable through Spotlight searches.

It handles both new items and updates to existing ones. If I index an item with the same unique identifier as an existing one, it updates the existing entry instead of creating a duplicate. Here is the implementation:

class SpotlightIndexer {
  private let secureIndex: CSSearchableIndex

  init() {
    self.secureIndex = CSSearchableIndex(name: "FussionIndex", protectionClass: .complete)
  }

  func updateSpotlightIndex(for stations: [StationForYou]) async throws {
    let searchableItems = stations.map { CSSearchableItem.create(from: $0) }

    try await secureIndex.indexSearchableItems(searchableItems)
  }
}

Integrating with the App

Finally, I integrated the Spotlight indexing into the PersonalStationsViewModel:

class PersonalStationsViewModel: ObservableObject {
  private let spotlightIndexer = SpotlightIndexer()
    
  func updateSpotlightIndex(for stations: [StationForYou]) {
    Task {
      do {
        try await spotlightIndexer.updateSpotlightIndex(for: stations)
      } catch {
        print("Error updating Spotlight index: \(error)")
      }
  }
}

Handling Spotlight Item Selection

When handling the selection of a Spotlight item, I can directly use the uniqueIdentifier from the user activity. We have CSSearchableItemActionType activity type for it. By listening for it, the app can handle the action when a user selects one of the indexed items.

In Fusion's case, when a user taps on a station in Spotlight results, the app launches and immediately starts playing that station. Here is how I handled in a SwiftUI lifecycle app that uses the App protocol:

import CoreSpotlight
import MusadoraKit

@main
struct FussionApp: App {
  var body: some Scene {
    WindowGroup {
      FusionTabView()
        .onContinueUserActivity(CSSearchableItemActionType, perform: handleSpotlightActivity)
    }
  }

  func handleSpotlightActivity(_ userActivity: NSUserActivity) {
    guard let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else {
      print("Failed to get station ID from user activity")
      return
    }

    Task {
      do {
        let station = try await MCatalog.station(id: MusicItemID(uniqueIdentifier))
        try await APlayer.shared.play(station: station)
        print("Playing station: \(station.name)")
      } catch {
        print("Failed to play station: \(error)")
      }
    }
  }
}

Conclusion

Implementing Spotlight search has opened up a new way to discover stations for Fusion. Users can now quickly access their favorite stations directly from Spotlight search!

This is just one of the many ways Apple is encouraging us to extend our app's functionality beyond the app itself. As you work on your own apps, consider how you can use the CoreSpotlight framework to make your app's content more accessible and your users' lives easier.

Have you used Core Spotlight in your apps? I would love to hear about your experiences and any creative ways you have found to implement it! Reach out to me on X (formerly Twitter)!

x.com

Happy spotlighting!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.

Tagged in: