Episode #403

Setting up Core Data to Save Subscriptions

Series: Making a Podcast App From Scratch

24 minutes
Published on August 1, 2019

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

In this episode we set up a Core Data model for persisting podcast subscriptions. We'll cover the various ways Xcode generates model classes for us and work on saving and loading podcast subscriptions so that the subscribe button behaves as it should.

Episode Links

Creating a Core Data Model

We will create a new Core Data model Subscriptions with podcast and subscription as entities. We will add a few attributes to represent podcasts with id and feedURLString as non-optional. Also, we will add a date attribute to subscription entity which will be used to track the subscriptions for the podcast.

To have a one-to-one relationship between the subscription for one podcast we will create an inverse relationship between podcast and subscription. We will set the cascade delete rule for this relationship. This will ensure the deletion of subscription if a podcast is deleted, or the deletion of the podcast while a subscription is deleted.

To add the custom methods and classes we will set the code generator as "Category/Extension". Next we will create an NSManagedObject subclass for subscription model with our podcast and subscription entities.

Saving a Subscription

Now we will create an object to save our subscriptions. We will create a static shared instance of the store and a persistent container to initialize it.

class SubscriptionStore {
    static var shared = SubscriptionStore()

    private let persistentContainer: NSPersistentContainer

    var mainContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

    private init() {
        persistentContainer = NSPersistentContainer(name: "Subscriptions")
    }

    func initializeModel() {
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error {
                fatalError("Core Data error: \(error.localizedDescription)")
            } else {
                print("Loaded Store: \(storeDescription.url?.absoluteString ?? "nil")")
            }
        }
    }
}

Initializing the Data Model

After creating our SubscriptionStore model, we will initialize it in AppDelegate.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    Theme.apply(to: window!)
    SubscriptionStore.shared.initializeModel()

    return true
}

Setting the Initial Stage

To set the initial stage of the subscription button while we load a podcast, we will create a view model which will set the data for the subscribed podcast and also helps in wrapping the podcast episode.

struct PodcastViewModel {
    private let podcast: Podcast
    let isSubscribed: Bool

    init(podcast: Podcast, isSubscribed: Bool) {
        self.podcast = podcast
        self.isSubscribed = isSubscribed
    }

    var title: String? {
        return podcast.title
    }

    var author: String? {
        return podcast.author
    }

    var genre: String? {
        return podcast.primaryGenre
    }

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

    var artworkURL: URL? {
        return podcast.artworkURL
    }

    var episodes: [EpisodeCellViewModel] {
        return podcast.episodes.map(EpisodeCellViewModel.init)
    }
}

Tracking the Podcast

To keep the track of podcast, we will create a view model having all the details of the podcast along with the subscription details obtained from the Core Data.

var podcastLookupInfo: PodcastLookupInfo!

private let subscriptionStore = SubscriptionStore.shared

private var podcast: Podcast? {
    didSet {
        podcastViewModel = podcast.flatMap {
            PodcastViewModel(podcast: $0,
                             isSubscribed: subscriptionStore.isSubscribed(to: $0.id))
        }
    } information 
}

private var podcastViewModel: PodcastViewModel? {
    didSet {
        headerViewController.podcast = podcastViewModel
        tableView.reloadData()
    }
}

To check if the podcast is subscribed, we will lookup in the Core Data for the subscription for a given podcast id. As we expect only one record while fetching the subscription request, we will set the .fetchLimit to 1. We will also set the format while fetching the record and try fetching the first record.

func isSubscribed(to id: String) -> Bool {
    do {
        return try findSubscription(with: id) != nil
    } catch {
        return false
    }
}

func findSubscription(with podcastId: String) throws -> SubscriptionEntity? {
    let fetch: NSFetchRequest<SubscriptionEntity> = SubscriptionEntity.fetchRequest()
    fetch.fetchLimit = 1
    fetch.predicate = NSPredicate(format: "podcast.id == %@", podcastId)
    return try mainContext.fetch(fetch).first
}

Next we will make the header oblivious to the Podcast model by using the reference of PodcastViewModel instead of Podcast. We will also set the selection of the subscribe button when a podcast is subscribed.

private func updateUI(for podcast: PodcastViewModel) {
    subscribeButton.isEnabled = true
    subscribeButton.isSelected = podcast.isSubscribed

    let options: KingfisherOptionsInfo = [.transition(.fade(1.0))]
    podcast.artworkURL, placeholder: nil, options: options)

    titleLabel.text = podcast.title
    authorLabel.text = podcast.author
    genreLabel.text = podcast.genre
    descriptionLabel.text = podcast.description
}

Subscribing to a Podcast

Now we are set to subscribe to a podcast. We will create a function returning the SubscriptionEntity, marked with @discardableResult attribute.
We will then create a podcast using mainContext and assign the details of the podcast model to its entity object. Next, we will create a subscription by assigning date and podcast entity.

@discardableResult 
func subscribe(to podcast: Podcast) throws -> SubscriptionEntity {
    let podcastEntity = PodcastEntity(context: mainContext)
    podcastEntity.id = podcast.id
    podcastEntity.title = podcast.title
    podcastEntity.podcastDescription = podcast.description
    podcastEntity.author = podcast.author
    podcastEntity.genre = podcast.primaryGenre
    podcastEntity.artworkURLString = podcast.artworkURL?.absoluteString
    podcastEntity.feedURLString = podcast.feedURL.absoluteString

    let subscription = SubscriptionEntity(context: mainContext)
    subscription.dateSubscribed = Date()
    subscription.podcast = podcastEntity

    try mainContext.save()

    return subscription
}

Next, we handle when the subscribe button is tapped..

@objc private func subscribeTapped(_ sender: SubscribeButton) {
    guard let podcast = podcast else { return }
    let isSubscribing = !sender.isSelected
    do {

        if isSubscribing {
            try subscriptionStore.subscribe(to: podcast)
        } else {
            try subscriptionStore.unsubscribe(from: podcast)
        }
        sender.isSelected.toggle()
    } catch {
        let action = isSubscribing ? "Subscribing to Podcast" : "Unsubscribing from Podcast"
        let alert = UIAlertController(title: "Error \(action)", message: error.localizedDescription, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
    }
}

Hook Into Tapped Event

Finally, we will add the event handler for the subscribe button while it is been tapped. We will subscribe the podcast while the button is selected and unsubscribe while it is in the deselected state.

override func viewDidLoad() {
    super.viewDidLoad()

    tableView.backgroundColor = Theme.Colors.gray5
    tableView.separatorColor = Theme.Colors.gray4

    headerViewController = children.compactMap { $0 as? PodcastDetailHeaderViewController }.first
    headerViewController.subscribeButton.addTarget(self, action: #selector(subscribeTapped(_:)), for: .touchUpInside)

    loadPodcast()
}

// MARK: - Event Handling
@objc private func subscribeTapped(_ sender: SubscribeButton) {
    guard let podcast = podcast else { return }
    let isSubscribing = !sender.isSelected
    do {

        if isSubscribing {
            try subscriptionStore.subscribe(to: podcast)
        } else {
            try subscriptionStore.unsubscribe(from: podcast)
        }
        sender.isSelected.toggle()
    } catch {
        let action = isSubscribing ? "Subscribing to Podcast" : "Unsubscribing from Podcast"
        let alert = UIAlertController(title: "Error \(action)", message: error.localizedDescription, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
    }
}

Now we will create a function to unsubscribe from the podcast.

func unsubscribe(from podcast: Podcast) throws {
    if let sub = try findSubscription(with: podcast.id) {
        mainContext.delete(sub)
        try mainContext.save()
    }
}

This episode uses Xcode 10.2.1, Swift 5.0.