Scope of this tutorial

This tutorial will teach you how to implement a simple instant search refreshing the whole view “as you type” by using Algolia’s engine and the Swift API Client.

Note that as of July 2017, we have released a library to easily build instant search result pages with Algolia, called InstantSearch iOS. We highly recommend that you use InstantSearch iOS to build your search interfaces. You can get started with either the Getting started guide, the InstantSearch repo or Example apps.

Let’s see how it goes with a simple use-case: searching for movies.

Covered subjects include:

  • results as you type
  • highlighting the matched words
  • infinite scrolling

Records

For this tutorial, we will use an index of movies but you could use any of your data. To import your own data into an Algolia index, please refer to our import guide.

Here is an extract of what our records look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[
  {
    "title": "The Shawshank Redemption",
    "year": 1994,
    "image": "https://image.tmdb.org/t/p/w154/9O7gLzmreU0nGkIB6K3BsJbzvNv.jpg",
    "color": "#8C634B",
    "score": 9.97764206054169,
    "rating": 5,
    "genre": [
      "Drama",
      "Crime"
    ],
    "objectID": "439817390"
  },
  {
    "title": "Guardians of the Galaxy",
    "year": 2014,
    "image": "https://image.tmdb.org/t/p/w154/9gm3lL8JMTTmc3W4BmNMCuRLdL8.jpg",
    "color": "#3D404C",
    "score": 9.965674684060334,
    "rating": 5,
    "genre": [
      "Science Fiction",
      "Fantasy",
      "Adventure"
    ],
    "objectID": "439486910"
  }
]

Initialization

The whole application has been built in Swift with Xcode.

New project

The first thing we need to do is to create a new Swift project: iOS > Application > Single View Application.

Dependencies

The following code depends on the following third-party libraries:

You can install all the dependencies with CocoaPods. Add the following lines in a Podfile file:

1
2
3
use_frameworks!
pod 'AlgoliaSearch-Client-Swift', '~> 3.3'
pod 'AFNetworking', '~> 2.0'

And then type pod install in your command line and open the generated Xcode Workspace. For the UILabel extension, just drag and drop the file in your project.

UI

Storyboard

Let’s start building the UI. In the Storyboard, we remove the basic UI generated by Xcode and we drag-n-drop a Navigation Controller.

Then, we set the Style attribute of the Table View Cell to Right Detail and Identifier to movieCell.

Since iOS 8, UISearchDisplayController is deprecated and you should now use UISearchController. Unfortunately, at the time of writing, Interface Builder is not able to create the new UISearchController so we must create it in code.

Let’s remove ViewController.swift and create a new subclass of UITableViewController: File > New File > iOS > Source > Cocoa Touch Class.

In the new file that we have just created, we add the search bar initialization in the viewDidLoad method and a new property searchController:

1
2
3
4
5
class MoviesTableViewController: UITableViewController, UISearchBarDelegate, UISearchResultsUpdating {
    func updateSearchResultsForSearchController(searchController: UISearchController) {
        // Search code will go here
    }
}

Then, we have to implement two protocols: UISearchBarDelegate and UISearchResultsUpdating. Let’s add them:

1
2
3
4
5
@implementation MovieTableViewController
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
    // Search code will go here.
}
@end

We have two more things to do in the Storyboard:

  • select the Navigation Controller and check the attribute Is the initial View Controller,
  • and set the Custom Class of the table view to our subclass of UITableViewController.

We can launch the application and see the result.

Search logic

Movie model

Let’s first create a model that has the same structure as our records. In our case, we create MovieRecord in a new file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Foundation
// NOTE: For ease of implementation, our data class will merely wrap a JSON object, providing typed accessors on top of it.
struct MovieRecord {
    private let json: [String: AnyObject]

    init(json: [String: AnyObject]) {
        self.json = json
    }
    var
    var imageUrl: NSURL? {
        guard let urlString = json["image"] as? String else { return nil }
        return NSURL(string: urlString)
    }
    var title_highlighted: String? { return SearchResults.getHighlightResult(json, path: "title")?.value }
    var rating: Int? { return json["rating"] as? Int }
    var year: Int? { return json["year"] as? Int }
}

Search movies

In the viewDidLoad method, we initialize the Algolia Search API Client. Don’t forget to add import AlgoliaSearch at the beginning of the file.

1
2
3
4
5
6
7
8
9
10
11
var movieIndex: Index!
let query = Query()
override func viewDidLoad() {
    [...]
    // Algolia Search
    let apiClient = Client(appID: "YOUR_APP_ID", apiKey: "YOUR_SEARCH_ONLY_API_KEY")
    movieIndex = apiClient.getIndex("YourIndexName")
    query.hitsPerPage = 15
    query.attributesToRetrieve = ["title", "image", "rating", "year"]
    query.attributesToHighlight = ["title"]
}

We will store the results of the search query in an array.

1
var movies = [MovieRecord]()

All the logic goes inside the updateSearchResultsForSearchController method. We use an closure to asynchronously process the results once the API returns the matching hits. Inside the closure, we need to check that the result is newer than the result currently displayed because we cannot ensure the ordering of the network calls/answers. We transform the resulting JSON hits into our movie model. Don’t forget to add import SwiftyJSON too.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var searchId = 0
var displayedSearchId = -1
var loadedPage: UInt = 0
var nbPages: UInt = 0
[...]
func updateSearchResultsForSearchController(searchController: UISearchController) {
    query.query = searchController.searchBar.text
    let curSearchId = searchId
    movieIndex.search(query, block: { (data, error) -> Void in
        if (curSearchId <= self.displayedSearchId) || (error != nil) {
            return // Newest query already displayed or error
        }
        self.displayedSearchId = curSearchId
        self.loadedPage = 0 // Reset loaded page
        // Decode JSON
        guard let hits = content!["hits"] as? [[String: AnyObject]] else { return }
        guard let nbPages = content!["nbPages"] as? UInt else { return }
        self.nbPages = nbPages
       &nbsp;var tmp = [MovieRecord]()
        for hit in hits {
            tmp.append(MovieRecord(json: hit))
        }
        // Reload view with the new data
        self.movies = tmp
        self.tableView.reloadData()
    })
    ++self.searchId
}

Display the matching movies

At this point, we have the result of the query saved in the movies array but we don’t display anything to the user yet. We can now implement the method of UITableViewController so the controller will update the view.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let placeholder = UIImage(named: "white")
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return movies.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier("movieCell", forIndexPath: indexPath) as! UITableViewCell
    // Configure the cell...
    let movie = movies[indexPath.row]
    cell.textLabel?.highlightedTextColor = UIColor(red:1, green:1, blue:0.898, alpha:1)
    cell.textLabel?.highlightedText = movie.title
    cell.detailTextLabel?.text = "\(movie.year)"
    // Avoid loading image that we don't need anymore
    cell.imageView?.cancelImageRequestOperation()
    // Load the image and display another image during the loading
    cell.imageView?.setImageWithURL(NSURL(string: movie.image), placeholderImage: placeholder)
    return cell
}

Add import AFNetworking at the beginning of the file so we can use AFNetworking to asynchronously load the images from their URL, embed it inside the UIImageView and cache it to avoid further reloading.

Infinite scroll

As you can see, we currently load 15 results per search. We now have to implement another method that will load the next page of the displayed query.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func loadMore() {
    if loadedPage + 1 >= nbPages {
        return // All pages already loaded
    }
    let nextQuery = Query(copy: query)
    nextQuery.page = loadedPage + 1
    movieIndex.search(nextQuery, block: { (data , error) -> Void in
        if (nextQuery.query != self.query.query) || (error != nil) {
            return // Query has changed
        }
        self.loadedPage = nextQuery.page
        let json = JSON(data!)
        let hits: [JSON] = json["hits"].arrayValue
        var tmp = [MovieRecord]()
        for record in hits {
            tmp.append(MovieRecord(json: record))
        }
        // Display the new loaded page
        self.movies.extend(tmp)
        self.tableView.reloadData()
    })
}

We should call this method inside tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath), just before the return.

1
2
3
4
5
6
7
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    // [...]
    if (indexPath.row + 5) >= movies.count {
        loadMore()
    }
    return cell
}

See it in action

Check the source code on GitHub.

Filtering your results

There are multiple ways to filter your results using Algolia. You can filter by date, by numerical value and by tag. You can also use facets, which are a filter with the added benefits of being able to retrieve and display the values to filter by.

Filtering

There are several ways to filter a result set, depending on the attribute you want to filter by:

Filter by Numerical Value

Numerical Values Indexing

Algolia supports indexing of numerical values (integers, doubles and boolean). This can be used for searching for products in a given price range for example.

To enable it, you need to have objects with numerical attributes (ensure your numerical values are not encoded as strings, for boolean we transform false as 0 and true as 1).

Considering the following object with its price attribute:

1
2
3
4
5
6
7
8
9
10
11
12
[
  {
    "title": "Apple MacBook Pro 15.4-Inch Laptop with Retina Display",
    "price": 2594,
    "url": "http://www.amazon.com/Apple-MacBook-ME294LL-15-4-Inch-Display/dp/B0096VD85I"
  },
  {
    "title": "Apple iPhone 5S - 32GB",
    "price": 969.99,
    "url": "http://www.amazon.com/Apple-iPhone-5s-32GB-Space/dp/B00F3J4KYA"
  }
]

You can search with numeric conditions on the price. We support six operators: <, <=, =, >, >= and !=.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// search only with a numeric filter
var query = Query()
query.filters = "price>1000"
index.search(query) { (content, error) in
    guard let content = content else {
        print("Error: \(error)")
        return
    }
    print("Results: \(content)")
}
// search by query string and numeric filter
query = Query()
query.query = "los"
query.filters = "price>1000"
index.search(query) { (content, error) in
    // [...]
}

You can also mix OR and AND operators. The OR operator is defined with a parenthesis syntax (warning: != cannot be ORed). For example (code=1 AND (price:[0-100] OR price:[1000-2000])) translates in:

1
2
3
4
5
6
7
// search by query string and complex numerical conditions
let query = Query()
query.query = "los"
query.filters = "code=1 AND (price:1000 TO 3000 OR price:10 TO 100)"
index.search(query) { (content, error) in
    // [...]
}

Filter by date

By default the engine doesn’t interpret strings following the ISO date format. To enable search by date, you must convert your dates into numeric values. We recommend using a UNIX timestamp as illustrated by the following record:

1
2
3
4
5
6
{
  "objectID": "myID1",
  "name": "Jimmy",
  "company": "Paint Inc.",
  "date": 1362873600
}

Note: In the case you need to index and filter on dates previous to the 1st of January 1970, you can convert the date following a rule like: year * 10000 + month * 100 + day. The 30th of March 1964 will then become 19640330.

1
2
3
4
5
6
// search by date between 2013-03-10 & 2013-04-20
let query = Query()
query.filters = "date>=1362873600 AND date<=1366416000"
index.search(query) { (content, error) in
    // [...]
}

Filter by tag

Algolia supports indexing of categories (tags) that you can use when searching for a specific kind of objects.

To enable it, you need to index objects with a _tags attribute that contains the list of their categories (you can also use faceting, tags is just a simplified version of faceting).

Here is an example indexing products with tags:

1
2
3
4
5
6
7
8
9
10
11
12
[
  {
    "title": "Apple MacBook Pro 15.4-Inch Laptop with Retina Display",
    "url": "http://www.amazon.com/Apple-MacBook-ME294LL-15-4-Inch-Display/dp/B0096VD85I",
    "_tags": ["laptop", "computer", "retina"]
  },
  {
    "title": "Apple iPhone 5S - 32GB",
    "url": "http://www.amazon.com/Apple-iPhone-5s-32GB-Space/dp/B00F3J4KYA",
    "_tags": ["phone", "smartphone", "retina"]
  }
]

You can then easily search for one or multiple tags, using our filters feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
// search only by tags
var query = Query()
query.filters = "smartphone AND retina"
index.search(query) { (content, error) in
    // [...]
}
// search by query string and tags
query = Query()
query.query = "appl"
query.filters = "smartphone AND retina"
index.search(query) { (content, error) in
    // [...]
}

You can also mix OR and AND operators. The OR operator is defined with a parenthesis syntax. For example (retina AND (laptop OR smartphone)) translates in:

1
2
3
4
5
6
7
// search by query string and tags
let query = Query()
query.query = "appl"
query.filters = "retina AND (smartphone OR laptop)"
index.search(query) { (content, error) in
    // [...]
}

Negations are also supported via the NOT operator, prefixing the value. For example (retina AND NOT(smartphone)) translates in:

1
2
3
4
5
6
7
// search by query string and tags
let query = Query()
query.query = "appl"
query.filters = "retina AND NOT smartphone"
index.search(query) { (content, error) in
    // [...]
}

Filter by facet

You can also filter your result set using facets. We’ll have a look at facets in the next section.

Faceting

Algolia supports faceting and faceted search (or filtering, navigation). To enable it, you need to set the list of attributes on which you want to enable faceting in the index settings. The attributes can be either numerical or string values.

Here is an example of a book listing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[
  {
    "title": "The Hitchhiker's Guide to the Galaxy",
    "authors": "Adams Douglas",
    "type": "Literature & Fiction",
    "url": "http://www.amazon.com/Hitchhikers-Guide-Galaxy-Douglas-Adams/dp/0345391802"
  },
  {
    "title": "Remote: Office Not Required",
    "authors": [
      "Jason Fried",
      "David Heinemeier Hansson"
    ],
    "type": "Business & Investing",
    "url": "http://www.amazon.com/Remote-Office-Required-Jason-Fried/dp/0804137501"
  },
  {
    "title": "Rework",
    "authors": [
      "Jason Fried",
      "David Heinemeier Hansson"
    ],
    "type": "Business & Investing",
    "url": "http://www.amazon.com/Rework-Jason-Fried/dp/0307463745"
  }
]

You can enable faceting on the authors and type attributes with the following code:

1
$index->setSettings(array("attributesForFaceting" => array("authors", "type")));

Filtering / Navigation

You can implement faceted navigation (or facet filtering) by specifying the list of facet values you want to use as refinements using the filters query parameter:

1
2
3
4
5
6
7
8
// filter on author=Adams Douglas AND type=Literature & Fiction
var query = Query()
query.query = "appl"
query.facets = ["*"]
query.filters = "authors:\"Adams Douglas\" AND type:\"Literature & Fiction\""
index.search(query) { (content, error) in
    // [...]
}

Do not forget to configure your attributesForFaceting index setting with the list of attributes of want to facet on, otherwise you’ll not be able to use it at query-time.

Refinements are ANDed by default (Conjunctive selection).

You can get facet counts in the facets attribute of the JSON answer. These might be approximated (linear approximation) based on the size of the index. You can check if they are by using the exhaustiveFacetsCount of the JSON answer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "hits": ["/* many hits */"],
  "page": 0,
  "nbHits": 2,
  "nbPages": 1,
  "hitsPerPage": 20,
  "processingTimeMS": 1,
  "query": "appl",
  "params": "query=appl&facets=*",
  "facets": {
    "authors": {
      "Jason Fried": 2,
      "David Heinemeier Hansson": 1,
      "Adams Douglas": 1
    },
    "type": {
      "Literature & Fiction": 1,
      "Business & Investing": 2
    }
  },
  "exhaustiveFacetsCount": true
}

To OR refinements, you must use nested arrays. For example, to refine on “Business & Investing” books written by Jason Fried or David Heinemeier Hansson:

1
2
3
4
5
6
7
let query = Query()
query.query = "appl"
query.facets = ["*"]
query.filters = "authors:\"Jason Fried\" OR authors:\"David Heinemeier Hansson\" AND type:\"Business & Investing\""
index.search(query) { (content, error) in
    // [...]
}

Negations are also supported via the NOT operator, prefixing the facet value. For example to refine on “Business & Investing” book written by Jason Fried and not David Heinemeir Hanssan:

1
2
3
4
5
6
7
var query = Query()
query.query = "appl"
query.facets = ["*"]
query.filters = "authors:\"Jason Fried\" AND NOT authors:\"David Heinemeier Hansson\" AND type:\"Business & Investing\""
index.search(query) { (content, error) in
    // [...]
}

Disjunctive Faceting

The most common use case for faceted search or navigation is to select at most one value per facet, but there are at least two ways from which a user might select multiple values from the same facet:

  • Conjunctive “AND” selection (standard Navigation, described above)

  • Disjunctive “OR” selection. Selecting hotel ratings (e.g., hotels with 4 OR 5 stars) may be a kind of disjunctive selection. Checkboxes are usually used to represent such navigation capabilities.

We’ve implemented a method to help you generate such pages:

1
2
3
4
5
6
7
8
9
let query = Query(query: "luxury")
query.facets = ["facilities", "stars"]
let refinements = [
    "facilities": ["wifi"],
    "stars": ["4", "5"]
]
index.searchDisjunctiveFaceting(query, disjunctiveFacets: ["stars"], refinements: refinements) { (content, error) in
    // [...]
}

The results of a disjunctive faceting search are the same as those of a regular search, except that they contain an additional disjunctiveFacets top-level attribute listing counts for disjunctive facets:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "hits": ["/* many hits */"],
  "facets": {
    "facilities": {
      "wifi": 13,
      "swimming pool": 5
    }
  },
  "disjunctiveFacets": {
    "stars": {
       "1": 6,
       "2": 17,
       "3": 24,
       "4": 8,
       "5": 5
    }
  }
}

Disjunctive faceting results in querying several times the index:

  • a query is performed to display the result set ANDing refined conjunctive facets and ORing refined disjunctive facets.

  • a query used to display each disjunctive facet (with the associated number of hits that would be added to the result set if selected) ANDing the refined conjunctive facets.

For example, if a user is looking for a hotel matching the full-text query luxury with 4 OR 5 stars AND with a wifi facility, the following queries must be performed:

  • To display the result set and the conjunctive facet facilities: index.search("luxury", { facets: "facilities", filters: "facilities:wifi AND (stars:4 OR stars:5)" }).
  • To display the disjunctive facet stars: index.search("luxury", { facets: "stars", filters: "facilities:wifi" }).

Aggregations & Stats

All numerical-based facets returns the associated min, max, avg and sum values. The values are available in the facets_stats attribute of the JSON answer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "hits": ["/* many hits */"],
  "page": 0,
  "nbHits": 2,
  "nbPages": 1,
  "hitsPerPage": 20,
  "processingTimeMS": 1,
  "query": "appl",
  "params": "query=appl&facets=*",
  "facets": {
    "price": {
      "42": 4,
      "12": 3,
      "1": 1
    }
  },
  "exhaustiveFacetsCount": true,
  "facets_stats": {
    "price": {
      "min": 1,
      "max": 42,
      "avg": 25.625,
      "sum": 205
    }
  }
}

Did you find this page helpful?