Autocomplete

Download Sample Project on GitHub

 

Challenge

Autocomplete has several architectural challenges. Every keystroke initiates a new API call, so you may have a number of in-flight requests and risk that they’ll come back out of order. For example, if a user types AB and then ABC, but the ABC request comes back before the AB request, your UI will reflect AB when it finishes. So you need to track active tasks and cancel them.

There are also performance implications to making so many API calls. You want the autocomplete to be snappy and responsive. I think caching is absolutely neccessary here unless the data will be changing frequently. I’ll use the Google Places Autocomplete, and enabling caching will greatly improve the performant look and feel.

 

Part 1: Network Service

Then create the NetworkService with two dictionaries to maintain active tasks and cache.

struct NetworkService {
    
    static var tasks: [URLResource: [URLSessionDataTask]] = [:]
    static var cache: [URLResource: NetworkResponse] = [:]

 

The actual network calls happen inside this request() function.

/**
     
 The standard network request.
 
 Parameter resource - A URL Resource including url, method, parameters, and headers.
 Parameter enableCache - Cache the result and to return cached results.
 Parameter completion - The result of the network call, a response object.
 
 */
static func request(_ resource: URLResource, enableCache: Bool, completion: @escaping (NetworkResponse) -> ()) {
    
    if enableCache, var response = cache[resource] {
        response.cache = true
        completion(response)
        return
    }
    
    cache[resource] = nil
    
    guard let request = buildRequestFor(resource) else {
        let error = NetworkError(message: "Unable to build URL.")
        let response = NetworkResponse(error: error)
        completion(response)
        return
    }
    
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data,                            // is there data
            let response = response as? HTTPURLResponse,  // is there HTTP response
            (200 ..< 300) ~= response.statusCode,         // is statusCode 2XX
            error == nil else {                           // was there no error, otherwise ...
                let response = NetworkResponse(error: error!)
                completion(response)
                return
        }
        
        guard let responseObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
            let error = NetworkError(message: "Unable to serialize response into JSON.")
            completion(NetworkResponse(error: error))
            return
        }
        
        let networkResponse: NetworkResponse
        
        if enableCache {
            networkResponse = NetworkResponse(json: responseObject, cacheTime: Date())
            cache[resource] = networkResponse
        } else {
            networkResponse = NetworkResponse(json: responseObject)
        }
        
        completion(networkResponse)
        return
    }
    
    task.resume()
    
    if tasks[resource] == nil {
        tasks[resource] = []
    }
    
    tasks[resource]?.append(task)
}

 

A helper function to build URLs…

private static func buildRequestFor(_ resource: URLResource) -> URLRequest? {
    guard resource.url != nil, var components = URLComponents(string: resource.url!) else {
        return nil
    }
    
    if resource.method == .GET {
        if let parameters = resource.parameters {
            components.queryItems = parameters.map { (key, value) in
                URLQueryItem(name: key, value: value)
            }
            components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
        }
    }
    
    var request = URLRequest(url: components.url!)
    request.httpMethod = resource.method.rawValue
    
    if resource.method == .POST || resource.method == .DELETE {
        if let parameters = resource.parameters {
            request.httpBody = try? JSONSerialization.data(withJSONObject: parameters, options: .prettyPrinted)
        }
    }
    
    if let headers = resource.headers {
        for header in headers {
            request.addValue(header.value, forHTTPHeaderField: header.key)
        }
    }
    
    return request
}

 

A public API to cancel active tasks…

static func cancelActiveTasksFor(_ resource: URLResource) {
    for activeResource in NetworkService.tasks {
        
        // Cancels all tasks against an endpoint regardless of the parameters/arguments used.
        if activeResource.key.url == resource.url {
            
            for task in activeResource.value {
                task.cancel()
            }
            
            NetworkService.tasks[activeResource.key] = []
        }
    }
}

 

And a public API to flush the cache.

static func flushCache() {
    cache = [:]
}

 

Also some data types.

typealias JSON = [String: Any]

struct URLResource: Hashable {
    var url: String?
    var method: URLMethod
    var headers: [String: String]?
    var parameters: [String: String]?
    
    static func ==(lhs: URLResource, rhs: URLResource) -> Bool {
        return (lhs.url == rhs.url) &&
            (lhs.method == rhs.method) &&
            (lhs.headers == rhs.headers) &&
            (lhs.parameters == rhs.parameters)
    }
}

enum URLMethod: String {
    case GET = "GET"
    case POST = "POST"
    case DELETE = "DELETE"
}

struct NetworkError: Error {
    var message: String?
}

struct NetworkResponse {
    var json: JSON?
    var error: NetworkError?
    var cache = false
    var cacheTime: Date?
    
    init(error: NetworkError) {
        self.error = error
    }
    
    init(error: Error) {
        print("handle standard Error")
    }
    
    init(json: JSON) {
        self.json = json
    }
    
    init(json: JSON, cacheTime: Date) {
        self.json = json
        self.cacheTime = cacheTime
    }
}

struct Place {
    var description: String?
    
    init(json: [String: Any]) {
        description = json["description"] as? String
    }
}

 

Part 2: GoogleAPI Struct

I like to group together related APIs into a single data structure. Here in the GoogleAPI struct, I would include any APIs hitting Google servers. If the App also used a custom OAuth solution for example, I would make another struct for the account functionality, AccountAPI.

struct GoogleAPI {
    
    static func placeAutocomplete(_ query: String, completion: @escaping (PlaceAutocompleteResponse) -> ()) {
        
        let url = "https://maps.googleapis.com/maps/api/place/autocomplete/json"
        
        let parameters = ["input": query,
                          "key": ""]
        
        let resource = URLResource(url: url, method: .GET, headers: nil, parameters: parameters)
        
        // Cancel any network requests for the PlaceAPI currently in-flight.
        NetworkService.cancelActiveTasksFor(resource)
        
        // `withCache` will cache the response from the first request
        // and all subsequent requests will return that cached result.
        NetworkService.request(resource, enableCache: true) { (response) in
            guard response.error == nil, response.json != nil else {
                completion((nil, nil))
                return
            }
            
            var places = [Place]()
            
            if let predictions = response.json?["predictions"] as? [Any] {
                for place in predictions {
                    if let dictionary = place as? [String:Any] {
                        places.append(Place(json: dictionary))
                    }
                }
            }
            
            completion((places, response.cache))
        }
    }
}

/// Wrapper for the response model to include if it came from the cache.
typealias PlaceAutocompleteResponse = (predictions: [Place]?, cached: Bool?)
  1. Replace the empty string "" with a Google API key enabled for Google Places.

  2. cancelActiveTasksFor(resource) cancels all in-flight to prevent a latency timing problem.

  3. withCache parameter will cache the result and to return cached results if they exist.

 

Part 3: SearchViewController

Now it’s just updating the UI appropriately.

class SearchViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var cacheImageView: UIImageView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    
    var predictions: [Place]? {
        didSet {
            tableView.reloadData()
        }
    }
    
    override func viewDidLoad() {
        tableView.tableFooterView = UIView()
        textField.becomeFirstResponder()
        textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged)
    }
    
    @objc func textFieldDidChange(_ textField: UITextField) {
        
        cacheImageView.isHidden = true
        predictions = nil
        
        guard let text = textField.text, text != "" else {
            activityIndicator.stopAnimating()
            return
        }
        
        activityIndicator.startAnimating()
        
        GoogleAPI.placeAutocomplete(text) { (response) in
            DispatchQueue.main.async {
                
                self.activityIndicator.stopAnimating()
                self.predictions = response.predictions
                self.cacheImageView.isHidden = (response.cached ?? false) ? false : true
                
            }
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return predictions?.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "ResultCell", for: indexPath) as? ResultCell else {
            return UITableViewCell()
        }
        
        cell.titleLabel.text = predictions?[indexPath.row].description
        
        return cell
    }
}

Download Sample Project on GitHub