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 theLocalNotificationManager
calledremoveRequest
and uselocalNotificationManager.removeRequest(withIdentifier: request.identifier)
as ilustrated above on the.swipeActions
. - Remove all notifications:
You can use the functionclearRequests
onLocalNotificationManager
and use it withlocalNotificationManager.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.