In this episode we clean up some autolayout warnings, implement some changes to support dynamic text, then move our attention to presenting the podcast detail screen when tapping on search results. Since the data is coming from various places we introduce a Data Manager to move that responsibility out of the view controller.
Episode Links Why The Failure, Auto Layout? Cleaning Up The AutoLayout Warning To better understand the warnings shown by auto layout, we can use WTFAutolayout.com. By pasting the error log, we get the visual representation of errors occurred during auto layout. Creating Podcast Loading Errors We will create an enum PodcastLoadingError to handle all the related errors while loading the data. It converts the API error into the loading error with a meaningful description assigned to each one of them. import Foundation enum PodcastLoadingError : Swift.Error { case feedMissingData(String) case networkDecodingError(DecodingError) case invalidResponse case loadError(Error) case feedParsingError(Error) case notFound case requestFailed(Int) var localizedDescription: String { switch self { case .feedMissingData(let key): return "Feed is missing data for key: \(key)" case .networkDecodingError(let decodingError): return "Error decoding response: \(decodingError.localizedDescription)" case .feedParsingError(let error): return "Parsing Error: \(error.localizedDescription)" case .loadError(let error): return "Error loading feed: \(error.localizedDescription)" case .invalidResponse: return "Invalid response loading feed" case .notFound: return "Feed not found" case .requestFailed(let status): return "HTTP \(status) returned fetching feed." } } static func convert(from error: APIError) -> PodcastLoadingError { switch error { case .decodingError(let dec): return .networkDecodingError(dec) case .invalidResponse: return .invalidResponse case .networkingError(let e): return .loadError(e) case .requestError(let status, _): return .requestFailed(status) case .serverError: return .loadError(error) } } } Moving Recommended Search To PodcastDataManager To get the details of a podcast for the podcast detail screen, we require the feed. However only one of the current API responses has a feedURL property. We will have to add some logic to fetch the feed URL first if we need it, then fetch the feed itself. Since the view controller displays the data from various sources we can move this responsibility to a PodcastDataManager. This allows the data manager to decide the source of the data to be displayed in the podcast detail screen. class PodcastDataManager { private let topPodcastsAPI = TopPodcastsAPI() func recommendedPodcasts(completion: @escaping (Result<[SearchResult], PodcastLoadingError>) -> Void) { topPodcastsAPI.fetchTopPodcasts(limit: 50, allowExplicit: false) { result in switch result { case .success(let response): let searchResults = response.feed.results.map(SearchResult.init) completion(.success(searchResults)) case .failure(let error): completion(.failure(PodcastLoadingError.convert(from: error))) } } } func lookup(podcastID: String, completion: @escaping (Result<SearchResult?, APIError>) -> Void) { searchClient.lookup(id: podcastID) { result in completion(result) } } } We will now initialize data manager in SearchViewController. As we will have parsed result from data Manager, we will have to display it on our screen. private let dataManager = PodcastDataManager() private func loadRecommendedPodcasts() { dataManager.recommendedPodcasts { result in switch result { case .success(let podcastResults): self.recommendedPodcasts = podcastResults self.results = self.recommendedPodcasts self.tableView.reloadData() case .failure(let error): print("Error loading recommended podcasts: \(error.localizedDescription)") } } } Moving Search Result to PodcastDataManager Next, we will move our search functionality to the data manager. This function will use the searchClient and unwraps the results obtained from the search. private let searchClient = PodcastSearchAPI() func search(for term: String, completion: @escaping (Result<[SearchResult], PodcastLoadingError>) -> Void) { searchClient.search(for: term) { result in switch result { case .success(let response): let searchResults = response.results.map(SearchResult.init) completion(.success(searchResults)) case .failure(let error): completion(.failure(PodcastLoadingError.convert(from: error))) } } } We will get the search results from the data manager. func updateSearchResults(for searchController: UISearchController) { let term = searchController.searchBar.text ?? "" if term.isEmpty { resetToRecommendedPodcasts() return } dataManager.search(for: term) { result in switch result { case .success(let searchResults): self.results = searchResults self.tableView.reloadData() case .failure(let error): print("Error searching podcasts: \(error.localizedDescription)") } } } Representing our Feed For Search Results To capture the feedURL and a few other details we will be adding properties in PodcastSearchAPI. struct PodcastSearchResult : Decodable { let artistName: String let collectionId: Int let collectionName: String let artworkUrl100: String let genreIds: [String] let genres: [String] let feedUrl: String } Next, we will be adding an identifier to the view model of SearchResult. Sometimes we may have search results without the feed URL, so this property needs to be optional. class SearchResult { var id: String var artworkUrl: URL? var title: String var author: String var feedURL: URL? init(id: String, artworkUrl: URL?, title: String, author: String, feedURL: URL?) { self.id = id self.artworkUrl = artworkUrl self.title = title self.author = author self.feedURL = feedURL } } extension SearchResult { convenience init(podcastResult: TopPodcastsAPI.PodcastResult) { self.init( id: podcastResult.id, artworkUrl: URL(string: podcastResult.artworkUrl100), title: podcastResult.name, author: podcastResult.artistName, feedURL: nil ) } } extension SearchResult { convenience init(searchResult: PodcastSearchAPI.PodcastSearchResult) { self.init( id: String(searchResult.collectionId), artworkUrl: URL(string: searchResult.artworkUrl100), title: searchResult.collectionName, author: searchResult.artistName, feedURL: URL(string: searchResult.feedUrl)) } } Adding LookUp for Search Result without FeedURL We will now display our detailed view of the selected search result in SearchViewController. For the results containing feedURL, we will display the details for the selected feed, in the cases where the result does not have feedURL we will have to lookup in the data manager to get the feedURL. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let result = results[indexPath.row] if let feed = result.feedURL { showPodcast(with: feed) tableView.deselectRow(at: indexPath, animated: true) } else { dataManager.lookup(podcastID: result.id) { result in switch result { case .success(let podcast): if let feed = podcast?.feedURL { self.showPodcast(with: feed) } else { print("Podcast not found") } case .failure(let error): print("Error loading podcast: \(error.localizedDescription)") } tableView.deselectRow(at: indexPath, animated: true) } } } Adding the lookup functionality in the dataManager. func lookup(podcastID: String, completion: @escaping (Result<SearchResult?, APIError>) -> Void) { searchClient.lookup(id: podcastID) { result in completion(result) } } func lookup(id: String, country: String = "us", completion: @escaping (Result<SearchResult?, APIError>) -> Void) { let url = baseURL.appendingPathComponent("\(country)/lookup") var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "id", value: id) ] let request = URLRequest(url: components.url!) perform(request: request, completion: parseDecodable { (result: Result<Response, APIError>) in switch result { case .success(let response): let result = response.results.first.flatMap(SearchResult.init) completion(.success(result)) case .failure(let error): completion(.failure(error)) } }) } Creating Detail View Now we will display the details of the selected row in SearchViewController. As we are contained in a navigation controller, show displays the detail view on the navigation stack. private func showPodcast(with feedURL: URL) { let detailVC = UIStoryboard(name: "PodcastDetail", bundle: nil).instantiateInitialViewController() as! PodcastDetailViewController detailVC.feedURL = feedURL show(detailVC, sender: self) } Fixing Header issue while the required data is missing Displaying the detail view may not be correct when an expected data is missing in the selected search. To resolve this, we will clear all the properties to nil and disables the subscribeButton. Do enable the subscribeButton while updating UI. func clearUI() { imageView.image = nil titleLabel.text = nil authorLabel.text = nil genreLabel.text = nil descriptionLabel.text = nil subscribeButton.isEnabled = false } override func viewDidLoad() { super.viewDidLoad() if let podcast = podcast { updateUI(for: podcast) } else { clearUI() } } private func updateUI(for podcast: Podcast) { subscribeButton.isEnabled = true let options: KingfisherOptionsInfo = [.transition(.fade(1.0))] imageView.kf.setImage(with: podcast.artworkURL, placeholder: nil, options: options) titleLabel.text = podcast.title authorLabel.text = podcast.author genreLabel.text = podcast.primaryGenre descriptionLabel.text = podcast.description } Finally, we will refactor the PodcastDetailViewController to clear the UI while displaying the error message. override func viewDidLoad() { super.viewDidLoad() headerViewController = children.compactMap { $0 as? PodcastDetailHeaderViewController }.first loadPodcast() } private func loadPodcast() { UIApplication.shared.isNetworkActivityIndicatorVisible = true PodcastFeedLoader().fetch(feed: feedURL) { result in UIApplication.shared.isNetworkActivityIndicatorVisible = false switch result { case .success(let podcast): self.podcast = podcast case .failure(let error): self.headerViewController.clearUI() let alert = UIAlertController(title: "Failed to Load Podcast", message: "Error loading feed: \(error.localizedDescription)", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in self.loadPodcast() })) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) self.present(alert, animated: true, completion: nil) } } }