BS
BleepingSwift
Published on
6 min read

> Scheduling Local Notifications with UNUserNotificationCenter

Share:

Local notifications let your app surface timely information even when it isn't running. Reminders, timers, to-do deadlines, calendar alerts: these all work the same way under the hood through Apple's UserNotifications framework and its central class, UNUserNotificationCenter.

Unlike push notifications, local notifications don't require a server or APNs configuration. Everything happens on-device, which makes them much simpler to set up and test.

Requesting Permission

Before scheduling anything, you need to ask the user for permission. iOS gates notification delivery behind an explicit authorization prompt, and you only get one shot at showing it. If the user declines, subsequent calls to requestAuthorization won't show the dialog again.

Swift
import UserNotifications

func requestNotificationPermission() async throws -> Bool {
    let center = UNUserNotificationCenter.current()
    let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
    return granted
}

Call this early in your app's lifecycle, but not necessarily at launch. Users are more likely to grant permission when they understand why your app needs it. A task reminder app might wait until the user creates their first task before prompting.

You can check the current authorization status at any time without triggering the prompt:

Swift
func checkNotificationStatus() async -> UNAuthorizationStatus {
    let settings = await UNUserNotificationCenter.current().notificationSettings()
    return settings.authorizationStatus
}

Scheduling a Time-Based Notification

The basic flow is: create content, create a trigger, wrap them in a request, and add the request to the notification center.

Swift
import UserNotifications

func scheduleReminder(title: String, body: String, timeInterval: TimeInterval) async throws {
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default

    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
    let request = UNNotificationRequest(
        identifier: UUID().uuidString,
        content: content,
        trigger: trigger
    )

    try await UNUserNotificationCenter.current().add(request)
}

The identifier is important. It uniquely identifies this notification so you can cancel or update it later. Using a UUID works fine for one-off notifications, but for something like a recurring reminder tied to a specific item in your app, you should use a stable identifier that maps back to that item.

Calendar-Based Triggers

UNCalendarNotificationTrigger lets you schedule notifications for specific dates and times. This is what you want for "remind me at 9 AM tomorrow" type functionality:

Swift
func scheduleDailyReminder(hour: Int, minute: Int) async throws {
    let content = UNMutableNotificationContent()
    content.title = "Daily Check-In"
    content.body = "Time to log your progress for today."
    content.sound = .default

    var dateComponents = DateComponents()
    dateComponents.hour = hour
    dateComponents.minute = minute

    let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
    let request = UNNotificationRequest(
        identifier: "daily-checkin",
        content: content,
        trigger: trigger
    )

    try await UNUserNotificationCenter.current().add(request)
}

Because this uses a fixed identifier ("daily-checkin"), scheduling it again replaces the previous one rather than creating a duplicate. Setting repeats: true means it fires every day at the specified time.

Adding Actions to Notifications

Plain notifications are informative, but actionable notifications let users respond without opening your app. You define a category with actions, then assign that category to your notification content.

Swift
func registerNotificationCategories() {
    let completeAction = UNNotificationAction(
        identifier: "COMPLETE_ACTION",
        title: "Mark Complete",
        options: []
    )

    let snoozeAction = UNNotificationAction(
        identifier: "SNOOZE_ACTION",
        title: "Snooze 15 min",
        options: []
    )

    let taskCategory = UNNotificationCategory(
        identifier: "TASK_REMINDER",
        actions: [completeAction, snoozeAction],
        intentIdentifiers: [],
        options: []
    )

    UNUserNotificationCenter.current().setNotificationCategories([taskCategory])
}

Call this during app setup (in your App.init() or app delegate's didFinishLaunching). Then when scheduling a notification, set the category:

Swift
let content = UNMutableNotificationContent()
content.title = "Task Due"
content.body = "Buy groceries"
content.categoryIdentifier = "TASK_REMINDER"
content.sound = .default

Handling Notification Responses

When a user taps a notification or one of its actions, your app needs to respond. This requires implementing UNUserNotificationCenterDelegate. In a SwiftUI app, the cleanest approach is through an app delegate:

Swift
import SwiftUI
import UserNotifications

@main
struct ReminderApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let actionIdentifier = response.actionIdentifier

        switch actionIdentifier {
        case "COMPLETE_ACTION":
            print("User marked task complete")
        case "SNOOZE_ACTION":
            // Reschedule the notification for 15 minutes from now
            let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 900, repeats: false)
            let request = UNNotificationRequest(
                identifier: response.notification.request.identifier,
                content: content,
                trigger: trigger
            )
            try? await center.add(request)
        case UNNotificationDefaultActionIdentifier:
            print("User tapped the notification itself")
        default:
            break
        }
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        return [.banner, .sound, .badge]
    }
}

The willPresent method controls what happens when a notification arrives while your app is in the foreground. By default, iOS suppresses the banner. Returning .banner overrides that behavior.

Managing Scheduled Notifications

You can inspect and cancel pending notifications at any time. This is useful when the user completes a task ahead of schedule or deletes something that had a reminder attached:

Swift
func cancelNotification(identifier: String) {
    UNUserNotificationCenter.current().removePendingNotificationRequests(
        withIdentifiers: [identifier]
    )
}

func cancelAllNotifications() {
    UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}

func listPendingNotifications() async -> [UNNotificationRequest] {
    return await UNUserNotificationCenter.current().pendingNotificationRequests()
}

There's a system limit of 64 scheduled local notifications per app. If your app could realistically hit that limit, you will want to prioritize which notifications get scheduled and manage the queue yourself.

Putting It Together

Here's a SwiftUI view that ties the key pieces together into a simple reminder scheduler:

Swift
import SwiftUI
import UserNotifications

struct ReminderView: View {
    @State private var reminderText = ""
    @State private var minutesFromNow = 5.0
    @State private var permissionGranted = false
    @State private var showConfirmation = false

    var body: some View {
        Form {
            Section("Reminder") {
                TextField("What do you need to remember?", text: $reminderText)
                Slider(value: $minutesFromNow, in: 1...60, step: 1) {
                    Text("Minutes from now")
                }
                Text("Fire in \(Int(minutesFromNow)) minutes")
                    .foregroundStyle(.secondary)
            }

            Section {
                Button("Schedule Reminder") {
                    Task {
                        let content = UNMutableNotificationContent()
                        content.title = "Reminder"
                        content.body = reminderText
                        content.sound = .default

                        let trigger = UNTimeIntervalNotificationTrigger(
                            timeInterval: minutesFromNow * 60,
                            repeats: false
                        )
                        let request = UNNotificationRequest(
                            identifier: UUID().uuidString,
                            content: content,
                            trigger: trigger
                        )

                        try? await UNUserNotificationCenter.current().add(request)
                        showConfirmation = true
                        reminderText = ""
                    }
                }
                .disabled(reminderText.isEmpty || !permissionGranted)
            }
        }
        .alert("Reminder Scheduled", isPresented: $showConfirmation) {
            Button("OK") { }
        }
        .task {
            let granted = try? await requestNotificationPermission()
            permissionGranted = granted ?? false
        }
    }
}

Local notifications are one of those features that feels more complex than it actually is. The core flow is always the same: get permission, build content, attach a trigger, and hand it off to the system. Once you have that pattern down, layering on categories, actions, and response handling is straightforward. For testing notifications on the simulator without a server, check out xcrun simctl push.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.