```html SwiftUI: How to Build Local Notifications (iOS 17+, 2026)

How to Build Local Notifications in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: UNUserNotificationCenter Updated: May 11, 2026
TL;DR

Request permission with UNUserNotificationCenter.current().requestAuthorization, build a UNMutableNotificationContent, pick a trigger, and schedule it with UNUserNotificationCenter.current().add(). Everything is async-safe and can be called directly from a SwiftUI button action.

import UserNotifications

// 1. Request permission once (e.g. on first launch)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
    guard granted else { return }

    // 2. Build the content
    let content = UNMutableNotificationContent()
    content.title = "Reminder"
    content.body  = "Time to check in!"
    content.sound = .default

    // 3. Fire after 5 seconds
    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

    // 4. Schedule it
    let request = UNNotificationRequest(identifier: UUID().uuidString,
                                        content: content,
                                        trigger: trigger)
    UNUserNotificationCenter.current().add(request)
}

Full implementation

The cleanest pattern in SwiftUI is to wrap UNUserNotificationCenter in an @Observable class — introduced in iOS 17 — so the UI can reactively display the current authorization status without polling. The view requests permission on appear, and each button schedules a new notification with a unique identifier so multiple pending reminders never collide. A UNUserNotificationCenterDelegate conformance lets you intercept notifications that arrive while the app is in the foreground.

import SwiftUI
import UserNotifications

// MARK: - Notification Manager

@Observable
final class NotificationManager: NSObject, UNUserNotificationCenterDelegate {

    var authorizationStatus: UNAuthorizationStatus = .notDetermined

    override init() {
        super.init()
        UNUserNotificationCenter.current().delegate = self
    }

    // Fetch current status (does NOT prompt the user)
    func refreshStatus() async {
        let settings = await UNUserNotificationCenter.current().notificationSettings()
        await MainActor.run { authorizationStatus = settings.authorizationStatus }
    }

    // Ask for permission (prompts once; subsequent calls return cached answer)
    func requestPermission() async {
        do {
            let granted = try await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .sound, .badge])
            await MainActor.run {
                authorizationStatus = granted ? .authorized : .denied
            }
        } catch {
            print("Notification permission error: \(error)")
        }
    }

    // Schedule a time-interval notification
    func scheduleReminder(title: String, body: String, delay: TimeInterval) async {
        guard authorizationStatus == .authorized else { return }

        let content = UNMutableNotificationContent()
        content.title = title
        content.body  = body
        content.sound = .default
        content.badge = 1

        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
        let request  = UNNotificationRequest(identifier: UUID().uuidString,
                                             content: content,
                                             trigger: trigger)
        do {
            try await UNUserNotificationCenter.current().add(request)
        } catch {
            print("Failed to schedule notification: \(error)")
        }
    }

    // Show notifications even when the app is foregrounded
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        completionHandler([.banner, .sound])
    }
}

// MARK: - SwiftUI View

struct LocalNotificationsView: View {
    @State private var manager = NotificationManager()
    @State private var scheduled = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {

                // Status badge
                statusBadge

                // Permission button
                if manager.authorizationStatus != .authorized {
                    Button("Enable Notifications") {
                        Task { await manager.requestPermission() }
                    }
                    .buttonStyle(.borderedProminent)
                }

                // Schedule button
                Button("Schedule Reminder (5 s)") {
                    Task {
                        await manager.scheduleReminder(
                            title: "SwiftUI Reminder",
                            body: "This fired from UNUserNotificationCenter 🎉",
                            delay: 5
                        )
                        scheduled = true
                    }
                }
                .buttonStyle(.bordered)
                .disabled(manager.authorizationStatus != .authorized)

                if scheduled {
                    Text("Notification scheduled — background the app!")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .padding()
            .navigationTitle("Local Notifications")
            .task { await manager.refreshStatus() }
        }
    }

    @ViewBuilder
    private var statusBadge: some View {
        let (label, color): (String, Color) = switch manager.authorizationStatus {
        case .authorized:   ("Authorized ✓", .green)
        case .denied:       ("Denied ✗", .red)
        case .provisional:  ("Provisional", .orange)
        default:            ("Not Determined", .gray)
        }

        Text(label)
            .font(.caption.bold())
            .foregroundStyle(color)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(color.opacity(0.1), in: Capsule())
    }
}

#Preview {
    LocalNotificationsView()
}

How it works

  1. @Observable NotificationManager — Using iOS 17's @Observable macro instead of ObservableObject eliminates boilerplate @Published properties; SwiftUI automatically tracks reads of authorizationStatus and re-renders only the affected views.
  2. requestAuthorization with async/await — The requestPermission() method uses the async overload of requestAuthorization (available since iOS 14), so there is no callback pyramid. The result is pushed back to the main actor via @MainActor.run to safely update state.
  3. UNTimeIntervalNotificationTrigger — The delay parameter in scheduleReminder maps directly to this trigger. Setting repeats: false fires the notification exactly once; set it to true (with a minimum 60-second interval in the background) for recurring alerts.
  4. UUID identifier — Each request gets a UUID().uuidString identifier. Reusing the same identifier replaces any existing pending notification with that ID — handy for editable reminders but dangerous if you accidentally reuse IDs across unrelated events.
  5. willPresent delegate — Without the UNUserNotificationCenterDelegate implementation, notifications are silently swallowed when the app is active. Returning [.banner, .sound] forces the system to display them even in the foreground — critical for testing during development.

Variants

Calendar-based trigger (exact date & time)

func scheduleDailyStandup(hour: Int, minute: Int) async {
    let content = UNMutableNotificationContent()
    content.title = "Daily Standup"
    content.body  = "Time to sync with the team."
    content.sound = .default

    var dateComponents        = DateComponents()
    dateComponents.hour       = hour    // e.g. 9
    dateComponents.minute     = minute  // e.g. 30

    // repeats: true → fires every day at 09:30
    let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
    let request = UNNotificationRequest(identifier: "daily-standup",
                                        content: content,
                                        trigger: trigger)
    try? await UNUserNotificationCenter.current().add(request)
}

// Cancel it later
func cancelDailyStandup() {
    UNUserNotificationCenter.current()
        .removePendingNotificationRequests(withIdentifiers: ["daily-standup"])
}

Notification actions (interactive buttons)

Register a UNNotificationCategory with UNNotificationAction items at launch, then set content.categoryIdentifier on your notification. The system renders up to four tappable action buttons in the notification sheet without opening the app. Handle the selected action in userNotificationCenter(_:didReceive:withCompletionHandler:). This is especially useful for task-reminder apps where users want to mark something "done" directly from the lock screen.

// Register category at app startup (e.g. in App.init or AppDelegate)
let doneAction = UNNotificationAction(
    identifier: "MARK_DONE",
    title: "Mark Done",
    options: [.authenticationRequired]
)
let snoozeAction = UNNotificationAction(
    identifier: "SNOOZE",
    title: "Snooze 10 min",
    options: []
)
let category = UNNotificationCategory(
    identifier: "TASK_REMINDER",
    actions: [doneAction, snoozeAction],
    intentIdentifiers: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])

// Attach to a notification
content.categoryIdentifier = "TASK_REMINDER"

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement local notifications in SwiftUI for iOS 17+.
Use UNUserNotificationCenter.
Support time-interval and calendar-based triggers.
Wrap the center in an @Observable manager class.
Make it accessible (VoiceOver labels on status indicators).
Add a #Preview with realistic sample data.

In Soarias's Build phase, drop this prompt into the active feature context so the generated NotificationManager is placed in the correct Swift package target alongside your existing @Observable stores — keeping the notification logic isolated and testable from day one.

Related

FAQ

Does this work on iOS 16?

The UNUserNotificationCenter APIs used here have been available since iOS 10, so all scheduling and delegate code works on iOS 16. The only iOS 17-exclusive feature is the @Observable macro used on NotificationManager. To target iOS 16, replace @Observable with ObservableObject and annotate authorizationStatus with @Published.

How do I handle what happens when a user taps a notification?

Implement userNotificationCenter(_:didReceive:withCompletionHandler:) on your UNUserNotificationCenterDelegate. The UNNotificationResponse parameter carries the actionIdentifier (or UNNotificationDefaultActionIdentifier for a tap) and the original UNNotificationContent, including any userInfo payload you embedded. Use that to navigate to the correct screen by publishing a NavigationPath change or posting a Notification.

What is the UIKit equivalent?

UNUserNotificationCenter is framework-agnostic — it is identical in UIKit and SwiftUI. The only UIKit-specific concern is that you typically set the delegate in application(_:didFinishLaunchingWithOptions:) inside AppDelegate rather than in a SwiftUI @Observable initializer. When migrating a UIKit app to SwiftUI, your existing notification logic can stay unchanged — just wire the delegate to your new manager class.

Last reviewed: 2026-05-11 by the Soarias team.

```