13.
Local Notifications
Written by Scott Grosch
Although you’ve put together the key concepts up to this point, there is one more category of notifications to cover: local notifications.
While the vast majority of notifications displayed on your device are remote notifications, it’s also possible to display notifications originating from the user’s device, locally. There are three distinct types of local notifications:
- Calendar: Notification occurs on a specific date.
- Interval: Notification occurs after a specific amount of time.
- Location: Notification occurs when entering a specific area.
While less frequently used, local notifications still play an important role for many apps. You should also challenge the immediate notion of using a remote notification. For example, if you provide a food-ordering app, it might want to tell the user that the food is ready to pick up. Will the restaurant really take action when the food is ready or could you, instead, use an interval-based local notification to send the alert after a 10-minute waiting period?
You Still Need Permission!
Even though the notification is created and delivered locally on the user’s device, you must still obtain permission to display local notifications. Just like remote notifications, the user can grant or remove permissions at any time.
The only difference when requesting permissions locally is that you do not call the registerForRemoteNotifications method on success:
func registerForLocalNotifications(application: UIApplication) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(
options: [.badge, .sound, .alert]) { granted, _ in
guard granted else { return }
// Take action here
}
}
Note: Since the user may revoke permissions at any time, view controllers creating a local notification must check for permission in
viewDidAppear. If you’re using SwiftUI, check for permissions inside theonAppear(perform:)method on your root view.
Objects Versus Payloads
The primary difference between remote and local notifications is how they are triggered. You’ve seen that remote notifications require some type of external service to send a JSON payload through APNs. Local notifications use all the same type of data that you provide in a JSON payload but they instead use Swift objects to define what is delivered to the user.
Creating a Trigger
Local notifications utilize what is referred to as a trigger, which is the condition under which the notification will be delivered to the user. There are three possible triggers, each corresponding to one of the notification types:
UNCalendarNotificationTriggerUNTimeIntervalNotificationTriggerUNLocationNotificationTrigger
All three triggers contain a repeats property, which allows you to fire the trigger more than once.
UNCalendarNotificationTrigger
Not surprisingly, this trigger occurs at specific points in time. While you might assume that you’d be using a Date to specify when the trigger goes off, you’ll actually use DateComponents. A Date distinctly specifies one specific point in time, which isn’t always helpful for a trigger. If you’re using a calendar trigger, it’s more likely that you only have parts of a date.
For example, you might want to trigger at 8:30 in the morning, or just on a Monday. Using DateComponents lets you specify as much of the requirements as necessary without being too explicit about the rest.
To have an alarm go off every Monday at 8:30 a.m., you’d write code like this:
let components = DateComponents(hour: 8, minute: 30, weekday: 2)
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: true)
UNTimeIntervalNotificationTrigger
This trigger is perfect for timers. You might want to display a notification after 10 minutes, rather than at a specific time. You just tell iOS how many seconds in the future the notification should be delivered. If you need the trigger to happen at a specific time, like 2 p.m., you should be using the UNCalendarNotificationTrigger instead to avoid numerous time zone issues related to dates.
In this example, after ordering food from an online service, you’ll want to let the end user know to head out in 10 minutes to pick it up:
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: 10 * 60,
repeats: false)
UNLocationNotificationTrigger
If you’re a fan of geocaching, this one’s for you! Utilizing this trigger allows you to specify a CLCircularRegion that you wish to monitor. When the device enters said area, the notification will fire. You need to know the latitude and longitude of the center of your target location as well as the radius that should be used. Those three items define a circular region on the map, which iOS will monitor for entry.
Note: You must have authorization to use Core Location and must have permission to monitor the user’s location while they’re using the app. You do not need to request to always have permission as just regions are being monitored.
You’ll also need to let iOS know whether you care if the user is entering the region, exiting or both.
Please see “Core Location Tutorial for iOS: Tracking Visited Locations” (bit.ly/2MLc1GG) for more information on Core Location, privacy concerns and requesting permissions if you’re not already familiar with that framework.
If, for example, you’d like to schedule a notification whenever the user enters a 1 mile radius around 1 Infinite Loop, Cupertino, California, you’d use code similar to the following:
let oneMile = Measurement(value: 1, unit: UnitLength.miles)
let radius = oneMile.converted(to: .meters).value
let coordinate = CLLocationCoordinate2D(
latitude: 37.33182,
longitude: -122.03118)
let region = CLCircularRegion(
center: coordinate,
radius: radius,
identifier: UUID().uuidString)
region.notifyOnExit = false
region.notifyOnEntry = true
let trigger = UNLocationNotificationTrigger(
region: region,
repeats: false)
Defining Content
Excellent; you now know when the trigger is going to go off. It’s time to tell iOS what should be presented in the notification. This is where the UNMutableNotificationContent class comes into play. Be sure to note the “Mutable” in that class’s name. There’s also a class called UNNotificationContent, which you won’t use here or you’ll end up with compiler errors.
You can think of this class as the equivalent of the JSON payload used in remote notifications. The elements from the aps dictionary exist as properties right on the object. For your custom content, you simply add that to the userInfo dictionary.
If you worked through Chapter 12, “Putting It All Together,” then you’ll remember working with a payload like so:
{
"aps" : {
"alert" : {
"title" : "New Calendar Invitation"
},
"badge" : 1,
"mutable-content" : 1,
"category" : "CalendarInvite"
},
"title" : "Family Reunion",
"start" : "2018-04-10T08:00:00-08:00",
"end" : "2018-04-10T12:00:00-08:00",
"id" : 12
}
You’d exactly mimic that same data with a local notification using the following code:
let content = UNMutableNotificationContent()
content.title = "New Calendar Invitation"
content.badge = 1
content.categoryIdentifier = "CalendarInvite"
content.userInfo = [
"title": "Family Reunion",
"start": "2018-04-10T08:00:00-08:00",
"end": "2018-04-10T12:00:00-08:00",
"id": 12
]
Notice how everything outside of your aps dictionary, meaning - your custom content, falls under the userInfo dictionary.
Playing Sounds
If you’d like your notification to play a sound when it’s delivered, you must either store the file in your app’s main bundle, or you must download it and store it in the Library/Sounds subdirectory of your app’s container directory. Generally, you’ll just want to use the default sound:
content.sound = UNNotificationSound.default
Please refer back to Chapter 3, “Remote Notification Payload,” for full details on the requirements around playing sounds and which formats are supported.
Adding Localization
There’s one small “gotcha” when working with localization and local notifications. Consider the case wherein the user’s device is set to English, and you set the content to a localized value. Then, you create a trigger to fire in three hours. An hour from then, the user switches their device back to Arabic. Suddenly, you’re showing the wrong language!
The solution to the above problem is to not use the normal NSLocalizedString methods. Instead, you should use localizedUserNotificationString(forKey:arguments:) from NSString. The difference is that the latter method delays loading the localized string until the notification is actually delivered, thus ensuring the localization is correct.
Note: Always use
localizedUserNotificationString(forKey:arguments:)when localizing local notifications.
Grouping Notifications
If you’d like your local notification to support grouping, simply set the threadIdentifier property with a proper identifier to group them by.
content.threadIdentifier = "My group identifier here"
Scheduling
Now that you’ve defined when the notification should occur and what to display, you simply need to ask iOS to take care of it for you:
let identifier = UUID().uuidString
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error {
// Handle unfortunate error if one occurs.
}
}
Each request needs to have a unique identifier so that you can refer to it later on if you wish to cancel the notification before it’s actually fired. A UUID is unique by definition, so it’s a great choice to use.
Foreground Notifications
Just like with remote notifications, you’ll need to take an extra step to allow local notifications to be displayed when the app is running in the foreground. It’s the exact same code that remote notifications use.
Simply adopt UNUserNotificationCenterDelegate somewhere in your app and implement the following method:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification)
async -> UNNotificationPresentationOptions
{
return [.badge, .sound, .banner]
}
You can, of course, take other actions here such as updating the user interface directly based on what the notification is for!
The Sample Platter
That seems like quite enough reference material. Time to write some code! Please open up the starter project and set your team ID as discussed in Chapter 7, “Expanding the Application.”
You’ll notice that there’s an awful lot of code in the starter project, but don’t let that scare you. The intent of this chapter is for you to learn about local notifications, not have you spend a ton of time building a SwiftUI app to handle all three types of local notifications.
The general goal for this app is to allow the user to pick one of the three types of notifications, configure it and then see on the main view whether or not it’s been delivered. The user will also be able to cancel any notifications that are still pending to be delivered.
Requesting Permission
Just like with remote notifications, the first task you’ll need to take care of is getting your user’s permission to send them local notifications.
Open LocalNotifications.swift. This file will manage everything related to local notifications. Start by adding the method which will request permissions:
func requestAuthorization() async throws {
authorized = try await center.requestAuthorization(options: [.badge, .sound, .alert])
}
The code is essentially the same as you’ve done throughout the book. The only difference is that you’re just directly updating the authorized property, which will then be published to any other objects which are monitoring the value.
Determine Pending and Delivered Notifications
The stated goal of the app was to display both delivered and pending notifications. To identify all notifications which are pending, you’ll use the pendingNotificationRequests method.
Create a new property in LocalNotifications to hold the list of pending notifications:
@Published var pending: [UNNotificationRequest] = []
Then populate that property by adding the following:
@MainActor
func refreshNotifications() async {
pending = await center.pendingNotificationRequests()
}
You ask the notification center to provide a list of requests which have been scheduled, but not yet delivered. By assigning the results to the pending property, iOS will publish a notification to anything which is watching for changes.
Retrieving the list of already delivered notifications is essentially the same code, just a different property and method. Add a property to hold the notifications:
@Published var delivered: [UNNotification] = []
Next, add the following code to the end of refreshNotifications:
delivered = await center.deliveredNotifications()
While the code for both pending and delivered notifications looks almost exactly the same, take note of the fact that pending notifications are of type UNNotificationRequest, whereas delivered notifications are UNNotification. The UNNotification has a request property that lets you get at the UNNotificationRequest details.
Note: Once a user deletes a notification from the Notification Center on their device, it will no longer appear in the list of delivered notifications.
Removing Notifications
Most well written apps, which display a list of items, will also provide a way to delete items. The user might have made a mistake in scheduling the notification, for example. Add the following methods to LocalNotifications.swift:
func removePendingNotifications(identifiers: [String]) async {
center.removePendingNotificationRequests(withIdentifiers: identifiers)
await refreshNotifications()
}
func removeDeliveredNotifications(identifiers: [String]) async {
center.removeDeliveredNotifications(withIdentifiers: identifiers)
await refreshNotifications()
}
UNUserNotificationCenter provides two separate methods to remove a notification. You must explicitly specify whether you’re removing a pending or delivered notification as identifiers are not necessarily unique between the two types.
Be sure to refresh the notifications after removing items so that the pending and delivered lists are updated properly.
Configuring the Main View
It’s time to make use of the class you just created. Open up ContentView.swift and add the following line to the top of the class:
@StateObject private var localNotifications = LocalNotifications()
Marking localNotifications with the @StateObject property wrapper lets SwiftUI know that the containing class owns management of the property.
Next, find the line that configures the sheet. When the sheet is dismissed, you’ll want to refresh the notifications.
.sheet(isPresented: $showSheet) {
Task { await localNotifications.refreshNotifications() }
To display notifications in the View, your users have to have granted permissions. Request the permissions when the view appears by adding the following code to the end of body:
.task { try? await localNotifications.requestAuthorization() }
Now, when the view appears, the app will check for authorization. If authorization isn’t granted you won’t be able to display anything.
Next, add the following block of code inside the Group at the top of the body:
if !localNotifications.authorized {
Text("This app only works when notifications are enabled.")
} else {
}
Hopefully that Text is never displayed for long! When permissions are granted, you’ll want to display a list of pending notifications. Add a List inside of the else block:
List {
// 1
Section(header: Text("Pending")) {
// 2
ForEach(localNotifications.pending, id: \.identifier) {
// 3
HistoryCell(for: $0)
}
}
}
// 4
.listStyle(GroupedListStyle())
The preceding code accomplishes the following:
- You’re creating a
Sectioninside of theListwith a title of Pending to show the notifications which are scheduled, but not yet delivered. - You’re iterating over each pending notification. The
ForEachcall requires that the data being iterated over conforms toIdentifiable. If it doesn’t, then you have to explicitly tell it the unique identifier. SinceUNNotificationRequestprovides a unique identifier, you can simply use that. - The notification is displayed via the supplied
HistoryCellview. - You style the
Listmakes to look like a grouped list.
Now add another Section, just below the first, to display the delivered notifications.
Section(header: Text("Delivered")) {
ForEach(localNotifications.delivered, id: \.request.identifier) {
HistoryCell(for: $0.request)
}
}
Notice that this time, the ID provided is slightly different. A UNNotification doesn’t have an identifier. However, it does have a reference to the request which was sent. Thus, you can use that for the identifier.
Now that you can display the row, let’s add a way to remove them. Just after the first ForEach, tell SwiftUI which method to call when a row is deleted by adding this line:
.onDelete(perform: deletePendingNotification)
Then, add the appropriate method to your struct:
private func deletePendingNotification(at offsets: IndexSet) {
let identifiers = offsets.map {
localNotifications.pending[$0].identifier
}
Task {
await localNotifications.removePendingNotifications(identifiers: identifiers)
}
}
When deleting a row, SwiftUI provides you with a list of integers via an IndexSet. That set represents each row which has been deleted. Using map, you transform the index of the row, which is also the index of the pending array, into the notifications identifier. Once you have the list of identifiers which should be removed you can pass them to the class you wrote to handle notifications.
Just like when configuring the sheet, you need to handle the fact that the delete to swipe action is not async-aware.
At first glance it seems odd to receive an IndexSet. If you swipe a row, that’s a single value. It’s nice to provide your users a way to delete multiple rows, though.
Find the line in the body which looks like this:
.navigationBarItems(trailing: Button {
And replace it with this:
.navigationBarItems(leading: EditButton(), trailing: Button {
That simple change now gives your view a way to edit multiple items at once. Build and run the app.
As expected, you’re asked right away to grant permissions. Say yes…you know you want to. You should see your two List sections as well as an Edit button.
Scheduling Notifications
While there are more options available on the content of a notification, for the sample app, you’ll only be using the title, sound and badge properties of the UNMutableNotificationContent.
Creating Content
Edit the LocalNotifications.swift file to add the following code to the bottom of the class:
func scheduleNotification(trigger: UNNotificationTrigger, model: CommonFieldsModel) async throws {
let title = model.title.trimmingCharacters(in: .whitespacesAndNewlines)
let content = UNMutableNotificationContent()
content.title = title.isEmpty ? "No Title Provided" : title
if model.hasSound {
content.sound = UNNotificationSound.default
}
if let number = Int(model.badge) {
content.badge = NSNumber(value: number)
}
}
Pretty straightforward, right? You set the content object as described earlier in this chapter.
Adding the Request
Now that the content and trigger are in place, all that’s left to do is create the request and hand it off to UNUserNotificationCenter. You’ve already seen the code for this, so it shouldn’t be anything too shocking. Add the following to the end of the method:
let identifier = UUID().uuidString
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger)
try await center.add(request)
While the request has to have a unique identifier, you don’t really have a need to know what it is, so using a UUID is a great choice here. If the request wasn’t successfully added to the list of pending local notifications, then you’ll tell the caller about the issue via the closure.
Head back over to ContentView.swift and call the method you just wrote from scheduleNotification(trigger:model:)
Task {
do {
try await localNotifications.scheduleNotification(trigger: trigger, model: commonFields)
} catch {
alertText = AlertText(text: error.localizedDescription)
}
}
If the notification failed to schedule, iOS will throw an exception with the failure. You then create an AlertText from that message.
Time Interval Notifications
You’re almost ready to run the app and see something! The first notification trigger to implement is the UNTimeIntervalNotificationTrigger. With the methods you just created, you’ll only need two lines of code now to set up a time-interval trigger. Open TimeIntervalView.swift and take a look at doneButtonTapped . Once the number of seconds to wait is known, you need to create the trigger just like you learned about earlier in the chapter.
Add this code to the end of the method, right before dismissing the modal:
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: interval,
repeats: model.isRepeating)
try await onComplete(trigger, model)
Calling the completion handler causes the scheduleNotification(trigger:model:) method in ContentView.swift to be called.
It’s finally time to try things out! Build and run the app. You should have no errors at this point. There’s a single warning which you’ll fix up in a bit.
Tap the + button in the navigation bar and choose to add a timed trigger.
You’ll be presented with a simple screen where you can specify how many seconds in the future the notification should trigger. While you must specify a title, the badge is optional. If you include a numeric value, then the app icon will be badged appropriately. If you specify a 10-second wait period and tap the Done button, you’ll be returned to the home screen with a view like the following:
Wait for 10 seconds and the notification should appear. What? It didn’t appear? Why not?
Just like when handling remote push notifications, local notifications will not appear when the app is running unless you tell it to.
Head back over to LocalNotifications and add this extension to the bottom of the file.
extension LocalNotifications: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
return [.banner, .badge, .sound]
}
}
The code is no different than what you have done for remote notifications. All that’s left to do is assign the delegate. Add an initializer to LocalNotifications:
override init() {
super.init()
center.delegate = self
}
Build and run again. This time, after waiting the appropriate amount of time, you should see the notification appear.
Location Notifications
Handling locations takes just a bit more work.
Requesting Location Permissions
To enable the location to trigger a notification, you need to know the user’s location. This means you first need to ask the user’s permission to access their location.
Open the Info.plist file and add the privacy key for access to the user’s location. The key’s name is “Privacy - Location When In Use Usage Description”.
Set its value to the string: “To know when you arrive at the target region.”
The LocationManager.swift file contains the boilerplate code necessary for requesting location authorization. You just need to make use of it.
Edit LocationLookupView.swift and grab a copy of the location manager from the environment:
@EnvironmentObject private var locationManager: LocationManager
The starter project put that object into the environment for you via the LocalNotificationsApp.swift file.
You shouldn’t display the location form if the user isn’t allowing location tracking, so replace the body in LocationLookupView.swift to wrap its contents in an if check:
if locationManager.authorized {
LocationForm(onComplete: onComplete)
} else {
Text(locationManager.authorizationErrorMessage)
}
If the user has authorized location tracking then you’ll display the location form. If they have not, then you’ll display the appropriate error message.
You’ll want to ask for permissions as soon as this view appears, which means adding the normal SwiftUI onAppear call. You can’t directly place that on an if block though, so wrap it in a Group instead.
Group {
if locationManager.authorized {
LocationForm(onComplete: onComplete)
} else {
Text(locationManager.authorizationErrorMessage)
}
}
.onAppear(perform: locationManager.requestAuthorization)
Build and run your app. This time, after tapping the + button, choose Location. You should be presented with a request to allow your app to determine the user’s location.
Now, you can start working on the actual trigger. In LocationForm.swift, you’ll again edit the doneButtonTapped action to create the trigger. Add the following code to the method just before the last line which dismisses the modal and inside the Task block:
guard let coordinates = model.coordinate else {
return
}
let region = CLCircularRegion(
center: coordinates,
radius: distance,
identifier: UUID().uuidString)
region.notifyOnExit = model.notifyOnExit
region.notifyOnEntry = model.notifyOnEntry
let trigger = UNLocationNotificationTrigger(region: region, repeats: commonFields.isRepeating)
try await onComplete(trigger, commonFields)
Similar to a timed notification, you’re pulling values from the model and then creating the trigger. Scheduling is handled by your callback, just like before.
Build and run, again choosing a Location notification. The first screen you see allows you to specify an address and see a view of it on the map. Enter any address you like and tap the Search button. If you gave a valid address, you should see your destination.
I don’t know about you, but I’m headed to the Louvre in Paris!
After you’ve entered a valid address, tap on the Correct button in the navigation bar.
Location notifications are based on a circular radius, so you’ll have to specify how many meters you’d like to use and provide a title. The badge is again optional but, this time, you can also identify if you want a notification when you enter the area, leave the area or both. After tapping Done, you should see your trigger in the Pending section. To complete this chapter, you’ll have to book a flight to Paris and head over to the Louvre.
Calendar Notifications
Just one notification to go! Calendar-based local notifications, as discussed earlier, use the DateComponents struct to specify exactly when the notification will trigger. If you’ve worked with DateComponents before, you know how many different properties are available to you. For the sample app, to keep things simple, you’re just using hours, minutes and seconds.
In CalendarView.swift, you’ll see that doneButtonTapped has pulled out the details of the time for you already. All you’ve got to do is create the trigger to fire at the right time. Add the following code at the end of the method, just before dismissing the alert:
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: commonFields.isRepeating)
try await onComplete(trigger, commonFields)
Build and run your app one final time, and you’ll be able to schedule a calendar-based local notification.
You’ve managed to utilize all the local notification types in a sample app. Hopefully, you’ve seen how easy the notification-related code is to implement.
Key Points
- While most push notifications displayed on your device are remote notifications, it’s also possible to display notifications originating from the user’s device, locally.
- Local notifications are less frequently used but they still merit your understanding. There may be times when a user needs a notification (like a reminder) free of any action being taken.
- Calendar notifications occur on a specific date or time.
- Interval notifications occur after a specific amount of time.
- Location notifications occur when entering a specific area.
- Even though the notification is created and delivered locally on the user’s device, you must still obtain permission to display notifications.
Where to Go From Here?
In your own apps, you’ll likely want to explore other concepts such as custom sounds, more options around calendar selection, and even custom actions and user interfaces. Refer back to each of the following chapters for information on how to add each feature to your local notifications:
- Chapter 9, “Custom Actions.”
- Chapter 10, “Modifying the Payload.”
- Chapter 11, “Custom Interfaces.”