SwiftUI - Open a specific View when user opens a Push Notification
Asked Answered
W

1

15

A have an app made in SwiftUI, with Parse used for DB. I'm some parts of the app i've integrated some cloud functions that send notifications (for example: when someone send's you a message, you will receive a push notification triggered by that cloud function). In the past days i'm struggling and searching for how to open a specific view when you press the Notification to open the app. I've found some solutions, but could not make them work.

This is the code that i have so far :

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        
        //Parse Intialization
        ...
        //notifications
        registerForPushNotifications()
        //Notification Badge
        UIApplication.shared.applicationIconBadgeNumber = 0
        // start notification while app is in Foreground
        UNUserNotificationCenter.current().delegate = self
        return true
    }
    
    // This function will be called right after user tap on the notification
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        print("app opened from PushNotification tap")
        UIApplication.shared.applicationIconBadgeNumber = 0
      completionHandler()
    }
}

@main
struct MyApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView(currentTab: Tab.home)
        }
    }
}

The app prints "app opened from PushNotification tap", but if I put a variable in AppDelegate and I listen for changes in ContentView with .onReceive or .onChange for that variable, nothing is hapenning

struct ContentView: View {
    @ObservedObject var appState = AppState()
    @State var currentTab : Tab
    @State var noReloadAddItemView = false
    var body: some View {
        TabView(selection: $appState.currentTab) {
            
            NavigationView {
                HomeView(appState: appState, noReloadAddItemView: $noReloadAddItemView)
                
            }
            .tabItem {
                if appState.currentTab == .home {
                    Image(systemName: "house.fill")
                } else {
                    Image(systemName: "house")
                }
                
                Text(LocalizedStringKey("HomeTabMenu"))
                
            }.tag(Tab.home)
            
            NavigationView {
                SearchView(appState: appState, noReloadAddItemView: $noReloadAddItemView)
            }
            .tabItem {
                if appState.currentTab == .search {
                    Image(systemName: "magnifyingglass.circle.fill")
                } else {
                    Image(systemName: "magnifyingglass")
                }
                Text(LocalizedStringKey("SearchTabMenu"))
            }.tag(Tab.search)
            
            NavigationView {
                AddItemView(appState: appState, noReloadAddItemView: $noReloadAddItemView)
            }
            .tabItem {
                if appState.currentTab == .add {
                    Image(systemName: "plus.circle.fill")
                } else {
                    Image(systemName: "plus.circle")
                }
                Text(LocalizedStringKey("SellTabMenu"))
            }.tag(Tab.add)
            
            NavigationView {
                ShoppingCartFavoritesView(appState: appState, noReloadAddItemView: $noReloadAddItemView)
            }
            .tabItem {
                if appState.currentTab == .favorites {
                    Image(systemName: "cart.fill")
                } else {
                    Image(systemName: "cart")
                }
                Text(LocalizedStringKey("CartTabMenu"))
            }.tag(Tab.favorites)
            
            NavigationView {
                ProfileView(appState: appState, noReloadAddItemView: $noReloadAddItemView)
            }
            .tabItem {
                if appState.currentTab == .profile {
                    Image(systemName: "person.fill")
                } else {
                    Image(systemName: "person")
                }
                Text(LocalizedStringKey("ProfileTabMenu"))
            }.tag(Tab.profile)
        }
        .accentColor(Color("ColorMainDark"))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(currentTab: Tab.home)
    }
}

class AppState: ObservableObject {
    @Published var currentTab : Tab = .home
}

enum Tab {
    case home, search, add, favorites, profile
}
Workroom answered 19/2, 2021 at 19:36 Comment(0)
K
20

You need some sort of shared state that you can modify that SwiftUI knows to react to. An ObservableObject is perfect for this:

class AppState: ObservableObject {
    static let shared = AppState()
    @Published var pageToNavigationTo : String?
}

Then, to listen to it and respond to it, you can do a couple different methods in your main view.

Option 1 -- NavigationLink binding based on the value of the ObservedObject:

struct ContentView : View {
    @ObservedObject var appState = AppState.shared //<-- note this
    @State var navigate = false
    
    var pushNavigationBinding : Binding<Bool> {
        .init { () -> Bool in
            appState.pageToNavigationTo != nil
        } set: { (newValue) in
            if !newValue { appState.pageToNavigationTo = nil }
        }
    }
    
    var body: some View {
        NavigationView {
            Text("My content")
                .overlay(NavigationLink(destination: Dest(message: appState.pageToNavigationTo ?? ""),
                                        isActive: pushNavigationBinding) {
                    EmptyView()
                })
        }
    }
}

struct Dest : View {
    var message : String
    var body: some View {
        Text("\(message)")
    }
}

Or, you could use onReceive:

struct ContentView : View {
    @ObservedObject var appState = AppState.shared
    @State var navigate = false
    
    var body: some View {
        NavigationView {
            VStack {
                if navigate {
                    //deprecated since 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView
                    NavigationLink(destination: Text("Test"), isActive: $navigate ) {
                        EmptyView()
                    }
                }
                Text("My content")
                    .onReceive(appState.$pageToNavigationTo) { (nav) in
                        if nav != nil { navigate = true }
                    }
            }
        }
    }
}

I'll leave the implementation details of your specific NavigationView, NavigationLink, TabView, etc to you, but this should get you started.

Finally, a fully-functional minimal example that mocks a notification and shows how the navigation view:


class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
     
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            print("Dispatch")
            AppState.shared.pageToNavigationTo = "test"
        }
        
        return true
    }
}

class AppState: ObservableObject {
    static let shared = AppState()
    @Published var pageToNavigationTo : String?
}

@main
struct MultiWindowApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView : View {
    @ObservedObject var appState = AppState.shared
    @State var navigate = false
    
    var pushNavigationBinding : Binding<Bool> {
        .init { () -> Bool in
            appState.pageToNavigationTo != nil
        } set: { (newValue) in
            if !newValue { appState.pageToNavigationTo = nil }
        }
    }
    
    var body: some View {
        NavigationView {
            Text("My content")
                .overlay(NavigationLink(destination: Dest(message: appState.pageToNavigationTo ?? ""),
                                        isActive: pushNavigationBinding) {
                    EmptyView()
                })
        }
    }
}


struct Dest : View {
    var message : String
    var body: some View {
        Text("\(message)")
    }
}
Knavish answered 19/2, 2021 at 20:32 Comment(4)
I didn't realize you could have an Observable singleton in Swift... but I guess it checks outCarton
You can also pass the reference explicitly between the app delegate and view — but, this could end up needing optionals.Knavish
On apps that use multiple scenes (iPad multi-window apps), a shared singleton would result in navigating to every scene, which isn't desirable. What you'd normally want is the notification to be handled by the last scene the user interacted with. I think I'd rather use the openURL approach - call openURL from the AppDelegate using an URL that properly identifies the notification. The system takes care of calling onOpenURL only on the scene the user last interacted with, and that scene can respond by navigating to the desired location.Whitehead
In iOS 17.0 I used .navigationDestination(isPresented: $appState.navigateToMyView) { MyView() }Epicritic

© 2022 - 2024 — McMap. All rights reserved.