Scheduling Local Notifications With Swift

Written by Reinder de Vries on April 22 2019 in App Development

Scheduling Local Notifications With Swift

How do you send and receive local notifications in your iOS app? In this article, 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…

Ready? Let’s go.

  1. What Is A Local Notification?
  2. The Local Notification Manager Class
  3. Asking Permission To Send Local Notifications
  4. Checking Local Notifications Permission Status
  5. Scheduling Local Notifications
  6. Handling Incoming Local Notifications
  7. Further Reading

What Is A Local Notification?

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

Perhaps the most common type of notification is a chat or text message, from iMessage 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. 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 article, 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 wellness app.

This article 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.

Learn how to build iOS apps

Get started with iOS 12 and Swift 5

Sign up for our iOS development course Zero to App Store and learn how to build professional iOS 12 apps with Swift 5 and Xcode 10.

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 controller with code to manage local notifications. This is a great pattern to build 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]()

}

Easy, right? The class has one instance property called notifications, of type [Notification], or array-of-Notification. 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.

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 a few functions in our LocalNotificationManager class:

  • listScheduledNotifications(), so we can debug what notifications have been scheduled
  • schedule(), the public function that will kick-off notification permissions and scheduling
  • requestAuthorization(), the private function that will prompt the app user to give permission to send local notifications
  • 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 important best practice.

You can use the following function to check 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.

Asking 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 instace 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 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!

Checking 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, 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!

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.)

Scheduling 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’re checking 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 calendar locale, 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: 2019, month: 4, day: 22, hour: 17, minute: 0)),
    Notification(id: "reminder-2", title: "Ask Bob from accounting", datetime: DateComponents(calendar: Calendar.current, year: 2019, month: 4, day: 22, hour: 17, minute: 1)),
    Notification(id: "reminder-3", title: "Send postcard to mom", datetime: DateComponents(calendar: Calendar.current, year: 2019, 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 two 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 application(_:didFinishLaunchingWithOptions:) 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
}

Don’t forget to import UserNotifications.

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.

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 article.

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.

Learn how to build iOS apps

Get started with iOS 12 and Swift 5

Sign up for our iOS development course Zero to App Store and learn how to build professional iOS 12 apps with Swift 5 and Xcode 10.

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.

Want to learn more? Check out these resources:

Reinder de Vries

Reinder de Vries

Reinder de Vries is a professional iOS developer. He teaches app developers how to build their own apps at LearnAppMaking.com. Since 2009 he has developed a few dozen apps for iOS, worked for global brands and lead development at several startups. When he’s not coding, he enjoys strong espresso and traveling.