Search code examples
iosswiftuinotificationsaction

How to open a custom view on notification action using SwiftUI?


I am trying to get a notification action to open a custom view. My notifications are basically news and I want the user to go to a page displaying a simple text (for purposes of this question) when he taps the "read notification" action. I have tried tons of tutorials but they all use some existing views like "imagePicker" that already have a ton of stuff enabled by default and I don't know what are all the things I need to add to my custom view to make this work. Like UIViewControllerRepresentable, coordinator or whatever else I may need.

This is my main swift file which handles notifications.(notifications are working fine) At the end of the file is the extension AppDelegate: UNUserNotificationCenterDelegate {} taken from the tutorial I was following which creates a NewsItem which I will also include here. But as I'm working in SwiftUI and not UIKit this is the point where I cannot follow any of the tutorials I've managed to find anymore to get it to work the way I want to.

I have included the complete app delegate extension and newsItem just to make the code here compilable but I put the part where I need the changes to start in a comment block.

import SwiftUI
import UserNotifications
enum Identifiers {
  static let viewAction = "VIEW_IDENTIFIER"
  static let readableCategory = "READABLE"
}
@main
struct MyApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
  var body: some Scene {
    WindowGroup {
      TabView{  
        NavigationView{
          ContentView()
        }
        .tabItem {
          Label("Home", systemImage : "house")
        }
      }
    }
  }
}


class AppDelegate: NSObject, UIApplicationDelegate {
  var window: UIWindow?
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    UNUserNotificationCenter.current().delegate = self// set the delegate
    registerForPushNotifications()
    return true
  }
  func application(  // registers for notifications and gets token
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
  ) {
    let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
    let token = tokenParts.joined()
    print("device token : \(token)")
  }//handles sucessful register for notifications
  
  func application( //handles unsucessful register for notifications
    _ application: UIApplication,
    didFailToRegisterForRemoteNotificationsWithError error: Error
  ) {
    print("Failed to register: \(error)")
  }//handles unsucessful register for notifications
  
  func application(   //handles notifications when app in foreground
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler:
      @escaping (UIBackgroundFetchResult) -> Void
  ) {
    guard let aps = userInfo["aps"] as? [String: AnyObject] else {
      completionHandler(.failed)
      return
    }
    print("new notification received")
  }//handles notifications when app in foreground
  
  func registerForPushNotifications() {
    UNUserNotificationCenter.current()
      .requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
        print("permission granted: \(granted)")
        guard granted else { return }
        let viewAction = UNNotificationAction(
          identifier: Identifiers.viewAction,
          title: "Mark as read",
          options: [.foreground])

        let readableNotification = UNNotificationCategory(
          identifier: Identifiers.readable,
          actions: [viewAction2],
          intentIdentifiers: [],
          options: [])
        UNUserNotificationCenter.current().setNotificationCategories([readableNotification])
        self?.getNotificationSettings()
      }
  }
  
  func getNotificationSettings() {
    UNUserNotificationCenter.current().getNotificationSettings { settings in
      guard settings.authorizationStatus == .authorized else { return }
      DispatchQueue.main.async {
        UIApplication.shared.registerForRemoteNotifications()
      }
      print("notification settings: \(settings)")
    }
  }
}

// MARK: - UNUserNotificationCenterDelegate

extension AppDelegate: UNUserNotificationCenterDelegate {
  func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
  ) {
    let userInfo = response.notification.request.content.userInfo

    if let aps = userInfo["aps"] as? [String: AnyObject],
      let newsItem = NewsItem.makeNewsItem(aps) {
      (window?.rootViewController as? UITabBarController)?.selectedIndex = 1

      if response.actionIdentifier == Identifiers.viewAction,
        let url = URL(string: newsItem.link) {
        let safari = SFSafariViewController(url: url)
        window?.rootViewController?.present(safari, animated: true, completion: nil)
      }
    }

    completionHandler()
  }
}

// MARK: - UNUserNotificationCenterDelegate

extension AppDelegate: UNUserNotificationCenterDelegate {
  func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
  ) {
    let userInfo = response.notification.request.content.userInfo

    if let aps = userInfo["aps"] as? [String: AnyObject],
      /*
      let newsItem = NewsItem.makeNewsItem(aps) {
      (window?.rootViewController as? UITabBarController)?.selectedIndex = 1
      if response.actionIdentifier == Identifiers.viewAction,
        let url = URL(string: newsItem.link) {
        let safari = SFSafariViewController(url: url)
        window?.rootViewController?.present(safari, animated: true, completion: nil)
      }
    }
   */

    completionHandler()
  }
}

Here is NewsItem.swift from the tutorial just in case but this is a file I don't need or want to use.

import Foundation

struct NewsItem: Codable {
  let title: String
  let date: Date
  let link: String

  @discardableResult
  static func makeNewsItem(_ notification: [String: AnyObject]) -> NewsItem? {
    guard
      let news = notification["alert"] as? String,
      let url = notification["link_url"] as? String
    else {
      return nil
    }

    let newsItem = NewsItem(title: news, date: Date(), link: url)
    let newsStore = NewsStore.shared
    newsStore.add(item: newsItem)

    NotificationCenter.default.post(
      name: NewsFeedTableViewController.refreshNewsFeedNotification,
      object: self)

    return newsItem
  }
}

the simplified ContentView

import SwiftUI
struct ContentView: View {   
    var body: some View {
        VStack{  
            Text(DataForApp.welcomeText)
                .font(.title)
                .bold()
                .multilineTextAlignment(.center)
                .foregroundColor(.secondary)
                .shadow(radius: 8 )
        } .navigationTitle("My Mobile App")
    }
}

now my goal is to use this MyView and once a user taps the "mark as read" action I want this view to show.

import SwiftUI
struct MyView: View {
    var body: some View {
        Text("Notification text here")
    }
}

Obviously MyView does not contain anything it needs but I don't want to post the code I tried here as I tried 200 different things and since none of them work I realise I'm not even close to the right track.


Solution

  • I solved this by using an @ObservableObject I created called NotificationManager -- this stores the text of the most recent notification (you could expand it to store an array if you like) and provides a binding to tell the app whether or not to show a new view in the stack based on whether or not there's a notification to be shown.

    This NotificationManager has to be an @ObservedObject on ContentView for this to work, since ContentView needs to watch for changes in the state of currentNotificationText, which is a @Published property.

    ContentView has an invisible NavigationLink (via .overlay with an EmptyView) that gets activated only in the event that there's notification.

    In the App Delegate methods, I just hand off the notification to a simple function handleNotification that parse the aps and puts the resulting String in the NotificationManager. You could also easily augment this with more robust features, including parsing other fields from the aps

    
    import SwiftUI
    import UserNotifications
    
    //new class to store notification text and to tell the NavigationView to go to a new page
    class NotificationManager : ObservableObject {
        @Published var currentNotificationText : String?
        
        var navigationBindingActive : Binding<Bool> {
            .init { () -> Bool in
                self.currentNotificationText != nil
            } set: { (newValue) in
                if !newValue { self.currentNotificationText = nil }
            }
            
        }
    }
    
    enum Identifiers {
        static let viewAction = "VIEW_IDENTIFIER"
        static let readableCategory = "READABLE"
    }
    
    @main
    struct MyApp: App {
        @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
        
        var body: some Scene {
            WindowGroup {
                TabView{
                    NavigationView{
                        ContentView(notificationManager: appDelegate.notificationManager) //pass the notificationManager as a dependency
                    }
                    .tabItem {
                        Label("Home", systemImage : "house")
                    }
                }
            }
        }
    }
    
    
    class AppDelegate: NSObject, UIApplicationDelegate {
        var notificationManager = NotificationManager() //here's where notificationManager is stored
        
        var window: UIWindow?
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
            UNUserNotificationCenter.current().delegate = self// set the delegate
            registerForPushNotifications()
            return true
        }
        func application(  // registers for notifications and gets token
            _ application: UIApplication,
            didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
        ) {
            let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
            let token = tokenParts.joined()
            print("device token : \(token)")
        }//handles sucessful register for notifications
        
        func application( //handles unsucessful register for notifications
            _ application: UIApplication,
            didFailToRegisterForRemoteNotificationsWithError error: Error
        ) {
            print("Failed to register: \(error)")
        }//handles unsucessful register for notifications
        
        func application(   //handles notifications when app in foreground
            _ application: UIApplication,
            didReceiveRemoteNotification userInfo: [AnyHashable: Any],
            fetchCompletionHandler completionHandler:
                @escaping (UIBackgroundFetchResult) -> Void
        ) {
            guard let aps = userInfo["aps"] as? [String: AnyObject] else {
                completionHandler(.failed)
                return
            }
            print("new notification received")
            handleNotification(aps: aps)
            completionHandler(.noData)
        }//handles notifications when app in foreground
        
        func registerForPushNotifications() {
            UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
                    print("permission granted: \(granted)")
                    guard granted else { return }
                    let viewAction = UNNotificationAction(
                        identifier: Identifiers.viewAction,
                        title: "Mark as read",
                        options: [.foreground])
                    
                    let readableNotification = UNNotificationCategory(
                        identifier: Identifiers.readableCategory,
                        actions: [viewAction],
                        intentIdentifiers: [],
                        options: [])
                    UNUserNotificationCenter.current().setNotificationCategories([readableNotification])
                    self?.getNotificationSettings()
                }
        }
        
        func getNotificationSettings() {
            UNUserNotificationCenter.current().getNotificationSettings { settings in
                guard settings.authorizationStatus == .authorized else { return }
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
                print("notification settings: \(settings)")
            }
        }
    }
    
    // MARK: - UNUserNotificationCenterDelegate
    
    extension AppDelegate: UNUserNotificationCenterDelegate {
        func userNotificationCenter(
            _ center: UNUserNotificationCenter,
            didReceive response: UNNotificationResponse,
            withCompletionHandler completionHandler: @escaping () -> Void
        ) {
            let userInfo = response.notification.request.content.userInfo
            if let aps = userInfo["aps"] as? [String: AnyObject] {
                handleNotification(aps: aps)
            }
        }
    }
    
    extension AppDelegate {
        @discardableResult func handleNotification(aps: [String:Any]) -> Bool {
    
            guard let alert = aps["alert"] as? String else { //get the "alert" field
                return false
            }
            self.notificationManager.currentNotificationText = alert
            return true
        }
    }
    
    struct ContentView: View {
        @ObservedObject var notificationManager : NotificationManager
        
        var body: some View {
            VStack{
                Text("Welcome")
                    .font(.title)
                    .bold()
                    .multilineTextAlignment(.center)
                    .foregroundColor(.secondary)
                    .shadow(radius: 8 )
            }
            .navigationTitle("My Mobile App")
            .overlay(NavigationLink(destination: MyView(text: notificationManager.currentNotificationText ?? ""), isActive: notificationManager.navigationBindingActive, label: {
                EmptyView()
            }))
        }
    }
    
    struct MyView: View {
        var text : String
        
        var body: some View {
            Text(text)
        }
    }
    

    (I had to fix a number of typos/compilation errors with the original code in your question, so make sure that if you use this, you're copying and pasting directly to get the right method signatures, etc.)