
This video is only available to subscribers. Start a subscription today to get access to this and 472 other videos.
Displaying Search Results and Recommendations
This episode is part of a series: Making a Podcast App From Scratch.
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