Episode #411

My Podcasts Screen

Series: Making a Podcast App From Scratch

28 minutes
Published on October 3, 2019

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

We refactor out some common logic to show a My Podcasts screen with all of the subscribed podcasts. We fetch the subscriptions using Core Data and listen for changes to subscriptions using our new Typed Notification system.

Extracting Common Logic of Searching Podcast

We will start by extracting the common code to display each podcast along with the details in the PodcastCell. This will be the base class for our search results and the My Podcasts screen.

class PodcastCell : UITableViewCell {

    @IBOutlet weak var artworkImageView: UIImageView!
    @IBOutlet weak var podcastTitleLabel: UILabel!
    @IBOutlet weak var podcastAuthorLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()

        artworkImageView.backgroundColor = Theme.Colors.gray3
        artworkImageView.layer.cornerRadius = 10
        artworkImageView.layer.masksToBounds = true

        backgroundColor = Theme.Colors.gray4
        backgroundView = UIView()
        backgroundView?.backgroundColor = Theme.Colors.gray4

        selectedBackgroundView = UIView()
        selectedBackgroundView?.backgroundColor = Theme.Colors.gray3

        podcastTitleLabel.textColor = Theme.Colors.gray0
        podcastAuthorLabel.textColor = Theme.Colors.gray1
    }
}

To configure the podcast with its details, we will create a protocol with podcast details and assign it as a model for the configuration.

protocol PodcastCellModel {
    var titleText: String? { get }
    var authorText: String? { get }
    var artwork: URL? { get }
}

func configure(with model: PodcastCellModel) {
    podcastTitleLabel.text = model.titleText
    podcastAuthorLabel.text = model.authorText
    if let url = model.artwork {
        let options: KingfisherOptionsInfo = [
            .transition(.fade(0.5))
        ]
        artworkImageView.kf.setImage(with: url, options: options)
    }
}

We will now conform the SeachResultsCell to the protocol PodcastCell.

class SearchResultCell : PodcastCell {
}

This way we can add additional behaviors to our SearchResultCell along with the existing and shared behaviors.

Fetching the Subscriptions

Now we will create an extension of PodcastEntity to conform the PodcastCellModel.

extension PodcastEntity : PodcastCellModel {
    var titleText: String? { return title }
    var authorText: String? { return author }
    var artwork: URL? { return artworkURL }
}

Next, we will create a shared view controller. This will act as a superclass for other controllers and will contain the shared logic of the table view used for the display of the My Podcasts screen and search results.

class PodcastListTableViewController : UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.separatorInset = .zero
        tableView.backgroundColor = Theme.Colors.gray4
        tableView.separatorColor = Theme.Colors.gray3
    }

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

We will then create a storyboard for My Podcasts. The user interface will be similar to the search results.

In SubscriptionStore we will now fetch all the active subscriptions and the details related to the podcast from the database.

We will also indicate that we want to prefetch the podcast relationship. This will fetch a subscription along with all the related podcast objects in a single fetch query. We also indicate that we want to sort the results with the newest subscription on top.

func fetchSubscriptions() throws -> [SubscriptionEntity] {
    let fetch: NSFetchRequest<SubscriptionEntity> = SubscriptionEntity.fetchRequest()
    fetch.returnsObjectsAsFaults = false
    fetch.relationshipKeyPathsForPrefetching = ["podcast"]
    fetch.sortDescriptors = [NSSortDescriptor(key: "dateSubscribed", ascending: false)]
    return try mainContext.fetch(fetch)
}

On our view controller we will fetch the active subscriptions in viewDidLoad. As we fetch the subscriptions and update it, we will also update the podcasts. Sometimes a podcast may get deleted but the link to subscription stays, to avoid such kind of nil podcast, we will filter out this data before we load the subscriptions.

private let store: SubscriptionStore = .shared
    private var subscriptions: [SubscriptionEntity] = [] {
        didSet {
            podcasts = subscriptions.compactMap { $0.podcast }
        }
    }

private var podcasts: [PodcastEntity] = []
private var subscriptionChangedObserver: NSObjectProtocol?

override func viewDidLoad() {
    super.viewDidLoad()

    loadSubscriptions()
    subscriptionChangedObserver = NotificationCenter.default
        .addObserver(SubscriptionsChanged.self,
                     sender: nil,
                     queue: .main,
                     using: { change in
                        self.loadSubscriptions()
        })
    }

private func loadSubscriptions() {
    do {
        subscriptions = try store.fetchSubscriptions()
        tableView.reloadData()
    } catch {
        print("ERROR: ", error.localizedDescription)
    }
}

Next we can configure our table view to display details about the podcasts we've subscribed to. When the user selects a row, we create a lookup info and use the method from the base class to show the podcast. We do this to make sure we fetch the latest info from the podcast as well as all of the latest episodes.

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return podcasts.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell: PodcastCell = tableView.dequeueReusableCell(for: indexPath)
    let podcast = podcasts[indexPath.row]
    cell.configure(with: podcast)
    return cell
}

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let podcast = podcasts[indexPath.row]
    let lookup = PodcastLookupInfo(id: podcast.id!, feedURL: URL(string: podcast.feedURLString!)!)
    showPodcast(with: lookup)
}

Sending Notification Using TypeNotification

There are two primary ways we can update this screen when users subscribe or unsubscribe from podcasts:
1) One is to fetch the data every time the screen is loaded.
2) Another way is to bind to these events in the background by monitoring the changed data while loading and reloading.

To load the changed details of subscribed and unsubscribed podcast, we will create a struct that conforms to TypedNotification and set Ids of subscribed and unsubscribed podcast.

struct SubscriptionsChanged : TypedNotification {
    var sender: Any?

    static var name: String = "SubscriptionsChangedNotification"

    let subscribedIds: Set<String>
    let unsubscribedIds: Set<String>

    init(subscribed: Set<String>) {
        subscribedIds = subscribed
        unsubscribedIds = []
    }

    init(unsubscribed: Set<String>) {
        subscribedIds = []
        unsubscribedIds = unsubscribed
    }
}

Next, to know the changes in subscriptions, we will post the notification with podcast details in SubscriptionStore.

let change = SubscriptionsChanged(subscribed: [podcast.id])
    NotificationCenter.default.post(change)


let change = SubscriptionsChanged(unsubscribed: [podcast.id])
            NotificationCenter.default.post(change)

Next, we will observe the TypeNotification in MyPodcastsViewController.

subscriptionChangedObserver = NotificationCenter.default
    .addObserver(SubscriptionsChanged.self,
                 sender: nil,
                 queue: .main,
                 using: { change in
                    self.loadSubscriptions()
})

This episode uses Swift 5.0, Xcode 11.0.