Episode #389

Displaying Search Results and Recommendations

Series: Making a Podcast App From Scratch

25 minutes
Published on May 2, 2019

This video is only available to subscribers. Get access to this video and 572 others.

In this episode we build another API client to search for podcasts matching a term and customize the UI and behavior of the search bar. We display the recommended podcasts first, then when a user types in a term we show the matching podcasts from the iTunes API.

Episode Links

Displaying Search Results and Recommendations

In this episode, we build another API client to search for podcasts matching a term and customize the UI and behavior of the search bar. We display the recommended podcasts first, then when a user types in a term we show the matching podcasts from the iTunes API.

Displaying Search Results

We will start by creating a function loadRecommendedPodcasts to load some suggested podcasts to show initially. Note that we are transforming results into our model type, SearchResult, which acts as an anti-corruption layer. This way we would safeguard our system from unwanted behavior of third-party APIs.

private func loadRecommendedPodcasts() {
    recommendedPodcastsClient.fetchTopPodcasts { result in
        switch result {
        case .success(let response):
            self.recommendedPodcasts = response.feed.results.map(SearchResult.init)
            self.results = self.recommendedPodcasts
            self.tableView.reloadData()

        case .failure(let error):
            print("Error loading recommended podcasts: \(error.localizedDescription)")
        }
    }
}

Make sure self.tableView.reloadData() runs on the main queue. Try handling the failures while loading the recommended podcasts.

Extracting Layer To Display Search Result

We will create an extension SearchResult, using convenience initializer for converting PodcastResult objects.

extension SearchResult {
    convenience init(podcastResult: TopPodcastsAPI.PodcastResult) {
        self.init(
            artworkUrl: URL(string: podcastResult.artworkUrl100),
            title: podcastResult.name,
            author: podcastResult.artistName)
    }
}

Refactoring the Top Podcasts API

We want to reuse most of the behavior of TopPodcastsAPI, so we are refactor and extract its generic functions parseDecodable(), and performRequest() and enum APIError to class APIClient. We also added localized descriptions for each error case in enum APIError to aid in debugging and presenting error information.

enum APIError : Error {
//...
var localizedDescription: String {
    switch self {
    case .networkingError(let error): return "Error sending request: \(error.localizedDescription)"
    case .serverError: return "HTTP 500 Server Error"
    case .requestError(let status, let body): return "HTTP \(status)\n\(body)"
    case .invalidResponse: return "Invalid Response"
    case .decodingError(let error):
        return "Decoding error: \(error.localizedDescription)"
    }   
}

Modeling the PodcastSearchAPI

We’ll start by defining the response JSON structure as Decodable structs. This will be called from the class PodcastSearchAPI. Note properties of struct PodcastSearchResult would match to JSON response obtained from search API.

extension PodcastSearchAPI {
    struct Response : Decodable {
        let resultCount: Int
        let results: [PodcastSearchResult]
    }

    struct PodcastSearchResult : Decodable {
        let artistName: String
        let collectionName: String
        let artworkUrl100: String
        let genreIds: [String]
        let genres: [String]
    }
}

Creating API for displaying Search Result

Here we use the same idea of passing the session into the initializer like we did for the TopPodcastsAPI.

init(session: URLSession = .shared) {
        self.session = session
}

Writing Search Function

To construct our search request we will leverage URLComponents, which will take care of encoding any of our query parameters for us.

    let url = baseURL.appendingPathComponent("search")
    var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
    components.queryItems = [
        URLQueryItem(name: "country", value: country),
        URLQueryItem(name: "media", value: "podcast"),
        URLQueryItem(name: "entity", value: "podcast"),
        URLQueryItem(name: "attribute", value: "titleTerm"),
        URLQueryItem(name: "term", value: term)
    ]

Creating URL Request

Create a URL request using components.url. Note we are force unwrapping the components.url!.

let request = URLRequest(url: components.url!)
activeSearchTask?.cancel()
activeSearchTask = perform(request: request, completion: parseDecodable(completion: completion))

Cancelling previous requests during continuous searching

Update perform() of APIClient to cancel previous requests in progress. Use URLSessionDataTask to cancel the previous request. To ignore the return values mark @discardableResult to perform(). Make sure to ignore any errors that are a result of cancellation.

@discardableResult
func perform(request: URLRequest, completion: @escaping (Result<Data, APIError>) -> Void) -> URLSessionDataTask {
    let task = session.dataTask(with: request) { data, response, error in
        if let error = error as NSError? {
            if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
                return
            }

            completion(.failure(.networkingError(error)))
            return
        }

        guard let http = response as? HTTPURLResponse, let data = data else {
            completion(.failure(.invalidResponse))
            return
        }
    }
}

Updating the UI with Search Results

To update the SearchResults, call PodcastSearchAPI.search and update the result by mapping to SearchResult.init. Make sure to handle failures.

searchClient.search(for: term) { result in
    switch result {
        case .success(let response):
            self.results = response.results.map(SearchResult.init)
            self.tableView.reloadData()

        case .failure(let error):
            print("Error searching podcasts: \(error.localizedDescription)")
    }
}

If the search bar doesn't have any text, we need to update the search results to use the recommended podcasts instead.

let term = searchController.searchBar.text ?? ""
if term.isEmpty {
    resetToRecommendedPodcasts()
    return
} 

UI modifications for SearchView

Here we will make some UI changes in SearchView.
1) To make the font visible on the search bar, change the foregroundColor of searchBarTextField.defaultTextAttribute in the UITextField. Note that this change is done only when UITextField is used for UISearchBar. We will make this change in Theme.swift

let searchBarTextFields = UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self])
    searchBarTextFields.defaultTextAttributes = [
        .foregroundColor : Colors.gray1,
        .font : UIFont.boldSystemFont(ofSize: 14)
    ]

2) To have the search bar initially visible when the view loads, we can add this in viewDidLoad:

navigationItem.hidesSearchBarWhenScrolling = false

However this also means that the search bar will be always visible. We can get the other behavior back by resetting this value in viewDidAppear:

navigationItem.hidesSearchBarWhenScrolling = true

This episode uses Xcode 10.2.1, Swift 5.0.