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