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 CSSearchableItem
objects 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)!
Happy spotlighting!