The UserNotifications framework introduced in iOS 10 is really powerful. I’ve been using it lately since my app makes use of local notifications to remind users about their habits. I didn’t know, however, that there was the possibility to display a custom ViewController when the notifications were focused. Doing so is part of an app extension called Notification Content Extension.
What the app content extension displays
I wanted to display the progress of the habits associated with the notifications. Each habit might have a challenge of days, which is displayed to the user, showing its current day, how many days to finish the challenge, and how many days were missed and executed. The habit can also be a daily one, which means there’s no current challenge of days. In this case, the ViewController only shows how many days were executed.
Accessing the data of each habit from the extension
Habit Calendar, the app I’m building, uses Core Data as the Model layer. The problem is that extensions aren’t apps. They are only a portion of your app, and I couldn’t access my core data stack initialized in the AppDelegate from it.
To solve this issue, I had to do 4 things:
- Create an app group and share it with the app and the extension.
- Move my data store file (I’m using the SQLite store type) to the shared container of the app group. This was needed because my App was already in the AppStore.
- Use the new location in my DataController (the object holding the whole CoreData stack).
- Initialize it within the ViewController of the extension, and retrieve/modify the data.
The first step is described in this post, however, nowadays it can be done entirely from Xcode.
To move my store file, I had to do the following (this code is executed before loading the data stores, in my DataController class):
/// Shares the storage file with the app group (only in cases of an app update, before adding extensions). private func shareStoreIfNecessary() throws { let fileManager = FileManager.default // Get the default store url (when it's not shared with the app group). let previousStoreUrl = try? fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false ).appendingPathComponent("HabitCalendar") // Get the store url when it's shared with the app group. let appGroupStoreUrl = fileManager.containerURL( forSecurityApplicationGroupIdentifier: "group.tiago.maia.Habit-Calendar" )?.appendingPathComponent("HabitCalendar") // Share the file, if not yet shared and only in cases of an app update before the adition of app extensions. if let previousStoreUrl = previousStoreUrl, let appGroupStoreUrl = appGroupStoreUrl { if fileManager.fileExists(atPath: previousStoreUrl.path), !fileManager.fileExists(atPath: appGroupStoreUrl.path) { do { print("Moving store from private bundle to shared app group container.") try fileManager.moveItem( at: previousStoreUrl, to: appGroupStoreUrl ) } catch { assertionFailure("Couldn't share the store with the extensions.") throw error } } } }
Now it’s possible to load the core data stack in the extension and retrieve the necessary data for display:
func didReceive(_ notification: UNNotification) { guard let habitID = notification.request.content.userInfo["habitIdentifier"] as? String else { assertionFailure("The notification request must inform the habit id.") return } habitNameLabel.text = notification.request.content.title dataController = DataController { [weak self] error, persistentContainer in if error == nil { // Fetch the habit and display its data. let request: NSFetchRequest = HabitMO.fetchRequest() request.predicate = NSPredicate(format: "id = %@", habitID) guard let result = try? persistentContainer.viewContext.fetch(request), result.count > 0 else { assertionFailure(""" Trying to display the habit but it doesn't exist anymore. Any scheduled notifications should've been removed. """ ) return } self?.habit = result.first self?.display(result.first!) } } }
Useful links