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