Episode #402

Polish and Cleanup

Series: Making a Podcast App From Scratch

23 minutes
Published on July 25, 2019

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

We have some housekeeping to do in this episode. We also want to add a little polish to the podcast detail screen so that it doesn't resemble a stock table view driven app. We also need to clean up the data model a bit in preparation for persistence, and we also want to remove the pesky html tags that show up on the podcast and episode descriptions.

Removing HTML tag

We will start by removing the specific occurrence of unwanted HTML tags, we will create a String extension that uses regular expressions to strip this out. Yes, this is a hacky solution and no... you probably shouldn't parse HTML with Regex. But it does do the job for our needs at the moment.

extension String {
    func strippingHTML() -> String {
        return replacingOccurrences(of: "<[^>]+>",
                                    with: "",
                                    options: .regularExpression,
                                    range: nil)
    }
}

Now we will use this extension to strip the HTML tags from the description of the episode used in the EpisodeCellViewModel.

var description: String? {
    return episode.description?
        .strippingHTML()
        .trimmingCharacters(in: .whitespacesAndNewlines)
}

Similarly, we will strip the HTML pattern from the description of the podcast used in PodcastDetailHeaderViewController.

descriptionLabel.text = podcast.description?
        .strippingHTML()
        .trimmingCharacters(in: .whitespacesAndNewlines)   

Polishing the Details View Screen

As we set the data to podcast details view controller, a blank screen appears for a few seconds before loading the header data. To avoid this, we will animate the entry of header view while it is loading on the screen,

var podcast: Podcast? {
    didSet {
        if isViewLoaded, let podcast = podcast {
            UIView.animate(withDuration: 0.4) {
                self.updateUI(for: podcast)
                self.view.layoutIfNeeded()
            }
        }
    }
}

Adding Parallax Scrolling Effect

We will add an effect to our detail screen, while it scrolls we will make the header to persist longer and then slowly allow it to be replaced by the content data. We will also set the content to scroll over the header data.

To add a parallax scrolling effect we will transform the header according to the content offset while scrolling our view.

To allow the content data scroll over the top of the header view we will set the .clipsToBounds of the header's superview to be false for a negative offset, and to true for a positive offset.

We will also set the .alpha value for the header. This will allow the header to be visible while we scroll down, and slowly fades out once the table view is scrolled up.

// MARK: - Scrolling

override func scrollViewDidScroll(_ scrollView: UIScrollView) {
    adjustHeaderParallax(scrollView)
}

private func adjustHeaderParallax(_ scrollView: UIScrollView) {
    let offsetY = scrollView.contentOffset.y
    let headerView = headerViewController.view
    if offsetY < 0 {
        headerView?.superview?.clipsToBounds = false
        headerView?.transform = CGAffineTransform(translationX: 0, y: offsetY/10)
        headerView?.alpha = 1.0
    } else {
        headerView?.superview?.clipsToBounds = true
        headerView?.transform = CGAffineTransform(translationX: 0, y: offsetY/3)
        headerView?.alpha = 1 - (offsetY / headerView!.frame.height * 0.9)
    }
}

Refactoring Data Model To Save Subscribed Podcast

Once we click on the subscribe button, the subscription will (eventually) be saved in the Core Data along with a unique identifier. With the current code, we don't have an id for the podcast, nor do we have a mechanism for carrying this information over along with the feed URL of the subscribed podcast. So we will now refactor the code to capture this data from the search view controller.

1) First, we need to add a type that captures the id and feed URL. Since these would be required to save a subscribed podcast, we will make them as non-optional.

2) Currently, PodcastFeedLoader constructs a podcast using feedURL, instead we will create a PodcastLookupInfo containing the identifiers required to construct a podcast.

struct PodcastLookupInfo {
    let id: String
    let feedURL: URL
}

3) Next we will refactor the PodcastFeedLoader to load the feed using PodcastLookupInfo.

class PodcastFeedLoader {
    func fetch(lookupInfo: PodcastLookupInfo, completion: @escaping (Swift.Result<Podcast, PodcastLoadingError>) -> Void) {

        let req = URLRequest(url: lookupInfo.feedURL, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 60)

        URLSession.shared.dataTask(with: req) { data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    completion(.failure(.loadError(error)))
                }
                return
            }

            let http = response as! HTTPURLResponse
            switch http.statusCode {
            case 200:
                if let data = data {
                    self.loadFeed(lookupInfo: lookupInfo, data: data, completion: completion)
                }

            case 404:
                DispatchQueue.main.async {
                    completion(.failure(.notFound))
                }

            default:
                DispatchQueue.main.async {
                    completion(.failure(.requestFailed(http.statusCode)))
                }
            }
        }.resume()
    }

    private func loadFeed(lookupInfo: PodcastLookupInfo, data: Data, completion: @escaping (Swift.Result<Podcast, PodcastLoadingError>) -> Void) {
        let parser = FeedParser(data: data)
        parser.parseAsync { parseResult in
            let result: Swift.Result<Podcast, PodcastLoadingError>
            do {
                switch parseResult {
                case .atom(let atom):
                    result = try .success(self.convert(atom: atom, lookupInfo: lookupInfo))
                case .rss(let rss):
                    result = try .success(self.convert(rss: rss, lookupInfo: lookupInfo))
                case .json(_): fatalError()
                case .failure(let e):
                    result = .failure(.feedParsingError(e))
                }
            } catch let e as PodcastLoadingError {
                result = .failure(e)
            } catch {
                fatalError()
            }
            DispatchQueue.main.async {
                completion(result)
            }
        }
    }
}

Similarly, we will fetch the data from Atom and RSS feed from lookupInfo.


private func convert(atom: AtomFeed, lookupInfo: PodcastLookupInfo) throws -> Podcast {
    guard let name = atom.title else { throw PodcastLoadingError.feedMissingData("title")  }

    let author = atom.authors?.compactMap({ $0.name }).joined(separator: ", ") ?? ""

    guard let logoURL = atom.logo.flatMap(URL.init) else {
        throw PodcastLoadingError.feedMissingData("logo")
    }

    let description = atom.subtitle?.value ?? ""

    let p = Podcast(id: lookupInfo.id, feedURL: lookupInfo.feedURL)
    p.title = name
    p.author = author
    p.artworkURL = logoURL
    p.description = description
    p.primaryGenre = atom.categories?.first?.attributes?.label

    p.episodes = (atom.entries ?? []).map { entry in
        let episode = Episode()
        episode.identifier = entry.id
        episode.title = entry.title
        episode.description = entry.summary?.value
        episode.enclosureURL = entry.content?.value.flatMap(URL.init)

        return episode
    }

    return p
}

private func convert(rss: RSSFeed, lookupInfo: PodcastLookupInfo) throws -> Podcast {
    guard let title = rss.title else { throw PodcastLoadingError.feedMissingData("title") }
    guard let author = rss.iTunes?.iTunesAuthor ?? rss.iTunes?.iTunesOwner?.name else {
        throw PodcastLoadingError.feedMissingData("itunes:author, itunes:owner name")
    }
    let description = rss.description ?? ""
    guard let logoURL = rss.iTunes?.iTunesImage?.attributes?.href.flatMap(URL.init) else {
        throw PodcastLoadingError.feedMissingData("itunes:image url")
    }

    let p = Podcast(id: lookupInfo.id, feedURL: lookupInfo.feedURL)
    p.title = title
    p.author = author
    p.artworkURL = logoURL
    p.description = description
    p.primaryGenre = rss.categories?.first?.value ?? rss.iTunes?.iTunesCategories?.first?.attributes?.text
    p.episodes = (rss.items ?? []).map { item in
        let episode = Episode()
        episode.identifier = item.guid?.value
        episode.title = item.title
        episode.description = item.description
        episode.publicationDate = item.pubDate
        episode.duration = item.iTunes?.iTunesDuration
        episode.enclosureURL = item.enclosure?.attributes?.url.flatMap(URL.init)
        return episode
    }
    return p
}

4) Now we will allow the data manager to fetch the podcast details asynchronously.

/// Returns an object required to lookup the details of a podcast. If the requisite properties are present on the searchResult,
/// the object is returned in the callback immediately. Otherwise a call to the iTunes API is made to fetch the missing data before the callback.

func lookupInfo(for searchResult: SearchResult, completion: @escaping (Result<PodcastLookupInfo?, APIError>) ->  Void) {
    if let feed = searchResult.feedURL {
        let lookup = PodcastLookupInfo(id: searchResult.id, feedURL: feed)
        completion(.success(lookup))
    } else {
        searchClient.lookup(id: searchResult.id) { result in
            switch result {
            case .success(let updatedResult):
                let lookupInfo = updatedResult?.feedURL.flatMap({ PodcastLookupInfo(id: searchResult.id, feedURL: $0) })

                completion(.success(lookupInfo))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

5) Finally, we will refactor the podcast detail view to fetch the lookupInfo from the data manager and display it in the details screen.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let searchResult = results[indexPath.row]

    dataManager.lookupInfo(for: searchResult) { result in
        switch result {
        case .success(let lookupInfo):
            if let lookupInfo = lookupInfo {
                self.showPodcast(with: lookupInfo)
            } else {
                print("Podcast not found")
            }
        case .failure(let error):
            print("Error loading podcast: \(error.localizedDescription)")
        }
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

private func showPodcast(with lookupInfo: PodcastLookupInfo) {
    let detailVC = UIStoryboard(name: "PodcastDetail", bundle: nil).instantiateInitialViewController() as! PodcastDetailViewController
    detailVC.podcastLookupInfo = lookupInfo
    show(detailVC, sender: self)
}

This episode uses Xcode 10.2.1, Swift 5.0.