Back to blog

Scheduling Local Notifications with Swift


Aasif Khan
By Aasif Khan | Last Updated on July 27th, 2023 6:41 am | 5-min read

How do you send and receive local notifications in your iOS app? In this tutorial, you’ll learn how to build local notifications into your iOS app with Swift.

We’ll focus on:

  • How to schedule and handle local notifications
  • Sensibly structuring the local notification code
  • Asking the user permission to send local notifications
  • How to set up triggers for local notifications
  • How to handle foreground and background notifications

We’ll also take a few intermezzo’s to discuss idempotence, app architecture, how to structure your code, how to deal with completion handlers, and what our code and an elevator have in common…

What’s a Local Notification?

Local notifications are short, text-based messages that appear at the top of your iPhone’s screen.

One of the most common types of notifications is a chat or text message, from iMessage or Telegram for example. Notifications also appear in iOS’s Notification Center, where you can view and respond to your most recent notifications.

On iOS, you can send and receive two types of notifications:

  • Push Notifications: Push notifications are sent via the internet, such as chat messages from a back-end webserver. They’re relayed to your iPhone via the web-based Apple Push Notification Service (APNS).
  • Local Notifications: Local notifications are scheduled and “sent” locally on your iPhone, so they aren’t sent over the internet. Typical use cases are calendar reminders or the iPhone alarm clock.

In this tutorial, we’ll focus specifically on local notifications. Local notifications are short, text-based messages that you can schedule from within your iOS app, to be delivered at a future point in time.

Local notifications are ideal for “local” functionality, such as reminding a user about an upcoming calendar event, a pending to-do list item, or an inspiring quote from your favorite health app.

This tutorial focuses specifically on local notifications, so it won’t show you how to use push notifications. If you’re looking for push notifications, check out OneSignal. An in case you’re looking for the Observer-Observable design pattern on iOS, check out How To Use Notification Center in Swift.

The Local Notification Manager Class

We’re going to build a component for your iOS app that can schedule local notifications. This LocalNotificationManager class is going to help us manage local notifications.

By building a separate component, we don’t have to clutter a view or view controller with code to manage local notifications. This is a great pattern for building a sensible app architecture. We’re also going to design an effective API, so we can “abstract away” some of the local notification’s code behind convenient functions.

Here’s the starting definition of the LocalNotificationManager class:

class LocalNotificationManager
{
var notifications = [Notification]()

}

Simple, right? The class has one instance property called notifications, of type [Notification], or an array of Notification objects. With the above syntax, the notifications property is initialized with an empty array.

We’ll define that Notification type next:

struct Notification {
var id: String
var title: String
var datetime: DateComponents
}

This Notification struct will help us organize the notifications better, as you’ll soon see.

Instead of passing different values to UNUserNotificationCenter – the iOS SDK’s component for scheduling local notifications – we’re wrapping the information in a convenient type of our own making.

Local Notification Workflow

The workflow for scheduling local notifications goes like this:

  1. Check if the user has given permission to schedule/handle local notifications
  2. If no permission has been asked before, ask the user for permission
  3. If permission has been given, schedule the local notifications
  4. If permission has been asked before, but the user declined, do nothing

To accomodate the above functionality, we’re going to define 4 functions in our LocalNotificationManager class:

  1. listScheduledNotifications(), so we can debug what notifications have been scheduled
  2. schedule(), the public function that will kick-off notification permissions and scheduling
  3. requestAuthorization(), the private function that will prompt the app user to give permission to send local notifications
  4. scheduleNotifications(), the private function that will iterate over the notifications property to schedule the local notifications

This 3-tiered permissions check is common in iOS development. Many components use constants to indicate if and how usage is authorized. Handling permissions well, including failure/denied, is an essential best practice.

You can use the following function to check and debug what local notifications have been scheduled:

func listScheduledNotifications()
{
UNUserNotificationCenter.current().getPendingNotificationRequests { notifications in

for notification in notifications {
print(notification)
}
}
}

The above code calls the getPendingNotificationRequests(completionHandler:) function on the shared UNUserNotificationCenter instance, which receives an array of UNNotificationRequest objects. Perfect for debugging purposes!

Don’t forget to import UserNotifications, to work with components like UNUserNotificationCenter.

Ask Permission to Send Local Notifications

Before we can schedule local notifications, to be sent locally to the user, we need to ask for permission. Most iOS components that are a security or privacy risk, such as the iPhone’s camera or Photo library, are protected by a permission-based system.

Asking permission to send local notifications involves the requestAuthorization(options:completionHandler:) function of a shared UNUserNotificationCenter instance. This singleton instance is used to manage everything related to notifications in your iOS app.

Code the following function in the LocalNotificationManager class:

private func requestAuthorization()
{
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in

if granted == true && error == nil {
self.scheduleNotifications()
}
}
}

Here’s what happens in the function:

  • First, we’re accessing the shared instance of UNUserNotificationCenter by calling the current() function. That way we get access to the single object that manages notifications on iOS. (In other APIs, this object is typically accessed via a shared property.)
  • Then, the function requestAuthorization(options:completionHandler:) is called. This prompts the permission alert dialog on iOS. The options parameter is an array of constants. We’re currently only asking permission to display alerts, to change an app icon’s numeric badge, and to play a sound when the notification alert pops up.
  • Then, as the last parameter of the function, the completion handler, is passed. It uses trailing closure syntax. This closure is executed when permission has been given. It has two parameters, granted, of type Bool, and error, of type Error. Based on these parameters we can check if permission has actually been given.
  • With the conditional, we’re checking that granted equals true and that error is nil. This way we’re certain that there were no errors, and that permission has been given. Subsequently, the scheduleNotifications() function is called (see below).

In short, this function asks the iPhone user for permission to send local notifications. If permission is given, the scheduleNotifications() function is called. If permission isn’t given, or an error occurs, nothing happens.

A few notes here:

  • The requestAuthorization() function is private, so it can only be used inside the LocalNotificationManager class.
  • In your own apps, always account for and handle failure scenarios, i.e. what happens if the user doesn’t give permission?
  • Are you tired of completion handlers, or do you use nested completion handlers a lot? Check out promises – it’ll positively blow your mind!

Check Local Notifications Permission Status

Now that we have a function that asks the user for permission, we can do our special secret dance with the authorization status.

The schedule() function is part of the public-facing API of the LocalNotificationManager class. You can use it to kick off the local notification permissions, and the subsequent scheduling of notifications.

Code the following function in the LocalNotificationManager class:

func schedule()
{
UNUserNotificationCenter.current().getNotificationSettings { settings in

switch settings.authorizationStatus {
case .notDetermined:
self.requestAuthorization()
case .authorized, .provisional:
self.scheduleNotifications()
default:
break // Do nothing
}
}
}

How does the schedule() function work?

  • First, we’re accessing that shared UNUserNotificationCenter object again, and we’re calling the getNotificationSettings(completionHandler:) function. Again, we’re using trailing closure syntax for conciseness. And, just as before, this completion handler is executed when the settings have been received.
  • Then, inside the switch block, we’re inspecting a property authorizationStatus of enum type UNAuthorizationStatus. This value can tell us exactly if and what permission has been given.
  1. Not determined. If the authorization is not determined, that means we haven’t asked for permission before. So, we’ll ask for permission by calling the requestAuthorization() function!
  2. Authorized or provisional. If the value of authorizationStatus is .authorized or .provisional, that means we have (temporary) permission to schedule notifications. So, we’ll schedule notifications by calling scheduleNotifications() (see below).
  3. Anything else. If no other switch cases match, i.e. in the case of .denied, we simply do nothing. We can’t ask for permission again, and we also can’t schedule local notifications.

On iOS, you can only ask for permission once. When permission has explicitly been denied, it has to be manually reset by the user via the iPhone’s Settings app. That’s why you always want to make it clear why an app is asking for permission. Many apps also use a 2-step approach, i.e. first ask for a soft permission (or prepare the user the permissions dialog is coming up), and then use the iOS-provided permission dialog.

One thing that’s important to understand here, is that we can define two paths in the class, that both end up at scheduleNotifications().

  1. Permission has not been given, so it’s asked, and scheduleNotifications() is called from requestAuthorization()
  2. Permission has been given before, so scheduleNotifications() is called directly from the schedule() function

The functions we’re coding in the LocalNotificationManager class are idempotent. Idempotence is a software development principle that states that a function can be called multiple times without changing the result beyond the initial call.

In other words, whether you call the schedule() function 1 or 100 times, it’ll always schedule the same set of notifications once. The opposite means that you call the function twice, and you get twice the amount of (duplicate) notifications! This is often undesired.

A few examples:

  • An idempotent function is the round(_:) function. It doesn’t matter if you call it once or a dozen times, the function will always round to the nearest integer.
  • A non-idempotent function is the moveUp() function, in a hypothetical board game. Every time you call the function, the piece will move up one position on the board. It matters how many times you call this function.

It matters that our schedule() function is idempotent, because then we can call it without worrying about unintended side-effects. We know for certain that the function will always schedule local notifications, if given permission, and when permission is pending, local notifications are scheduled too.

Compare this to a function that you have to call twice: once for the permission, and then again when permission is given. Or, imagine a function that schedules new notifications every time you call it. That’s surely a cause of bugs!

Quick Tip: Once you know idempotence, you see it everywhere. The crosswalk button. An “On” button. Elevator floor buttons. The iPhone’s home screen button. Good web form Submit buttons. The “Call flight attendant” button. Emergency brakes. It doesn’t matter how many times you press them! (And there’s zero uncertainty, and no side-effects, in doing so, i.e. it’s very safe.)

How To Schedule Local Notifications

We can now finally write that scheduleNotifications() function. This function iterates over the Notification objects in the notifications array and schedules them for delivery in the future.

Code the following function in the class:

private func scheduleNotifications()
{
for notification in notifications
{
let content = UNMutableNotificationContent()
content.title = notification.title
content.sound = .default

let trigger = UNCalendarNotificationTrigger(dateMatching: notification.datetime, repeats: false)

let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)

UNUserNotificationCenter.current().add(request) { error in

guard error == nil else { return }

print(“Notification scheduled! — ID = \(notification.id)”)
}
}
}

On the top level, the function iterates over the notifications array, an instance property, with a for loop. Inside the loop, for every item in the array, this happens:

  • First, create an object of type UNMutableNotificationContent. This object contains the content of the notification, such as its title, body and sound.
  • Then, create an object of type UNCalendarNotificationTrigger. This object contains the trigger for the notification, such as a date and time. We’re using the datetime property of the Notification struct, of type DateComponents, to indicate when the notification should be sent.
  • Then, we’re creating an object of type UNNotificationRequest. This object combines the content and the trigger, together with a unique ID. Every notification needs a unique ID, which you can conveniently use to reschedule a local notification.
  • Finally, the request object is passed to the add(request:completionHandler:) function of the shared UNUserNotificationCenter instance. This schedules the local notification and then executes the completion handler. In this completion handler, we check with guard that no errors occurred, and we print out a message to the Console.

Easy-peasy, right? First constuct the content, then construct the trigger, then combine them in a “request”, and then pass the request to UNUserNotificationCenter.

You can also trigger a local notification based on a time interval:

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 120, repeats: false)

And you can also use a GPS region as a trigger, of type CLRegion, either upon entering or exiting that region. Like this:

let trigger = UNLocationNotificationTrigger(triggerWithRegion: region, repeats: false)

The DateComponents struct is useful for creating date-time based objects with specific day, month, year, hour, minute, etc. parameters. An instance of DateComponents will use the iPhone’s device locale settings, which pretty much lets you create vanilla date-time objects with zero fuss.

Now that the LocalNotificationManager class is complete, we can schedule some notifications like this:

let manager = LocalNotificationManager()
manager.notifications = [
Notification(id: “reminder-1”, title: “Remember the milk!”, datetime: DateComponents(calendar: Calendar.current, year: 2021, month: 4, day: 22, hour: 17, minute: 0)),
Notification(id: “reminder-2”, title: “Ask Bob from accounting”, datetime: DateComponents(calendar: Calendar.current, year: 2021, month: 4, day: 22, hour: 17, minute: 1)),
Notification(id: “reminder-3”, title: “Send postcard to mom”, datetime: DateComponents(calendar: Calendar.current, year: 2021, month: 4, day: 22, hour: 17, minute: 2))
]

manager.schedule()

It doesn’t matter if you run this code once or a dozen times, it’ll always schedule those same three local notifications. It’ll ask for permission once, and take the authorization status into account for subsequent calls.

The id property of the Notification struct is used to identify unique notifications. If you use the same id, or provide an identical identifier to the identifier parameter of UNNotificationRequest, the associated notification is rescheduled. If you use a good naming structure for local notifications, you don’t have to worry about accidentally scheduling many duplicate notifications.

The Notification struct, in the above code, uses the synthesized initializer function. This function includes all properties of the struct as parameters. It’s automatically added to structs and classes, if you don’t provide an initializer yourself.

It’s worth pointing out here that the LocalNotificationManager must use a property notifications, that needs to be filled with Notification objects prior to calling schedule(). Why is that?

Due to the asynchronous nature of UNUserNotificationCenter, and its reliance on completion handlers, we’re scheduling the notifications from within the closure inside schedule(). If the schedule() function would have accepted a notifications argument, we would have needed some spaghetti code to pass that array to both requestAuthorization() and scheduleNotifications(). Unfortunately, this means that schedule() relies on the state of the notifications property to function properly.

A great alternative would be to use promises. Both requestAuthorization() and scheduleNotifications() functions can then return a promise, and we can use a promise block in schedule() that resolves the chain based on the permission status.

Quick Tip: Don’t forget to adjust the date and time for the DateComponents instances, in the above code… And did you know that you can use local notifications in iPhone Simulator? Just keep the Simulator open on your Mac, and wait for the notifications to pop up.

Handling Incoming Local Notifications

When you tap a local notification, your iOS app opens by default. This is perfect if you just want to use notifications to open your app, but what if you want to handle specific notifications in the app? That’s what we’ll build next.

We’ll respond to local notifications in 2 ways:

  1. When the app isn’t running, i.e. it’s in the background or closed, using the delegate function userNotificationCenter(_:didReceive:withCompletionHandler:)
  2. When the app is running and in the foreground, using the delegate function userNotificationCenter(_:willPresent:withCompletionHandler:)

First, to handle local notifications, your app needs to register a delegate instance that conforms to the UNUserNotificationCenterDelegate protocol before the app’s delegate function returns. In practice, this often means that the AppDelegate is also the delegate for local notifications.

We want to avoid cluttering the AppDelegate class with too many implementations, so we’ll define an extension like this:

extension AppDelegate: UNUserNotificationCenterDelegate
{

}

An extension effectively adds functions to existing types. You can put extensions in a separate Swift file, which is a best practice for splitting up large classes.

You can now assign the AppDelegate as the delegate of the shared UNUserNotificationCenter instance, like this:

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

return true
}

Note: Working with a scene delegate or the SwiftUI app lifecycle? Check out these tutorials on how to configure them: Scene Delegate vs. App Delegate Explained and SwiftUI’s App Lifecycle Explained

Next, we’ll implement the “userNotificationCenter(_:didReceive:withCompletionHandler:)` function to handle incoming local notifications. This delegate is called when the app starts from a tapped notification.

Code the following function in the extension:

func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)
{
let id = response.notification.request.identifier
print(“Received notification with ID = \(id)”)

completionHandler()
}

The response object contains all information about the notification. In the above code we’re using the local notification’s identifier to find out what notification was sent. In your own app, you can respond to a notification by restoring the state of the app, or presenting UI, or by taking some action.

It’s important to call completionHandler() when you’re done. This closure is passed to the delegate function, and it should be called when you want to indicate to the system that you’re done handling the notification. It’s worth noting that this closure can escape.

If you’ve defined any custom actions for your local notification, the response object will also contain information about that action. Setting custom actions isn’t covered in this tutorial.

Your app can also receive local notifications while it’s running. You use a different delegate function to respond to these notifications, and you use that function to determine what happens with the notification.

Like this:

func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
let id = notification.request.identifier
print(“Received notification with ID = \(id)”)

completionHandler([.sound, .alert])
}

The above code is similar to the delegate function that responds to non-foreground notifications. Again, we’re checking what local notification was triggered, and we’re executing the completionHandler to indicate that handling the notification is done.

You can pass options from the UNNotificationPresentationOptions struct to the completion handler, to indicate what you want iOS to do with the notification. The above code shows an alert to the user, and plays the notification’s sound. You can also silence the notification by passing an empty array, i.e. no options, like this: completionHandler([]).

How you handle local notifications depends entirely on your app. It may be enough to just launch the app from a notification. You may want to present a UI, such as an alarm clock, or direct the user to a specific UI or state in the app.

Further Reading

Awesome! We’ve gone from scheduling local notifications to handling them, after asking the user for permission to send notifications. And in the meantime we’ve discussed idempotence, structuring your code, completion handlers, and more.


Aasif Khan

Head of SEO at Appy Pie

App Builder

Most Popular Posts