Local Notifications - SwiftUI

Jul 3, 2022 8 min read
Local Notifications - SwiftUI

This is more like a boilerplate to reuse in projects that covers local notifications for your project. By the end of it. It contains all kinds of notifications such as Authorization, Time Intervals, Calendar, and more.

Notification Manager

First we are going to create a class that we are going to use to inject into the environment to help us manage the response notifications:

import SwiftUI
import NotificationCenter

@MainActor
class LocalNotificationManager: NSObject, ObservableObject {

    let notificationCenter = UNUserNotificationCenter.current()

    // To check if the notification access was granted by the user
    @Published var isGranted = false
    // Pending notifications to be displayed to the user
    @Published var pendingRequests: [UNNotificationRequest] = []
    // Next view to show the user once they tap on the notification. This is great for sheets.
    @Published var nextViewAfterNotificationWasTapped: NextViewNotification?
    
    @AppStorage(UserDefaultsUtil.Keys.silentNotificationSetting) var silentNotificationSetting: Bool = true
    
    enum SnoozeTime: String {
        case fiveMinutes, tenMinutes, fifteenMinutes, thirtyMinutes
    }

    // Let the delegate class know that this class is the delegate to handle the functions
    override init() {
        super.init()
        notificationCenter.delegate = self
    }

    /// Request notification authorization
    func requestAuthorization() async throws {
        try await notificationCenter
            .requestAuthorization(options: [.sound, .badge, .alert])
        registerActions()
        await getCurrentSettings()
    }

    /// Makes sure that the user has granted access to notifications
    func getCurrentSettings() async {
        let currentSettings = await notificationCenter.notificationSettings()
        isGranted = (currentSettings.authorizationStatus == .authorized)
    }

    /// Opens the default device notifications settings so the user can change the access previously given to our app
    func openSettings() {
        if let url = URL(string: UIApplication.openSettingsURLString) {
            if UIApplication.shared.canOpenURL(url) {
                Task {
                    await UIApplication.shared.open(url)
                }
            }
        }
    }
    
    /// Call to schedule a local notification using async Task handler
    func scheduleNotification(id: String = UUID().uuidString, title: String, body: String, timeInterval: Double = 5, repeatNotification: Bool = false, scheduleType: LocalNotification.ScheduleType, dateComponents: DateComponents = Calendar.current.dateComponents([.day, .minute, .second], from: Date.now)) {
        Task {
            switch scheduleType {
            case .time:
                var localNotification = LocalNotification(identifier: id,
                                                          title: title,
                                                          body: body,
                                                          timeInterval: timeInterval,
                                                          repeats: repeatNotification)
                localNotification.categoryIdentifier = "snooze"
                await schedule(localNotification: localNotification)
            case .calendar:
                var localNotification = LocalNotification(identifier: id,
                                                          title: title,
                                                          body: body,
                                                          dateComponents: dateComponents,
                                                          repeats: repeatNotification)
                localNotification.categoryIdentifier = "snooze"
                await schedule(localNotification: localNotification)
            }
        }
    }

    /// Schedule a local notification
    private func schedule(localNotification: LocalNotification) async {
        let content = UNMutableNotificationContent()
        content.title = localNotification.title
        content.body = localNotification.body
        if let subtitle = localNotification.subtitle {
            content.subtitle = subtitle
        }
        if let bundleImageName = localNotification.bundleImageName {
            if let url = Bundle.main.url(forResource: bundleImageName, withExtension: "") {
                if let attachment = try? UNNotificationAttachment(identifier: bundleImageName, url: url) {
                    content.attachments = [attachment]
                }
            }
        }
        if let userInfo = localNotification.userInfo {
            content.userInfo = userInfo
        }
        if let categoryIdentifier = localNotification.categoryIdentifier {
            content.categoryIdentifier = categoryIdentifier
        }
        
        // If the user wants silent notifications
        if(!silentNotificationSetting) {
            content.sound = .default
        }
        
        if localNotification.scheduleType == .time {
        guard let timeInterval = localNotification.timeInterval else { return }
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval,
                                                        repeats: localNotification.repeats)
            let request = UNNotificationRequest(identifier: localNotification.identifier, content: content, trigger: trigger)
            try? await notificationCenter.add(request)
        } else {
            guard let dateComponents = localNotification.dateComponents else { return }
            let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: localNotification.repeats)
            let request = UNNotificationRequest(identifier: localNotification.identifier, content: content, trigger: trigger)
            try? await notificationCenter.add(request)
        }
        await getPendingRequests()
    }

    /// Check how many pending local notifications requests we have
    func getPendingRequests() async {
        pendingRequests = await notificationCenter.pendingNotificationRequests()
        print("Pending notifications: \(pendingRequests.count)")
    }

    /// Remove a specific local notification request given its id
    func removeRequest(withIdentifier identifier: String) {
        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
        if let index = pendingRequests.firstIndex(where: {$0.identifier == identifier}) {
            pendingRequests.remove(at: index)
            print("Pending notifications: \(pendingRequests.count)")
        }
    }

    /// Removes all pending local notifications
    func clearRequests(ignoreNotifications: [String] = []) {
        // Handles
        if ignoreNotifications.isEmpty {
            notificationCenter.removeAllPendingNotificationRequests()
            pendingRequests.removeAll()
        } else {
            for request in pendingRequests {
                if(!(ignoreNotifications.contains(request.identifier))) {
                    removeRequest(withIdentifier: request.identifier)
                }
            }
        }
        
        
        print("Pending: \(pendingRequests.count)")
    }
}

extension LocalNotificationManager: UNUserNotificationCenterDelegate {

    /// Used for notification actions, e.g. snooze on lockscreen or when the notification is shown foreground/background
    /// List of actions to show to the user. The first 2 are the prioritized.
    func registerActions() {
        let snooze5Action = UNNotificationAction(identifier: SnoozeTime.fiveMinutes.rawValue, title: "snooze_5_minutes".localized())
        let snooze10Action = UNNotificationAction(identifier: SnoozeTime.tenMinutes.rawValue, title: "snooze_10_minutes".localized())
        let snooze15Action = UNNotificationAction(identifier: SnoozeTime.fifteenMinutes.rawValue, title: "snooze_15_minutes".localized())
        let snooze30Action = UNNotificationAction(identifier: SnoozeTime.thirtyMinutes.rawValue, title: "snooze_30_minutes".localized())
        let snoozeCategory = UNNotificationCategory(identifier: "snooze",
                                                    actions: [snooze5Action, snooze10Action, snooze15Action, snooze30Action],
                                                    intentIdentifiers: [])
        notificationCenter.setNotificationCategories([snoozeCategory])
    }

    // Delegate function
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        await getPendingRequests()
        
        // If the user wants silent notifications
        if(!silentNotificationSetting) {
            return [.sound, .banner]
        } else {
            return [.banner]
        }
    }

    /// Respond to user tapping to the opening of the notification
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
        if let value = response.notification.request.content.userInfo["nextView"] as? String {
            nextViewAfterNotificationWasTapped = NextViewNotification(rawValue: value)
        }

        // Respond to snooze action
        var snoozeInterval: Double?
                
        switch response.actionIdentifier {
        case SnoozeTime.fiveMinutes.rawValue:
            snoozeInterval = 300
        case SnoozeTime.tenMinutes.rawValue:
            snoozeInterval = 600
        case SnoozeTime.fifteenMinutes.rawValue:
            snoozeInterval = 900
        case SnoozeTime.thirtyMinutes.rawValue:
            snoozeInterval = 1800
        default:
            snoozeInterval = 600
        }

        if let snoozeInterval = snoozeInterval {
            let content = response.notification.request.content
            let newContent = content.mutableCopy() as! UNMutableNotificationContent
            
            // If the user wants silent notifications
            if(!silentNotificationSetting) {
                newContent.sound = .default
            }
            
            let newTrigger = UNTimeIntervalNotificationTrigger(timeInterval: snoozeInterval, repeats: false)
            let request = UNNotificationRequest(identifier: UUID().uuidString,
                                                content: newContent,
                                                trigger: newTrigger)
            do {
                try await notificationCenter.add(request)
            } catch {
                print(error.localizedDescription)
            }

            await getPendingRequests()
        }
    }

}

Model (which contains extra stuff we'll use later on):

struct LocalNotification {

    // Time
    internal init(identifier: String,
                  title: String,
                  body: String,
                  timeInterval: Double,
                  repeats: Bool) {
        self.identifier = identifier
        self.scheduleType = .time
        self.title = title
        self.body = body
        self.timeInterval = timeInterval
        self.dateComponents = nil
        self.repeats = repeats
    }

    // Calendar
    internal init(identifier: String,
                  title: String,
                  body: String,
                  dateComponents: DateComponents,
                  repeats: Bool) {
        self.identifier = identifier
        self.scheduleType = .calendar
        self.title = title
        self.body = body
        self.timeInterval = nil
        self.dateComponents = dateComponents
        self.repeats = repeats
    }

    enum ScheduleType {
        case time, calendar
    }

    var identifier: String
    var scheduleType: ScheduleType
    var title: String
    var body: String
    var subtitle: String?
    var bundleImageName: String?
    var userInfo: [AnyHashable : Any]?
    // Measured in seconds. Must be at least 60s if notification is repating or will throw error.
    var timeInterval: Double?
    var dateComponents: DateComponents?
    var repeats: Bool
    var categoryIdentifier: String?
}

Next, you can inject the notification manager on your SwiftUI view with something like this:

@main
struct MyApp: App {

    @StateObject var localNotificationManager = LocalNotificationManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(localNotificationManager)
        }
    }
}

and of course, in your other views you can use that environment like:

@EnvironmentObject var localNotificationManager: LocalNotificationManager

Request Notification Access to User

On your view, you can either place it on a button or .task etc. Example using task:

.task {
    try? await localNotificationManager.requestAuthorization()
}

Check for Notification Access

Let's supposed the user tapped on "Don't Allow". Using the environment, we can check for the notification access with the following:

if localNotificationManager.isGranted {
    // Do something
} else {
    Button("Enable Notifications") {
        localNotificationManager.openSettings()
    }
}

Now... let's supposed the user goes to settings and grants access to notification, in order for our app to know of this change without the user having to relunch it, we need to let the environment know. A good way of doing this is by re-checking for access once the app comes to foreground with scenePhase:

// Use the scene phase environment to access the app state
@Environment(\.scenePhase) var scenePhase

// on the view itself:
.onChange(of: scenePhase) { newPhase in
    switch newPhase {
    case .active:
        print("✅ App became active")
        Task {
           await localNotificationManager.getCurrentSettings()
           await localNotificationManager.getPendingRequests()
        }
    case .inactive:
        print("✅ App became inactive")
    case .background:
        print("✅ App is running in the background")
    @unknown default:
        print("🛑 Fallback for future cases")
    }
}

Schedule Notifications

For this I will be following Apple's doc, in case you need it, here it is.

Now we can create a new notification in our views using the function schedule from our LocalNotificationManager with the following:

Button("Interval Notification") {
    Task {
        let localNotification = LocalNotification(identifier: UUID().uuidString,
                                                  title: "Some Title",
                                                  body: "some body",
                                                  timeInterval: 10,
                                                  repeats: false)
        await localNotificationManager.schedule(localNotification: localNotification)
    }
}
Note: By default, if you schedule a notification and your app is still on the foreground, the notification will not show. It has to be in the background. There is a way to make it show while on foreground, keep reading below.

To make the notifications show while the app is on the foreground, you need to add a UNUserNotificationCenterDelegate. This is already included in the LocalNotificationManager function above with also the function userNotificationCenter and init to make it work.

Adding a delegate

In the step above we added the delegate, called: userNotificationCenter. A delegate function gets called every time a notification gets posted.

Get pending notifications

A function for this was added on the LocalNotificationManager above called getPendingRequests. You could use it for example on the .onChange(of: scenePhase) {... created above and add within the Task:

await lnManager.getPendingRequests()

now everytime the notification count changes, it will print it to the console.

You can get visually on the view all the pending notification requests with the following:

List {
    ForEach(localNotificationManager.pendingRequests, id: \.identifier) { request in
        VStack(alignment: .leading) {
            Text(request.content.title)
            HStack {
                Text(request.identifier)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .swipeActions {
            Button("Delete", role: .destructive) {
                localNotificationManager.removeRequest(withIdentifier: request.identifier)
            }
        }
    }
}

It would look something like:

Remove notifications

  • Remove single notification:
    You can use the function created on the LocalNotificationManager called removeRequest and use localNotificationManager.removeRequest(withIdentifier: request.identifier) as ilustrated above on the .swipeActions.
  • Remove all notifications:
    You can use the function clearRequests on LocalNotificationManager and use it with localNotificationManager.clearRequests()

Calendar Notifications

Reference for date components.

Using the function schedule on the LocalNotificationManager, we could set a future calendar notification like:

@State private var scheduleDate = Date()

...

DatePicker("", selection: $scheduleDate)
Button("Calendar Notification") {
    Task {
        let dateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: scheduleDate)
        let localNotification = LocalNotification(identifier: UUID().uuidString,
                                                  title: "Calendar Notification",
                                                  body: "Some Body",
                                                  dateComponents: dateComponents,
                                                  repeats: false)
        await localNotificationManager.schedule(localNotification: localNotification)
    }
}
.buttonStyle(.bordered)

Repeated Calendar notifications

Let's say that we want to repeat the same notification every time we get to the second 1, in the code above, modify the Task to be:

Task {
    let dateComponents = Calendar.current.dateComponents([.second], from: scheduleDate)
    let localNotification = LocalNotification(identifier: UUID().uuidString,
                                              title: "Calendar Notification",
                                              body: "Some Body",
                                              dateComponents: dateComponents,
                                              repeats: true)
    await localNotificationManager.schedule(localNotification: localNotification)
}

Now whatever second you chose will always repeat.
E.g. Let's say I choose: 8:01 pm. Every time a new minute goes by, example: 8:02, 8:03, a new notification will fire on the second 1 of that minute.

Add image to notification

The preparations have been made already on the LocalNotificationManager, all you have to do now is add the image (do not add it to the Assets) to the root folder of the app, maybe a folder for it and on the notification task specify the image name:

Button("Interval Notification") {
    Task {
        var localNotification = LocalNotification(identifier: UUID().uuidString,
                                                  title: "Some Title",
                                                  body: "some body",
                                                  timeInterval: 5,
                                                  repeats: false)
        localNotification.subtitle = "This is a subtitle"
        localNotification.bundleImageName = "Stewart.png"
        localNotification.userInfo = ["nextView" : NextView.renew.rawValue]
        localNotification.categoryIdentifier = "snooze"
        await localNotificationManager.schedule(localNotification: localNotification)
    }
}

it would look like:

If you long press the notification, it would show the full image:

Responding to Notifications

Once the user taps on the notification, we can either redirect him to one page or the other, a good case? A promo offer.

Create a next view enum that will handle our views:

import SwiftUI

enum NextView: String, Identifiable {
    case promo, renew
    var id: String {
        self.rawValue
    }
    
    @ViewBuilder
    func view() -> some View {
        switch self {
        case .promo:
            Text("Promotional Offer")
                .font(.largeTitle)
        case .renew:
            VStack {
                Text("Renew Subscription")
                    .font(.largeTitle)
               Image(systemName: "dollarsign.circle.fill")
                    .font(.system(size: 128))
            }
        }
    }
}

We have also defined this on our manager above in if let value = response.notification.request.content.userInfo["nextView"]

When adding the notification, you can now also let the notification know that a response is required with:

localNotification.userInfo = ["nextView" : NextView.renew.rawValue]

(see how it was done on the step of adding image)

You can create a .sheet so when the notification is tapped, it presents to the user like:

.sheet(item: $localNotificationManager.nextView, content: { nextView in
    nextView.view()
})

Add Snooze actions

You can add actions to the notifications such as snooze with the following:

localNotification.categoryIdentifier = "snooze"

(see how it was done on the step of adding image)

Big thanks to Stewart. His video, full project code.

Great! Next, complete checkout for full access to ArturoFM.
Welcome back! You've successfully signed in.
You've successfully subscribed to ArturoFM.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info has been updated.
Your billing was not updated.