Alex Brie

I am Alexandru Brie - iOS developer / solopreneur. This is my real blog.

Logo

Blog

Tech (english) 29 Oct 2018

19 April 2018

iOS dev story - Spotlight on-device search for app contents

by Alex

For the AdoreMe iOS app (the ecommmerce startup I’ve been working since last year), a feature I’ve personally wanted to add for a long time while is integration with Spotlight search of the product categories pages.

Lately I’ve started working on it, and would like to share with you some of the insights I discovered and practices I used:

To keep things as decoupled as possible, I created a separate class SpotlightHelper where I placed all search-related functionality.

import MobileCoreServices
import CoreSpotlight

@objc class SpotlightHelper: NSObject {
  @objc static let CATEGORY_SEARCH_DOMAIN = "com.adoreme.category.search"

The public API consists of 2 main methods:

attemptReindexCategories

Checks if spotlight indexing is allowed (by my homegrown feature flag and a/b test utility class), if we need reindexing (the current condition is to index categories only once / month, with the last indexed date saved on device in UserDefaults), then calls the async method that retrieves the categories list and eventually starts the indexing.

  @objc public func attemptReindexCategories() {
      guard FeatureGate.isActive(FeatureGate.SPOTLIGHT_INDEX) else {return}
      if checkAndSetIfNeedsReindexing() {
        //... call the method responsible for getting the categories list
        // loadCategoriesArray(completion: {
          // let validCategoriesList = ...
            self.indexSearchableItemsFor(categories: validCategoriesList)
        // })
      }
  }

tryOpenActivity

This is a class method called from AppDelegate in the application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) method. It just checks if the current activity results from a tap on a spotlight search query result and opens the corresponding category screen (in my case, using my deeplink opening engine).

  @objc public class func tryOpenActivity(userActivity: NSUserActivity) -> Bool {
       if userActivity.activityType == CSSearchableItemActionType {
           if let categoryId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
               let link = LinkBuilderCustomScheme.buildCategoryLink(forCategoryId: categoryId)
               UniversalLinkManager.shared.processLink(link, source: "deviceSearch")
           }
           return true
       }
      return false
   }

Now for the actual functionality

I wanted each index category to have a nice thumbnail image, which was loaded from our backend server.

The tricky part was to only add those categories to the index only after the thumbnail image was downloaded. To achieve this, I’m using a global array where, once my async download of the image is complete, I add the CSSearchableItem for the category. When I’m done downloading the thumbnails for all categories, I call the CSSearchableIndex.default().indexSearchableItems.

    func indexSearchableItemsFor(categories: [CategoryModel]){
      for category in categories {
        let isIndexableCategory = self.downloadRemoteDataAndPrepareSearchItem(category: category)
        if isIndexableCategory {
          self.indexableItemsCount = self.indexableItemsCount + 1
        }
      }
    }

    private func downloadRemoteDataAndPrepareSearchItem(category: CategoryModel) -> Bool {
        guard let imageURL = URL(string: category.remoteImageURL) else {
            // skip category from downloading and indexing
            return false
        }

        let searchItem = buildSearchItemFor(category: category)

        // returns true if we've launched an async operation for getting the category's image
        URLSession.shared.dataTask(with: imageURL) { data, response, error in
            if let data = data {
                searchItem.attributeSet.thumbnailData = data
            }

            self.indexableItems.append(searchItem)

            if self.indexableItemsCount == self.indexableItems.count {
                // has finished downloading images, let's index
                CSSearchableIndex.default().indexSearchableItems(self.indexableItems.flatMap({$0})) { (error) in
                    print("finished indexing ; error? \(error?.localizedDescription)")
                }
            }
        }.resume()
        return true
    }


    private func buildSearchItemFor(category: CategoryModel) -> CSSearchableItem {
        let attributeSet = CSSearchableItemAttributeSet( itemContentType: kUTTypeItem as String)
        attributeSet.title = category.name
        attributeSet.keywords = category.name.components(separatedBy: CharacterSet.whitespacesAndNewlines)
        attributeSet.contentDescription = category.metaDescription
        let searchableItem = CSSearchableItem(uniqueIdentifier: category.category_id, domainIdentifier: SpotlightHelper.CATEGORY_SEARCH_DOMAIN, attributeSet: attributeSet)
        return searchableItem
    }

Issues handled

Possible issues (areas to improve)

I hope that my real-life example will give some of you you ideas on implementing Spotlight search for your apps as well.

tags: