iOS Generic Search

Create a reusable search utility using generics and keypaths.

Daniel Sedam
The Startup

--

In this tutorial we are going to implement a search that returns search results that will be used as filters. It can be very useful when narrowing or filtering down a list of content by applying multiple filters (chips). This is a common pattern seen when implementing Search. …

Let’s dive right in…

The idea is to show a list of filters when the user starts typing in the search bar. A filter will consist of some matching text and a category.

When the user taps on a search result we will create another chip as a filter. We will keep track of these with a SearchResult. A SearchResult contains the information we need later when applying them as filters to our list.

protocol SearchResult {    var searchText: String { get set }   
var
matchingText: String { get set }
var keyPath: AnyKeyPath { get set }
var categoryName: String { get }
init(searchText: String,
matchingText: String,
keyPath: AnyKeyPath)
}

One little annoying thing is we have to wrap our SearchResult with another protocol. Otherwise later we get this compile-time error when we go to implement our concrete search:

Protocol ‘SearchResult’ can only be used as a generic constraint because it has Self or associated type requirements

protocol UniqueSearchResult: Hashable, SearchResult { }

Lets back our Generic Search with a protocol. This makes testing easier.

protocol SearchDefinition {    func linearSearch<Content, SearchResult: UniqueSearchResult     
(content: [Content],
searchString: String,
keyPaths: [AnyKeyPath],
resultType: SearchResult.Type,
completion: @escaping (_ results: [SearchResult]) -> Void)
}

Here is the implementation of our SearchDefinition. We pass in an array of Content we want to be searched, the search string, the properties we want to search on that Content, and the result type.

struct GenericSearch: SearchDefinition {    func linearSearch<Content, SearchResult: UniqueSearchResult>.    
(content: [Content],
searchString: String,
keyPaths: [AnyKeyPath],
resultType: SearchResult.Type,
completion: @escaping ([SearchResult]) -> Void) {
guard searchString.count > 0 else {
completion([])
return
}
let searchStringLowercased = searchString.lowercased() var results: Set<SearchResult> = Set<SearchResult>() content.forEach { itemToSearch in keyPaths.forEach { prop in if let itemToSearch = itemToSearch[keyPath: prop]
as? String, itemToSearch.lowercased().contains(searchStringLowercased) {
let result = SearchResult.init(searchText:
searchString, matchingText: itemToSearch,
keyPath: prop)
results.insert(result)
}
}
}
var resultsArray = Array(results) resultsArray.sort {
guard let first =
$0.matchingText.lowercased().range(of:
searchStringLowercased)?.lowerBound, let second =
$1.matchingText.lowercased().range(of:
searchStringLowercased)?.lowerBound else {
return true
}
return first < second
}
completion(resultsArray)
}
}

Now that we have our Generic Search we can create specific searches that specify the type of Content we want to perform the search on and the properties to search on that Content.

In this example we will use a list of Items that you can purchase:

struct Item {
var name: String
var category: Category
var price: Double
var stores: [Store]
}
struct Store {
var name: String
var categories: [Category]
}
enum Category: String {
case toys
case clothes
case electronics
case housing
case jewelry
}

Next we will wrap our generic search with a concrete search class. This will define our result type as well as all the keypaths we want to search within our content.

protocol SearchService: class {    func search<Content>(content: [Content], 
with string: String,
completion: @escaping (_ results:
[SearchResult]) -> Void)
}class ItemSearch: SearchService { private lazy var search: GenericSearch = {
GenericSearch()
}()
func search<Content>(content: [Content],
with string: String,
completion: @escaping (_ results:
[SearchResult]) -> Void) {
search.linearSearch(content: content,
searchString: string,
keyPaths: [\Item.name,
\Item.category.rawValue],
resultType: ItemSearchResult.self) {
results in
completion(results)
}
}
}
struct ItemSearchResult: UniqueSearchResult { var searchText: String
var matchingText: String
var keyPath: AnyKeyPath
var categoryName: String {
switch keyPath {
case \Item.name:
return "Item"
case \Item.category.rawValue:
return "Category"
default:
return ""
}
}
init(searchText: String,
matchingText: String,
keyPath: AnyKeyPath) {
self.searchText = searchText
self.matchingText = matchingText
self.keyPath = keyPath
}
}

Now let’s use our Search:

class SearchViewController: UITableViewController {    private var searchController: UISearchController!

private let search = ItemSearch()
weak var dataSource: SearchDataSource? weak var delegate: SearchResultsDelegate? ...}extension SearchViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { guard let searchString = searchController.searchBar.text,
let dataSource = self.dataSource else { return }
self.search.search(content: dataSource.items,
with: searchString,
completion: { [weak self] results in
/// Update Chips and apply filters aka results
self
.delegate.reload(searchResults: results)
})
}
}

This all works great! If and only if the properties we are including in our keyPaths are Strings. If we want to search inside an array or another type that contains an array we need to do a little more work.

Lets update our SearchResult by replacing our KeyPath with a recursive enum:

indirect enum SearchPath {    case path(currentLevel: AnyKeyPath, nestedLevel: SearchPath?)    var currentLevel: AnyKeyPath {
switch self {
case .path(let currentLevel, _):
return currentLevel
}
}
var nestedLevel: SearchPath? {
switch self {
case .path(_, let nestedLevel):
return nestedLevel
}
}
}
extension SearchPath: Hashable {
static func == (lhs: SearchPath, rhs: SearchPath) -> Bool {
return lhs.currentLevel == rhs.currentLevel
&& lhs.nestedLevel == rhs.nestedLevel
}
}
protocol SearchResult { var searchText: String { get set }
var matchingText: String { get set }
var searchPath: SearchPath { get set }
var categoryName: String { get }
init(searchText: String,
matchingText: String,
searchPath: SearchPath)
}

Above we used an indirect enum which allows us to have nested key paths! Shout out to my co-worker Ben Hakes for suggesting the indirect enum.

Now we can update our Generic Search with our new SearchPath:

protocol SearchDefinition {    func search<Content, SearchResult: UniqueSearchResult>(content: [Content], searchString: String,
searchPaths: [SearchPath],
resultType: SearchResult.Type,
completion: @escaping (_ results: [SearchResult]) -> Void)
}struct GenericSearch: SearchDefinition { func search<Content, SearchResult: UniqueSearchResult>(content: [Content], searchString: String,
searchPaths: [SearchPath],
resultType: SearchResult.Type,
completion: @escaping ([SearchResult]) -> Void) {
if searchString == "" {
completion([])
return
}
let searchStringLowercased = searchString.lowercased() var results: Set<SearchResult> = Set<SearchResult>() content.forEach { itemToSearch in searchPaths.forEach { prop in self.search(itemToSearch: itemToSearch,
searchString: searchString,
originalSearchPath: prop,
searchPath: prop,
results: &results)
}
}
var resultsArray = Array(results)
resultsArray.sort {
guard let first =
$0.matchingText.lowercased().range(of:
searchStringLowercased)?.lowerBound,
let second =
$1.matchingText.lowercased().range(of:
searchStringLowercased)?.lowerBound else {
return true
}
return first < second
}
completion(resultsArray)
}
private func search<Content, SearchResult: UniqueSearchResult>
(itemToSearch: Content,
searchString: String,
originalSearchPath: SearchPath,
searchPath: SearchPath,
results: inout Set<SearchResult>) {
let searchStringLowercased = searchString.lowercased() guard let nestedLevel = searchPath.nestedLevel else {
if let itemToSearch = itemToSearch[keyPath:
searchPath.currentLevel] as? String,
itemToSearch.lowercased().contains(searchStringLowercased){
let result = SearchResult.init(searchText:
searchString,
matchingText: itemToSearch,
searchPath: originalSearchPath)
results.insert(result)
}
return
}
guard let nextItems = itemToSearch[keyPath:
searchPath.currentLevel] as? [Any] else { return }
nextItems.forEach { nextItemToSearch in
return
search(itemToSearch: nextItemToSearch,
searchString: searchString,
originalSearchPath: originalSearchPath,
searchPath: nestedLevel,
results: &results)
}
}
}

We will need new filter logic to handle the nested SearchPaths when applying our results (chips):

protocol FilterService: class {    func apply<Content>(filter: SearchResult, 
to content: [Content]) -> [Content]
}
class TextFilter: FilterService { typealias SearchFilter = SearchResult func apply<Content>(filter: SearchFilter,
to content: [Content]) -> [Content] {
let filteredContent = content.filter { item in
return
applyFilter(path: filter.searchPath,
value: filter.matchingText.lowercased(),
itemToSearch: item)
}
return filteredContent
}
private func applyFilter<Content>(path: SearchPath,
value: String,
itemToSearch: Content) -> Bool {
guard let nestedLevel = path.nestedLevel else {
if let itemToSearch = itemToSearch[keyPath:
path.currentLevel] as? String,
itemToSearch.lowercased() == value {
return true
}
return false
}
guard let nextItems = itemToSearch[keyPath:
path.currentLevel] as? [Any] else { return false }
return nextItems.contains { nextItemToSearch -> Bool in
return
applyFilter(path: nestedLevel,
value: value,
itemToSearch: nextItemToSearch)
}
}
}

Now we handle nested structures like ‘Item’ above. We have everything we need to search through stores. We can update our ItemSearch to the following:

class ItemSearch: SearchService {    private lazy var search: GenericSearch = {
GenericSearch()
}()
func search<Content>(content: [Content],
with string: String,
completion: @escaping (_ results:
[SearchResult]) -> Void) {
search.linearSearch(content: content,
searchString: string,
keyPaths: [.path(currentLevel: \Item.name,
nestedLevel: nil),
.path(currentLevel:
\Item.category.rawValue,
nestedLevel: nil),
.path(currentLevel: \Item.stores,
nestedLevel:
.path(currentLevel: \Store.name,
nestedLevel: nil))],
resultType: ItemSearchResult.self) { results in
completion(results)
}
}
}

As you can see in the above example we can access collections via our new SearchPath enum:

.path(currentLevel: AnyKeyPath, nestedLevel: SearchPath?)

And that's it! We now have a generic, reusable Search that can be used to create multiple filters on a list. Simply wrap the Generic Search with your own concrete struct or class and define the keypaths and SearchResult for your data. Please feel free to take this and modify it to fit your needs in your next project.

--

--