Playlist Screen

Episode #418 | 18 minutes | published on November 15, 2019 | Uses Xcode-11.2.1, Swift-5.1
Subscribers Only
In this episode we create the UI for our playlist screen, showing episodes from each of the subscribed podcasts. On this screen we combine NSFetchedResultsController with UITableViewDiffableDatasource so that our playlist screen can react to changes to the underlying data and reload as necessary. We do this using the automaticallyMergesChangesFromParent on our NSManagedObjectContext.

Let's create a Playlist UI.

Since we've already seen most of the table view setup and cell configuration, we'll quickly paste in some code for this.

class PlaylistViewController : UITableViewController {
    override func viewDidLoad() {
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 94

        tableView.separatorInset = .zero
        tableView.backgroundColor = Theme.Colors.gray4
        tableView.separatorColor = Theme.Colors.gray3
    }
}
class PlaylistCell : UITableViewCell {

    @IBOutlet weak var artworkImageView: UIImageView!
    @IBOutlet weak var episodeTitleLabel: UILabel!
    @IBOutlet weak var podcastLabel: UILabel!
    @IBOutlet weak var durationLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()

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

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

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

        episodeTitleLabel.textColor = Theme.Colors.gray0
        podcastLabel.textColor = Theme.Colors.gray1
        durationLabel.textColor = Theme.Colors.gray2
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        episodeTitleLabel.text = nil
        podcastLabel.text = nil
        durationLabel.text = nil

        artworkImageView.kf.cancelDownloadTask()
        artworkImageView.image = nil
    }

    func configure(with viewModel: PlaylistCellViewModel) {
        episodeTitleLabel.text = viewModel.title
        podcastLabel.text = viewModel.podcastTitle
        durationLabel.text = viewModel.info
        artworkImageView.kf.setImage(with: viewModel.artworkURL, placeholder: nil, options: [.transition(.fade(0.3))])
    }
}

We also need to create this view model, which will take our entity and provide UI-related properties for display. This is similar to what we did in other cells in this project.

struct PlaylistCellViewModel : Hashable {
    private let episode: EpisodeEntity

    private static var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        return formatter
    }()

    private static var timeFormatter: DateComponentsFormatter = {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .short
        formatter.allowedUnits = [.hour, .minute, .second]
        return formatter
    }()

    init(episode: EpisodeEntity) {
        self.episode = episode
    }

    var title: String {
        return episode.title
    }

    var podcastTitle: String? {
        return episode.podcast.title
    }

    var artworkURL: URL? {
        return episode.podcast.artworkURL
    }

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

    var info: String {
        let parts = [timeString, dateString].compactMap { $0 }
        return parts.joined(separator: " • ")
    }

    private var timeString: String? {
        return Self.timeFormatter.string(from: episode.duration)
    }

    private var dateString: String {
        return Self.dateFormatter.string(from: episode.publicationDate)
    }
}

With these pieces out of the way, we can now work on the data.

We'll also add this view controller as a tab in Main.storyboard, setting the Storyboard ID to Playlist, and then setting the icon in the tab bar to the Priority icon.

Adding Playlist fetch methods to SubscriptionStore

Open up SubscriptionStore.swift and create a new method to fetch the playlist:

    func fetchPlaylist() throws -> [EpisodeEntity] {
        return try context.fetch(playlistFetchRequest())
    }

    func playlistFetchRequest() -> NSFetchRequest<EpisodeEntity> {
        let fetch: NSFetchRequest<EpisodeEntity> = EpisodeEntity.fetchRequest()
        fetch.predicate = NSPredicate(format: "podcast.subscription != nil")
        fetch.sortDescriptors = [NSSortDescriptor(key: "publicationDate", ascending: false)]
        return fetch
    }

We are separating the fetch request from the method so that we can reuse it in a fetched results controller.

Add DiffableDatasource to PlaylistViewController

Since we updated the app to target iOS 13, we can use the new diffable datasources.

In PlaylistViewController.swift:

    enum Section {
        case main
    }
    private var datasource: UITableViewDiffableDatasource<Section, PlaylistCellViewModel>!
    private var fetchedResultsController: NSFetchedResultsController<EpisodeEntity>!

Then, in viewDidLoad we can set this datasource up:

    datasource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, model) -> UITableViewCell? in
            let cell: PlaylistCell = tableView.dequeueReusableCell(for: indexPath)
            cell.configure(with: model)
            return cell
        })

And also trigger the fetch request:

    let context = PersistenceManager.shared.mainContext
    let store = SubscriptionStore(context: context)
    fetchedResultsController = NSFetchedResultsController(fetchRequest: store.playlistFetchRequest(),
        managedObjectContext: context,
        sectionNameKeyPath: nil,
        cacheName: nil)

    do {
        try fetchedResultsController.performFetch()
        updateSnapshot()
    } catch {
        print("Error fetching playlist: ", error)
    }

Next we'll create an updateSnapshot method to take the results and apply it to the datasource:

private func updateSnapshot() {
    let episodes = fetchedResultsController.fetchedObjects ?? []
    var snapshot = NSDiffableDataSourceSnapshot<Section, PlaylistCellViewModel>()
    snapshot.appendSections([.main])

    let viewModels = episodes.map(PlaylistCellViewModel.init)
    snapshot.appendItems(viewModels, toSection: .main)

    datasource.apply(snapshot, animatingDifferences: true, completion: nil)
}

This works and we can see our episodes in the PlaylistViewController, however we have a problem: if we unsubscribe from one of these podcasts and then return to the playlist screen, we'll get a crash.

The issue is that we've deleted data that is still being referenced on the playlist screen. To fix this we'll have to allow the playlist view controller to react to changes to the fetch request.

Responding to data changes

In viewDidLoad, where we set up the fetchedResultsController we need to make ourselves the delegate:

fetchedResultsController.delegate = self

Then we need to add our conformance:

extension PlaylistViewController : NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        updateSnapshot()
    }
}

Now we will get an update when the data changes and the app no longer crashes. However, now we have a warning to address:

[TableView] Warning once only
UITableView was told to layout its visible cells and other contents with being in a view hierarchy...

This is a fantastic warning, and tells us that we shouldn't reload the snapshot while the tableview is off-screen. Instead, we'll mark a flag when we need to update the snapshot, and only do this when the view has appeared:

First, a property:

private var needsSnapshotUpdate = false

Then, in viewDidLoad:

    do {
        try fetchedResultsController.performFetch()
        // Replace updateSnapshot() with
        needsSnapshotUpdate = true
    } catch {
        print("Error fetching playlist: ", error)
    }

Then we can update our delegate method to do the same (but only if we're off-screen):

if view.window != nil {
    updateSnapshot()
} else {
    needsSnapshotUpdate = true
}

Finally, in viewDidAppear:

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if needsSnapshotUpdate {
            updateSnapshot()
            needsSnapshotUpdate = false
        }
    }

And with this change we now respond to updates, but only apply the snapshot once the view has been added to a window.

Handling New Subscriptions

There's one last change we need to make here. Subscribing to new podcasts triggers an import on a background context of all the episodes. These changes are never merged into the main context, so our fetched reuslts controller doesn't know about them.

We can do this automatically, by telling our main context to automatically merge changes from the store. In our PersistenceManager we can tell it to do this when our context is first set up:

    func initializeModel(then completion: @escaping () -> Void) {
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error {
                fatalError("Core Data error: \(error.localizedDescription)")
            } else {

                // Add this
                self.persistentContainer.viewContext.automaticallyMergesChangesFromParent = true

                self.isLoaded = true
                print("Loaded Store: \(storeDescription.url?.absoluteString ?? "nil")")
                completion()
            }
        }
    }

Now when we run the app and subscribe to new shows, the playlist is automatically updated to include the new episodes.

blog comments powered by Disqus