Episode #398

Refactoring to a Data Manager

Series: Making a Podcast App From Scratch

26 minutes
Published on July 4, 2019

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

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

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)
        }
    }
}

This episode uses Xcode 11.0-beta3, Swift 5.1.