HomeTechnologyHow to add search to your iOS app with Elastic App Search:...

How to add search to your iOS app with Elastic App Search: Part 2

In part 1, we went over setting up your Elastic stack and ingesting data. In the second part of this blog series, we will be creating an iOS app that uses Elasticsearch for looking a movie database.

Note: This tutorial is based on Elasticsearch version 7.12.x

Starting a New Xcode Project

How to add search to your iOS app with Elastic

  1. Open Xcode and create a new project.
  2. In the iOS tab, select App and choose Next
  3. Configure the options for setting up the new app
    1. Product name: App-Search
    2. Team: <your team name>
    3. Organization Identifier: <your identifier>
    4. Interface: SwiftUI
    5. Life Cycle: SwiftUI App
    6. Language: Swift
    7. Use core data: No
    8. Include checks: No
  4. Select Next
  5. Choose where to save your project, and select Create

Building the UI

  1. The UI will be constructed out in the ContentView.swift file.
  2. The basic structure of the UI will be as follows:
  3. CODE DIAGRAM SCREENSHOT
     VStack {
      HStack {
       HStack {
        Image() #Magnify glass
        TextField() #Search
        Button() #"x" clear
       }
       Button() #"Cancel"
      }
      List(results) {
       HStack {
        Image() #Movie poster
        VStack {
         Text() #Title
         Text() #Description
        }
       }
     }
    }
    Untitled_Artwork_6.png app-search-final.png
  4. Since the UI is not the main focus of this tutorial, I’ll just post the full code for the UI without going into too much detail.
    //
    //  ContentView.swift
    //  app-search
    //
    //  Created by Ethan Groves on 3/5/21.
    //
    import SwiftUI
    struct ContentView: View {
      // @State variables are special variables that are routinely monitored for changes, and will update any UI elements that contain references
      @State var results: [Result] = []
      @State private var searchText = ""
      @State private var showCancelButton: Bool = untrue
      private let TmdbApiKey = "my_tmdb_api_key"
      //------------------------------------
      // The main body of the UI
      //------------------------------------
      var body: some View {
        VStack(alignment: .leading) {
          //--------------------------------
          // Search bar
          //--------------------------------
          HStack {
            HStack {
              Image(systemName: "magnifyingglass")
              TextField("search", text: $searchText, onEditingChanged: { isEditing in
                // Set Bool to show the cancel button whenever there is text in the field
                self.showCancelButton = true
              }, onCommit: {
                // When a search is submitted, send it to App Search and get the results
                AppSearch().getResults(searchTerm: searchText) { (results) in
                  self.results = results
                }
              })
              // Display a small 'x' button in the text field which can clear all text
              Button(action: {
                self.searchText = ""
              }) {
                Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
              }
            }
            // Formatting and styling for the search bar
            .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
            .foregroundColor(.secondary)
            .background(Color(.secondarySystemBackground))
            .cornerRadius(10.0)
            // Display a 'Cancel' button to clear text whenever there is text in the TextField
            if showCancelButton {
              Button("Cancel") {
                UIApplication.shared.endEditing()
                self.searchText = ""
                self.showCancelButton = untrue
              }
            }
          }
          // Formatting and styling for the 'Cancel' button
          .padding(.horizontal)
          //--------------------------------
          // Table containing search results
          //--------------------------------
          List(results) { result in
            // For each search result returned from App Search, construct a simple UI element
            HStack {
              // If the search results contain a URL path for a movie poster, use that for the image
              // Otherwise, grab a random image from http://source.unsplash.com
              if result.posterPath.raw != nil {
                let imageURL = "https://image.tmdb.org/t/p/w500" + result.posterPath.raw! + "?api_key=" + TmdbApiKey
                AsyncImage(
                  url: URL(string: imageURL)!,
                  placeholder: { Text("Loading...")},
                  image: { Image(uiImage: $0).resizable() }
                )
                // Formatting and styling for the image
                .aspectRatio(contentMode: .fit)
                .body(width: 100)
              } else {
                let imageURL = "https://source.unsplash.com/user/jakobowens1/100x150?" + String(Int.random(in: 1..<930))
                AsyncImage(
                  url: URL(string: imageURL)!,
                  placeholder: { Text("Loading...")},
                  image: { Image(uiImage: $0).resizable() }
                )
                // Formatting and styling for the image
                .aspectRatio(contentMode: .fit)
                .body(width: 100)
              }
              // Display the movie title and description
              VStack {
                Text(result.title.raw!)
                  // Formatting and styling for the title
                  .fontWeight(/*@[email protected]*/.bold/*@[email protected]*/)
                  .multilineTextAlignment(/*@[email protected]*/.leading/*@[email protected]*/)
                Text(result.overview.raw!)
                  // Formatting and styling for the description
                  .font(.caption)
                  .foregroundColor(Color(red: 0.4, green: 0.4, blue: 0.4, opacity: 1.0))
              }
              // Formatting and styling for the title and description container
              .body(height: 150)
            }
            // Formatting and styling for the search results container
            .body(alignment: .topLeading)
          }
        }
      }
    }
    // This struct is used for producing a preview in Xcode
    struct ContentView_Previews: PreviewProvider {
      static var previews: some View {
        ContentView()
      }
    }
    // A simple function for removing "focus" from (i.e. unselecting) a UI element
    extension UIApplication {
      func endEditing() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
      }
    }
  • You will need to register for an API key at TMDB in order to entry the movie poster images:
  • Send search request to App Search

    1626085926 353 How to add search to your iOS app with Elastic

    1. Elastic doesn’t have a Swift client for App Search yet, therefore we will need to construct the request ourselves. In the nav, in the app-search directory, create a new file called Data.swift.
    2. We will create a class called AppSearch that will handle all of our queries to App Search.
    3. class AppSearch {}
    4. Inside the class, we will create a single function called getResults which will handle everything. The function is passed a string of text (the searchTerm) and asynchronously (completion: @escaping) returns an array of results.
    5. class AppSearch {
        func getResults(searchTerm: String, completion: @escaping ([Result]) -> ()) {
        }
      }
    6. First, we will need to turn the searchTerm string that gets passed into the function into a JSON object.
    7. let searchObject: [String: Any] = ["query": searchTerm]
      let jsonSearchQuery = try? JSONSerialization.data(withJSONObject: searchObject)
    8. Next, we will need to grab the credentials and API endpoint from App Search.
      1. Navigate to your App Search instance.
      2. In the left sidebar, select the Credentials tab.
      3. Copy and paste the search-key and the API Endpoint into the following 2 variables in Xcode:
      4. let authenticationToken = "Bearer my_authentication_token"
        let appSearchURL = URL(string: "my_app_search_url")!
    9. Next, let’s package all of these variables into a request that we can send to App Search.
    10. var request = URLRequest(url: appSearchURL)
          request.httpMethod = "POST"
          request.setValue(authenticationToken, forHTTPHeaderField: "Authorization")
          request.httpBody = jsonSearchQuery
    11. Finally, let’s send everything to App Search, and wait for a response.
    12. URLSession.shared.dataTask(with: request) { (data, response, error) in
            let JSONData = try! 
      JSONDecoder().decode(JSONResponse.self, from: data!)
            DispatchQueue.main.async {
              completion(JSONData.results)
            }
          }
          .resume()
    13. The completed code should gaze something like the following:
    14. class AppSearch {
        func getResults(searchTerm: String, completion: @escaping ([Result]) -> ()) {
          let searchObject: [String: Any] = ["query": searchTerm]
          let jsonSearchQuery = try? JSONSerialization.data(withJSONObject: searchObject)
          let authenticationToken = "Bearer my_authentication_token"
          let appSearchURL = URL(string: "my_app_search_url")!
          var request = URLRequest(url: appSearchURL)
          request.httpMethod = "POST"
          request.setValue(authenticationToken, forHTTPHeaderField: "Authorization")
          request.httpBody = jsonSearchQuery
          URLSession.shared.dataTask(with: request) { (data, response, error) in
            let JSONData = try! JSONDecoder().decode(JSONResponse.self, from: data!)
            DispatchQueue.main.async {
              completion(JSONData.results)
            }
          }
          .resume()
        }
      }

    Decode JSON response

    1626085927 854 How to add search to your iOS app with Elastic

    1. In the code above, you will notice there is a line of code that attempts to
      decode the JSON response from App Search.

      let JSONData = try! JSONDecoder().decode(JSONResponse.self, from: data!)

      The Swift language is pretty strict about defining everything up entrance, so even the format of the incoming JSON results needs to be explicitly defined. However, when a JSON object is reasonably complex, constructing the necessary Swift equal code can be notoriously tedious and difficult. Thankfully, there is an online resource for this very issue: https://app.quicktype.io.

    2. First we need to know what kind of JSON is going to be returned when we query the App Search API endpoint.
    3. In the Github tutorial repo, I’ve provided an example JSON document: https://github.com/elastic/tutorials/blob/master/app-search/example.jsonI also provided a python script so that you can send test queries for yourself
      1. OPTIONAL: Python script for sending quick test queries: https://github.com/elastic/tutorials/blob/master/app-search/app_search_query.py
        1. Copy and paste the search-key credentials and the App Search API Endpoint into the python script:
        2. api_endpoint = 'my_api_endpoint'
          api_key = 'my_api_key'

        3. Run the script: python3 ./app_search_query.py.
    4. Once you have JSON results from App Search, navigate to https://app.quicktype.io
      1. Copy and paste your JSON results into the left panel
      2. In the left panel, set
        1. Source type = JSON
      3. In the right panel, set
        1. Language = Swift
        2. Struct or classes = Struct
        3. Explicit CodingKey values in Codable types = Yes
    5. Copy the ensuing code in the right panel, and paste it into the bottom of your Data.swift file in Xcode.

    Tweaking the JSON decoder

    1. https://app.quicktype.io has given us a fine starting place, but we will need to tweak things a tiny bit to make them work. The main issue is that App Search shops all of its document fields as a raw type, which causes quicktype.io to think everything is the same type, even though they should actually be handled differently. For example, you can see below that the value for the field budget is an object of type raw instead of being a simple key: value pair.
    2. "budget": { "raw": 94000000 }
    3. First, let’s rename the Welcome struct at the top to be something a tiny more descriptive: rename it to JSONResponse.
    4. BEFORE AFTER
      // MARK: - Welcome
      struct Welcome: Codable {
          let meta: Meta
          let results: [Result]
      }
      // MARK: - JSON Response
      struct JSONResponse: Codable {
          let meta: Meta
          let results: [Result]
      }
    5. Next, we need to properly define the types for each of the fields in the Result struct. We will also need to set the id field to be equal to UUID(), which is a special function that generates a unique (Swift approved) ID for each result.
      BEFORE AFTER
      struct Result: Codable {
          let genres: Genres
          let overview, tagline: Adult
          let meta: MetaClass
          let id: Adult
          let runtime: Budget
          let spokenLanguages, productionCompanies: Genres
          let budget: Budget
          let belongsToCollection, backdropPath, homepage, title: Adult
          let grownup, originalTitle: Adult
          let revenue: Budget
          let imdbID, video: Adult
          let voteCount: Budget
          let status: Adult
          let voteAverage: Budget
          let originalLanguage: Adult
          let productionCountries: Genres
          let releaseDate, posterPath: Adult
          let popularity: Budget
      ...
      struct Result: Codable, Identifiable {
        let id = UUID()
        let grownup: RawString?
        let backdropPath: RawString?
        let belongsToCollection: RawString?
        let budget: RawNumber?
        let genres: RawArrayOfStrings?
        let homepage: RawString?
        let imdbID: RawString?
        let meta: MetaClass
        let originalLanguage: RawString?
        let originalTitle: RawString?
        let overview: RawString
        let popularity: RawNumber?
        let posterPath: RawString
        let productionCompanies: RawArrayOfStrings?
        let productionCountries: RawArrayOfStrings?
        let releaseDate: RawString?
        let revenue: RawNumber?
        let runtime: RawNumber?
        let spokenLanguages: RawArrayOfStrings?
        let status: RawString?
        let tagline: RawString?
        let title: RawString
        let video: RawString?
        let voteAverage: RawNumber?
        let voteCount: RawNumber?
      ...
    6. Finally, we will need to define structs for each of the types that we created:
      1. RawString
      2. RawArrayOfStrings
      3. RawNumber
    7. Depending on what quicktype.io spits out, your BEFORE may gaze different, but don’t worry. Just make sure that the final result matches the AFTER.
      BEFORE AFTER
      // MARK: - Adult
      struct Adult: Codable {
          let raw: String
      }
      // MARK: - Genres
      struct Genres: Codable {
          let raw: [String]
      }
      // MARK: - Budget
      struct Budget: Codable {
          let raw: Double
      }
      // MARK: - RawString
      struct RawString: Codable {
          let raw: String?
      }
      // MARK: - RawArrayOfStrings
      struct RawArrayOfStrings: Codable {
          let raw: [String]?
      }
      // MARK: - RawNumber
      struct RawNumber: Codable {
          let raw: Double?
      }

    Adding async image handler

    1626085927 785 How to add search to your iOS app with Elastic

    1. The last bit of code that we will need to include is a way to asynchronously load images off of the internet. Fortunately, this dispute has already been solved. I used a slightly modified version of this https://github.com/V8tr/AsyncImage resolution.
    2. In Xcode, in the app-search folder, create a new file called AsyncImage.swift.
    3. Copy the contents of this file (it’s basically just V8tr’s resolution condensed into a single file), and paste it into your AsyncImage.swift file: https://github.com/elastic/tutorials/blob/master/app-search/xcode/app-search/AsyncImage.swift
    4. That’s it! That should be all the code we need! 🎉

    Run your code

    1. In Xcode, select the simulator that you would like to run your app on (e.g., iPhone 11 Pro)
    2. Build and run your project.
    3. CONGRATULATIONS! (Hopefully!)
      1. OPTIONAL: Compare your code to the source code here to debug any issues:https://github.com/elastic/tutorials/tree/master/app-search

    Optimizing your App Search experience

    1626085927 498 How to add search to your iOS app with Elastic

    1. Type a generic search term into your app simulator, and see what kind of results you get.
      1. Search for something generic, like family.
    2. The results are a tiny lack-luster. Normally, you would expect the search results to return items with the term family in the title. So, let’s boost the significance of the movie title in our search results.
      1. Navigate to your App Search instance.
      2. In the left sidebar, click on Relevance Tuning.
      3. Click on the title field.
      4. Drag the WEIGHT parameter up to approx. 5 or something close.
      5. Select Save.
    3. In your app simulator, run the same generic search that you did earlier. You should get much better results this time.
    4. Cool! You just tuned and improved your search results without restarting the app or writing a single line of code!

    Congratulations! 🎉 You just constructed a mobile app with incredibly powerful search capabilities. Elasticsearch provides lightning quickly search results and can scale to handle petabytes of data. The crazy part is, all of that power is now available to your mobile app users, and it’s free! Try experimenting with some of the other App Search features like synonyms, curations, and the Web Crawler. Have fun!

    Go to the source

    Most Popular