SwiftUI has been available for several years, but I have never had to handle push notifications in a pure SwiftUI application until now. My goal this past week was to integrate push notification handling for the following cases:
- App is running in the foreground
- App is running in the background
- App is terminated and started via the user tapping on the notification
While the first two cases were relatively easy to implement at first, the last point was not that straightforward. In this article, I will explain my solution to the problem based on an iOS application. The same technique also works on watchOS.
Setup
Let's discuss setting up push notifications in a SwiftUI app. We must create a UIApplicationDelegate
instance to handle notifications for specific app events.
class MyAppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current()
.requestAuthorization(
options: authOptions,
completionHandler: {_, _ in
// Possibility handle the result of the authorization
}
)
return true
}
}
We are doing two things here:
- Registering the class to receive delegate events from the notification center. This becomes important when we want to react to push notifications
- Requesting permission to send push notifications to the app. If the user declines, it will not be possible to receive push notifications. Therefore, it is a good practice to implement the
completionHandler
function to show a message to the user on how to enable notifications again if they want to.
To use this delegate we need to wrap it with UIApplicationDelegateAdaptor
in our SwiftUI App
instance. From this point on, it behaves like a regular AppDelegate
in any UIKit-based app.
@main
struct MyApp: App {
// MARK: - AppDelegate
@UIApplicationDelegateAdaptor var appDelegate: MyAppDelegate
// MARK: - Body
var body: some Scene {
WindowGroup {
ContentView()
}
}
Next, we make sure to get notified when a notification is received and show notifications while the app is in the foreground by conforming to UNUserNotificationCenterDelegate
and implementing userNotificationCenter(_:didReceive:)
and userNotificationCenter(_:willPresent:withCompletionHandler:)
.
// MARK: - UNUserNotificationCenterDelegate
extension MyAppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
// Handle receiving of notification
}
// Needed if notifications should be presented while the app is in the foreground
func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.list, .banner, .sound])
}
}
The final step is to add the push notification entitlement to the app via Xcode. The entitlement can be found under the project settings in the "Signing & Capabilities" tab.
With everything properly set up, we can now explore the solution for handling push notifications in SwiftUI.
Notification Handling
The goal of the solution was to fit into the already existing view modifiers, e.g. onOpenURL(perform:)
or .onContinueUserActivity(_:perform:)
. Therefore the signature I was thinking of, looked like this onNotification(perform closure: @escaping (UNNotificationResponse) -> Void
. After having the target signature ready, let's capture what the solution should offer:
- Capture the notification received
- Inform the view hierarchy
- Ensure the process works while the app is running, in the background as well as terminated
Capture Received Notifications
The idea is, to have a central place in the app, that's capturing the received UNNotificationResponse
s. For that, a minimal notification handler can be implemented:
public class NotificationHandler: ObservableObject {
// MARK: - Shared Instance
/// The shared notification system for the process
public static let shared = NotificationHandler()
// MARK: - Properties
/// Latest available notification
@Published private(set) var latestNotification: UNNotificationResponse? = .none // default value
// MARK: - Methods
/// Handles the receiving of a UNNotificationResponse and propagates it to the app
///
/// - Parameters:
/// - notification: The UNNotificationResponse to handle
public func handle(notification: UNNotificationResponse) {
self.latestNotification = notification
}
This utility simply captures a UNNotificationResponse
and publishes it via its latestNotification
property. Especially important is, that the property is wrapped with @Published
which means, any subscriber that will subscribe to the property after it has been set, gets notified with the latest available value.
After defining the handler, we can use it in our delegate.
// MARK: - UNUserNotificationCenterDelegate
extension MyAppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
NotificationHandler.shared.handle(notification: response)
}
}
Inform the View Hierarchy
To match the existing developer experience in SwiftUI when it comes to handling system events, we create a new modifier that encapsulates the handling of push notifications.
struct NotificationViewModifier: ViewModifier {
// MARK: - Private Properties
private let onNotification: (UNNotificationResponse) -> Void
// MARK: - Initializers
init(onNotification: @escaping (UNNotificationResponse) -> Void, handler: NotificationHandler) {
self.onNotification = onNotification
}
// MARK: - Body
func body(content: Content) -> some View {
content
.onReceive(NotificationHandler.shared.$latestNotification) { notification in
guard let notification else { return }
onNotification(notification)
}
}
}
The view modifier is rather straightforward. It offers a closure, which captures a UNNotificationResponse
that can be used by implementers to execute certain logic. Furthermore, it also hooks up into our shared instance of the NotificationHandler
, which ensures the notification received in userNotificationCenter(_:didReceive:)
is passed correctly.
To make developers' life a little bit easier, we can offer convenience extensions on View
.
extension View {
func onNotification(perform action: @escaping (UNNotificationResponse) -> Void) -> some View {
modifier(NotificationViewModifier(onNotification: action))
}
}
This makes it much easier to call the modifier:
struct ContentView: View {
var body: some View {
Text("Hello World")
.onNotification { notification in
print(notification)
}
}
}
App State Handling
The last item on the to-do list is to ensure that the solution works even when the app is terminated. Fortunately, this has already been addressed. When looking at the implementation of the NotificationHandler
, it was noted that the latestNotification
property is wrapped in a @Published
property wrapper. This guarantees that any subscriber will always receive the most up-to-date value. This is especially useful when the app is launched from a notification after being terminated.
When the app starts from a push notification, the userNotificationCenter(_:didReceive:)
delegate method is triggered before the view hierarchy is loaded, which means that any view modifier that could have captured the notification has not yet been added. However, because we are using the @Published
wrapper, as soon as the view hierarchy is loaded, the latest value is read and the onNotification(perform:)
closure is executed.
This approach also covers other app states, such as foreground and background, so we have met this requirement.
Conclusion
In this post, I share a way to handle push notifications in a pure SwiftUI app. We also found a way to handle them correctly when the app is terminated and the user starts the application via a push notification. If you need to implement something similar in your application, I hope this solution helps.
Please feel free to reach out to me if you find any mistakes, have questions or suggestions. 🙂
See you next time! 👋