How to Build Local Notifications in SwiftUI
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
-
@Observable NotificationManager — Using iOS 17's
@Observablemacro instead ofObservableObjecteliminates boilerplate@Publishedproperties; SwiftUI automatically tracks reads ofauthorizationStatusand re-renders only the affected views. -
requestAuthorization with async/await — The
requestPermission()method uses the async overload ofrequestAuthorization(available since iOS 14), so there is no callback pyramid. The result is pushed back to the main actor via@MainActor.runto safely update state. -
UNTimeIntervalNotificationTrigger — The
delayparameter inscheduleRemindermaps directly to this trigger. Settingrepeats: falsefires the notification exactly once; set it totrue(with a minimum 60-second interval in the background) for recurring alerts. -
UUID identifier — Each request gets a
UUID().uuidStringidentifier. 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. -
willPresent delegate — Without the
UNUserNotificationCenterDelegateimplementation, 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
-
iOS version gotcha — provisional authorization: On iOS 12+ the system grants
.provisionalstatus if you pass the.provisionaloption, meaning notifications arrive silently to the Notification Center without a user prompt. This is ideal for onboarding but not for time-sensitive alerts — always checkauthorizationStatus == .authorized, not just!= .denied. -
SwiftUI-specific gotcha — scheduling on the wrong actor:
UNUserNotificationCenter.add()can safely be called off the main thread, but updating@Observablestate from a background completion handler will trigger a purple runtime warning. Always dispatch state mutations back withawait MainActor.run { … }. -
Performance & UX gotcha — exceeding the pending limit: iOS silently drops the oldest pending notifications once the app hits 64 scheduled requests. If your app schedules many recurring reminders (e.g. one per habit per day for a week), call
UNUserNotificationCenter.current().getPendingNotificationRequests()before scheduling to audit and prune stale entries. Also addaccessibilityLabelvalues to in-app notification previews so VoiceOver users understand the notification content.
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.