
This video is only available to subscribers. Start a subscription today to get access to this and 472 other videos.
Refactoring to a Data Manager
This episode is part of a series: Making a Podcast App From Scratch.
Episode Links
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)
}
}
}