Teabyte

Mobile app development for iOS and Swift

Push Notifications in SwiftUI

2023-08-13

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.

MyAppDelegate.swift
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.

MyApp.swift
@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:).

MyAppDelegate.swift
// 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.

Xcodes UI for AppStore Distribution options

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:

  1. Capture the notification received
  2. Inform the view hierarchy
  3. 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 UNNotificationResponses. For that, a minimal notification handler can be implemented:

NotificationHandler.swift
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.

MyAppDelegate.swift
// 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.

NotificationViewModifier.swift
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.

View+Notifications.swift
extension View {
    func onNotification(perform action: @escaping (UNNotificationResponse) -> Void) -> some View {
        modifier(NotificationViewModifier(onNotification: action))
    }
}

This makes it much easier to call the modifier:

ContentView.swift
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! 👋