I have a SwiftUI app that receives notifications. When a user taps on the notification from outside the app, the func userNotificationCenter(_ center: UNUserNotificationCenter, did receive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {}
method is called, and I use what I called NotificationParser to find the correct location in the app to navigate to, it gets the necessary information from the UNNotificationResponse.
I have an @Published Boolean property on my appDelegate that indicates whether the app should navigate to a different page.
On the Home Screen of my app, I have the appDelegate in an environment variable and a .navigationDestination like this
.navigationDestination(isPresented: $appDelegate.openedFromNotification, destination: {
NotificationDestination()
// Text("Testing if this gets opened at all")
})
The problem that I am having is that when the app is closed (not running in the background) when a user taps on the notification, it opens the app and then navigates to a blank screen.
However, when the app is in the background, the app opens and navigates as expected to the correct page.
I assume that there is some sort of race condition with the app not having time to render the view properly before the NavigationStack pushes on a new view or something like that. I have tried making very simple views to be pushed on that wouldn't have to wait to load data from my server, but those also did not work. (When I comment out the NotificationDestination() and uncomment the Text in the above block, I still do not see anything when the view is pushed onto the stack.
I was able to get it to load correctly when I put a DispatchQueue.main.asyncAfter(deadline: .now() + 5) { }
around my updating the appDelegate.openedFromNotification property, but for obvious reasons, that produces a terrible experience when the app hangs for 5 seconds before navigating. (Smaller increments of time also worked inconsistently, but 5 seconds did it correctly every time). This fix seems really hacky to me, and I am sure there is a better way to do this, though I am not sure what that might be.
How can I get around this race condition?
Is there a better way to handle opening specific screens from push notifications?
I would appreciate any pointers that the community could provide.
P.S. Part of my problem is that because the bug only shows up when the app is closed, I do not know how to debug the app when it opens from a notification like that. How to debug app when launch by push notification in Xcode shows how to run the app with an edited scheme that allows for the app not to launch immediately, but debugger output still does not show for me when I do this.
I have tried using different types of NavigationLinks and putting the .navigationDestination modifier at a few nearby levels of the view hierarchy.
I have also tried using simpler views as the destination that doesn't require any external data.
Here is a minimum reproducible set of code. I did ten tries with this demo opening the app from the notification when it was closed and when it was in the background. I got to the correct screen 10/10 times when the app was in the background and 2/10 times when the app was closed. The app either did not navigate anywhere, or navigated to a blank screen.
After turning on Push Notifications in a new project, this code should recreate the problem.
import SwiftUI
@main
struct navigationDestinationApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appDelegate)
}
}
}
class NotificationParser {
@ViewBuilder
func getNotificationDestination(response: UNNotificationResponse?) -> some View {
if response == nil { Text("Response is nil and not updated.") }
else {
if Bool.random() {
Text("View option 1")
} else {
Text("View option 2")
}
}//: else not nil
}//: getNotificationDestination
}//: NotificationParser
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject {
@Published var openedFromNotification: Bool = false
@ViewBuilder
func notificationDestination() -> some View {
NotificationParser().getNotificationDestination(response: self.notificationResponse)
}
@Published var notificationResponse: UNNotificationResponse? = nil
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
//TODO: I should move this somewhere else where it does not spam the user immediately after they download the app.
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error = error {
print("Error in requesting notification authorization: \(error.localizedDescription)")
}
if granted {
// Register after we get the permissions.
UNUserNotificationCenter.current().delegate = self
}
}
return true
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
//MARK: This happens when the app is in the background and a silent notification is received.
print("application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult)")
}
//Tapped on a notification from outside the app.
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
DispatchQueue.main.async {
self.notificationResponse = response
self.openedFromNotification = true
}
completionHandler()
}
// App in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: (UNNotificationPresentationOptions) -> Void) {
print("Received notification while in foreground.")
}
}
struct ContentView: View {
@EnvironmentObject var appDelegate: AppDelegate
var body: some View {
NavigationStack {
VStack {
Button("Schedule new notification") {
NotificationService().scheduleTimerNotification(title: "You asked for this!", subtitle: "Your app is ready to view", numSeconds: 5)
}//: Button
}//: VStack
.navigationDestination(isPresented: $appDelegate.openedFromNotification, destination: {
appDelegate.notificationDestination()
})
.padding()
}//: NavigationStack
}//: body
}
import Foundation
import UserNotifications
class NotificationService {
//Schedule notification for 10 seconds from now to give time to close the app.
func scheduleTimerNotification(title: String, subtitle: String, numSeconds: Double = 10, sound: UNNotificationSound = .default, identifier: String = "NotificationTest") {
let content = UNMutableNotificationContent ( )
content.title = title
content.subtitle = subtitle
content.sound = sound
if numSeconds <= 0 { return }
//Set the notification to repeat every 10 seconds
let trigger = UNTimeIntervalNotificationTrigger (timeInterval: numSeconds, repeats: false)
let request = UNNotificationRequest (identifier: identifier, content: content,
trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
}
I found the answer after many hours of digging through logs and such. The problem was that the
was inside the completion handler of
That meant that the app delegate was not actually the delegate of the UNNotificationCenter when the notification was processed. Moving that line outside of the completion handler works like a charm.