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 iTunes RSS Feed Generator iTunes Search API 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