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]) {
[weak center, weak self] granted, _ in
guard granted, let center = center, let self = self
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.
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 class 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 have the trigger fire 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, exiting or both.
Please see “Core Location Tutorial for iOS: Tracking Visited Locations” (https://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.
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.
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
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 = 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 conform your AppDelegate to UNUserNotificationCenterDelegate and implement the following method:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.badge, .sound, .alert])
}
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 enable push notifications as described in Chapter 4, “Xcode Project Set Up,” 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 an iOS app to handle all three types of local notifications.
If you build the app right now, you’ll get four warnings from Xcode about values being defined but never used. Don’t worry as those will all go away as you build out the app.
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.
Configuring the main UITableView
Just like with remote notifications, the first task you’ll need to take care of is getting your user’s permission to send them notifications.
Open up ViewController.swift and take care of that in viewDidAppear(_:), by adding the following code to the method:
center.requestAuthorization(
options: [.alert,.sound,.badge],
completionHandler: { [weak self] (granted, error) in
guard let self = self else { return }
if granted {
self.refreshNotificationList()
self.center.delegate = self
}
self.addButton.isEnabled = granted
self.refreshButton.isEnabled = granted
})
You’ll want to be able to show the notifications that have already been delivered as well as those that are still pending. As the state of notifications will change, you’ll also want the end user to be able to update that list. In the refreshNotificationList method, add the following code to ask iOS to tell you about all of the notifications that are still pending:
center.getPendingNotificationRequests { [weak self] requests in
guard let self = self else { return }
self.pending = requests
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
Note: You must dispatch to the main queue to make any UI changes as the completion handler is not guaranteed to run on the main thread.
You’re simply storing the current list of notifications that are queued, but not yet delivered, and then asking the table to reload its content. You should also grab the notifications that are already delivered. Add the following to the bottom of the method:
center.getDeliveredNotifications { [weak self] requests in
guard let self = self else { return }
self.delivered = requests
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
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.
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.
There’s just one more modification that you’ll need to make in this file: allowing notifications to be removed.
There are two very similar methods on UNNotificationCenter to handle that. Look in the tableView(_:commit:forRowAt:) method and you’ll see most of it has been stubbed out for you. This method will get called if the user swipes to delete the table view cell. You’ll handle cancelling a pending request in the code block for section zero.
Just after the let request = pending[indexPath.row] is where you’ll want to add the following code to cancel the notification:
let identifiers = [request.identifier]
center.removePendingNotificationRequests(
withIdentifiers: identifiers)
As discussed earlier, each notification is created with a unique identifier so that you can cancel it if necessary.
The delivered notifications in the first section are handled basically the same way. Call just a slightly different method on UNNotificationCenter, and you’ll notice that to get the request you’ll need to access the request property discussed earlier. Add the following code inside the else block, just below the request declaration:
let identifiers = [request.identifier]
center.removeDeliveredNotifications(
withIdentifiers: identifiers)
You don’t need to remove anything from the pending or delivered arrays, nor do you need to remove rows from the table as the refreshNotificationList method will handle that for you.
Scheduling
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. As a good programmer, you always follow the KISS principle, right?
Open up NotificationScheduler.swift. Multiple great features of Swift are used here. Instead of sub-classing to use a common method, a protocol is used to describe the required method for scheduling notifications. Then, a default implementation is provided via the extension shown. Finally, you’ll notice there is a condition on the extension so that it only applies to classes that inherit from UIViewController. The where clause is necessary, as you’ll need to access the navigationController that all UIViewControllers have.
Creating content
You’ll create the content in scheduleNotification. You’ve already been passed the appropriate UNNotificationTrigger, so now you’ll need to generate the content that goes with it.
Add the following code to the bottom of the method:
let content = UNMutableNotificationContent()
content.title = title
if sound {
content.sound = UNNotificationSound.default
}
if let badge = badge, let number = Int(badge) {
content.badge = NSNumber(value: number)
}
Pretty straightforward, right?
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)
UNUserNotificationCenter.current().add(request) {
[weak self] error in
guard let self = self else { return }
if let error = error {
DispatchQueue.main.async {
let message = """
Failed to schedule notification.
\(error.localizedDescription)
"""
UIAlertController.okWithMessage(message,
presentingViewController: self)
}
} else {
DispatchQueue.main.async {
self.navigationController?.popToRootViewController(
animated: true)
}
}
}
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 warn the user about that using another provided helper method from Extensions/UIAlertController+Ext.swift. If it was added, which should always be the case with valid content, then you pop everything off of the UI’s navigation stack and take the user back to the main table view.
Note: You must dispatch to the main queue to make any UI changes as the completion handler is not guaranteed to run on the main thread.
If you’re not using extensions in Swift yet, then hopefully the two that were provided for you have helped show the great power they provide, as well as the cleanliness that they add to the rest of your code!
Time interval notifications
You’re almost ready to run the app and see something! The first location notification trigger to implement is the UNTimeIntervalNotificationTrigger. With the extension you just created, you’ll only need two lines of code now to set up a time-interval trigger. Open TimeIntervalViewController.swift and take a look at the doneButtonTouched method. Once the number of seconds to wait is known, you need to create the trigger, just like you learned about a few pages back. Add this code to the end of the method:
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: interval,
repeats: repeats.isOn)
scheduleNotification(
trigger: trigger,
titleTextField: notificationTitle,
sound: sound.isOn,
badge: badge.text)
As you’ll need to repetitively get the integer value from a UITextField, the starter project provides a few helper methods for that in Extensions/UITextField+Ext.swift.
The values are all taken from the UI controls and then handed over to your extension. The method is kept nice and clean, and it’s immediately obvious what is happening when the Done button is pressed.
Note: When working on your local notification, as opposed to remote notifications, you don’t need a physical device when testing your notification code, and may simply use your Simulator! Super helpful.
It’s finally time to try things out! Build and run the app. You should have no errors and only one warning left.
As expected, you’re asked right away to grant permissions. Say yes…you know you want to.
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:
And, then, 10 seconds later you’ll be notified!
Location notifications
Handling locations takes just a bit more work.
Location permissions
In order to get the location to trigger, you need to know the user’s location. This means you first need to ask the user’s permission to access their location.
Click on the PushNotifications project, and then the PushNotification target’s Info tab. Add the privacy key for access to the user’s location. The key’s name is “Privacy - Location When In Use Usage Description”, or you may simply paste in the NSLocationWhenInUseUsageDescription key.
Set its value to the string: “To know when you arrive at the target region.”
Now, you’ll need to ensure that you ask for those permissions in Location/LocationViewController.swift when the view appears. Replace the viewDidAppear(_:) method with the following code:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 1
address.isEnabled = false
doneButton.isEnabled = false
switch CLLocationManager.authorizationStatus() {
// 2
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
// 3
case .restricted:
let message =
"This device is not allowed to use location services."
UIAlertController.okWithMessage(message,
presentingViewController: self)
case .denied:
let message = "Location services must be enabled."
UIAlertController.okWithMessage(
message,
presentingViewController: self)
// 4
case .authorizedWhenInUse:
address.becomeFirstResponder()
address.isEnabled = true
doneButton.isEnabled = true
default:
break
}
locationManager.startUpdatingLocation()
}
The code looks a bit daunting, so here’s a step-by-step explanation:
- If permissions are not granted, then you don’t want the users to be able to interact with the Address or Done buttons, so disable them at the start.
- If you haven’t asked the user for location permission, ask them.
- Show an alert that tells informs the user of what’s going on when you don’t have access to their location.
- When they do have authorization, however, you can re-enable the buttons and bring up the keyboard for the address.
Now, you can start working on the actual trigger. In Location/LocationDetailsViewController.swift you’ll again edit the doneButtonTouched action to create the trigger. Add to the end of the method:
let region = CLCircularRegion(
center: coordinate,
radius: distance,
identifier: UUID().uuidString)
region.notifyOnExit = notifyOnExit.isOn
region.notifyOnEntry = notifyOnEntry.isOn
let trigger = UNLocationNotificationTrigger(
region: region,
repeats: repeats.isOn)
scheduleNotification(
trigger: trigger,
titleTextField: notificationTitle,
sound: sound.isOn,
badge: badge.text)
Similar to a timed notification, you’re simply pulling values from the UI and then creating the trigger. Scheduling is handled by your extension method just like before.
Build and run a second time but, this time around, after tapping the + button, choose 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 Done button in the navigation bar and you’ll see another beautiful data entry screen.
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 a 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 CalendarViewController.swift, you’ll see that the doneButtonTouched method 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:
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: repeats.isOn)
scheduleNotification(
trigger: trigger,
titleTextField: notificationTitle,
sound: sound.isOn,
badge: badge.text)
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.
- 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.”