Xcoding with Alfian

Mobile development articles and tutorials

Building Custom Interactive Remote Push Notification in iOS

Alt text

Since iOS 10, Apple has already provided rich notification support for push notification with the introduction of new frameworks, UserNotifications and UserNotificationsUI. Using this framework, we can customize our push notification with abilities such as:

  1. Customize type and content/UI of push notification.
  2. Provide custom actions and responses for each of the types of notification.
  3. Mutate the content of received push notification before it delivered to the user.
  4. Customize custom trigger of the push notification such as in specific time interval and geographic region.


Besides all this rich notification support, Apple also added new interactive custom UI support in iOS 13. Before, we can only customize the actions for the user to select in action-sheet like iPhone UI. With the interactive notification, we can provide any user interface and interaction that we want in our push notification.

This addition is a real game-changer for push notification. For example, we can provide controls like text field, switch, slider, stepper, and any custom control that we want. Finally, we have the freedom to customize our push notification preview the way we want it to be.

In this article, we are going to build an interactive custom push notification UI to display a video preview of a trailer with buttons for users to add and favorite. Users can also provide ratings using the star and comment using a text field. Here is the list of tasks to do:

  • Setup project and dependencies.
  • Register push notification permission, type, and category.
  • Simulate remote push notification for testing.
  • Setup Notification Extension target info plist.
  • Setup UI for the notification content.
  • Handle on receive notification and UI interaction in code.

To simulate a remote push notification in the simulator, you need to download and install Xcode 11.4 beta from Apple developer website

You can download the completed project from the GitHub repository here.

Setup Project and Dependencies

To begin, create a new Xcode project using your unique Bundle Identifier. Then, navigate to the project signing & capabilities tab. Click on + Capabilities button and select push notifications from the list. The purpose is to associate the push notification with the App ID of the project.

Next, we are going to add a new app extension target for the custom content notification UI. From the menu bar, click on File > New > Target. On the filter text field, type notification. Finally, select Notification Content Extension from the list. Give the name and click finish. After that, you can activate the scheme when in the alert dialog. Close the project.

Alt text

Next, we are going to initialize Cocoapods for the project and declare the dependencies. Using the terminal, navigate to the project folder, and type pod init. After that, open the Podfile using your text editor and add the dependencies to the targets of the app. There are 2 dependencies to add:

  1. XCDYoutubeKit. A library to play YouTube video using the AVPlayer. (warning: YouTube policy only allow the app to use WebView with iFrame to play video in-app).
  2. Cosmos. A star rating control that we can use in our app.
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'apnspushsimulate' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for apnspushsimulate
  pod "XCDYouTubeKit", "~> 2.9"
  pod 'Cosmos', '~> 21.0'

end

target 'test' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for test
  pod "XCDYouTubeKit", "~> 2.9"
  pod 'Cosmos', '~> 21.0'

end

Run pod install to install all the dependencies to all the targets. Open the project.xcworkspace in your Xcode to begin. Try to build with Command + B to make sure everything is good to go.

Register permission, type, and category for push notification

Next, we need to register permission to allow push notification in our app using the UNUserNotificationCenter. We add the code to do that in the AppDelegate inside the (_:didFinishLaunchingWithOptions:). Make sure to import the UserNotifications framework at the top of the source file. Here are the steps to do:

  1. First, we invoke the requestAuthorization passing the array of authorization options. In this case, we want the alert, badge, and sound.
  2. Second, we initialize the UNNotificationCategory passing the unique category string identifier. In this case, we don't want to have custom actions, so we pass an empty array.
  3. Last, we invoke the setNotificationCategories passing our custom notification category type inside an array.
import UIKit
import UserNotifications

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
        
        let testNotificationCategory = UNNotificationCategory(identifier: "testNotificationCategory", actions: [], intentIdentifiers: [], options: [])
        UNUserNotificationCenter.current().setNotificationCategories([testNotificationCategory])
        return true
    }
    
        ...    
}

Simulate remote push notification for testing

Xcode 11.4 finally introduced a new feature to simulate remote push notification locally. It's pretty simple to begin. We need to create the apns json file containing the payload. We also need to add additional key beside the aps, which is the Simulator Target Bundle containing the App ID of our app. The filename extension of the file must be using .apns instead of .json. You can take a peek of our sample apns file below.

{
   "aps" : {
      "alert" : {
         "title" : "A new trailer has arrived for you",
         "body" : "Fast and Furious F9 Official Trailer"
      },
      "category" : "testNotificationCategory",
      "sound": "bingbong.aiff",
      "badge": 3,
   },
   "videoId" : "Kopyc23VfSw",
   "description": "Release date: 05-21-2020",
   "Simulator Target Bundle": "com.alfianlosari.apnspushsimulate"
}

In this case, we also provide the additional key for videoId and description so we can retrieve the YouTube video URL and display custom description in the custom UI. To test the notification, run the app, and accept the notification permission. Then, put the app in background, drag, and drop the apns file into the simulator.

Alt text

Setup Notification Extension target info plist.

We need to add additional keys to the notification content application extension info.plist. Under the NSExtension > NSExtensionAttributes dictionary make sure you add all these keys and values:

  1. UNNotificationExtensionDefaultContentHidden. This key determines whether to hide the default push notification title and body labels. In our case, we want to hide it, so we set the value to NO.
  2. UNNotificationExtensionUserInteractionEnabled. This key determines whether to make the UI interactive. We set this to YES.
  3. UNNotificationExtensionCategory. We need to set the value of this using the notification type category identifier that we register at the AppDelegate, which is testNotificationCategory.
  4. UNNotificationExtensionInitialContentSizeRatio. The initial content size ratio when the preview appears the first time. We set this to default, 1.
Alt text

Setup UI for the notification content preview

Let's move to the notification content preview UI. Open the MainInterface.storyboard in the notification extension target. Here are the steps to do:

  1. Drag a view to the View Controller canvas. Add these constraints: Align top, leading, and trailing to Safe Area. Set the height constraints to 240. Rename this view to Player View. This is the video player view that embeds the AVPlayer View.
  2. Drag a Stack View to the View Controller below the Player View. Add these constraints: Top Space to Player View with 16. Align leading, trailing, bottom to Safe Area . Set the axis to vertical, alignment and distribution to fill, and set spacing to 16. Rename this to Outer Stack View.
  3. Drag a Stack View as a subview of the Outer Stack View. Add 2 labels inside this stack view, video title label and video description label. Set the axis to vertical, alignment and distribution to fill, and set spacing to 4. Set the video title label line limit to 2.
  4. Drag a Stack View as a subview of the Outer Stack View. Add 2 buttons inside this stack view, subscribe button and favorite button. Set the axis to horizontal, alignment to fill, distribution to fill equally, and set spacing to 4.
  5. Drag a Button as a subview of the Outer Stack View, rename this to Review Button. Set the text to Review.
  6. Drag a Label as a subview of the Outer Stack View, rename this to Submit Label. Set the text to Your review has been submitted!
  7. Drag a Stack View as a subview of the Outer Stack View. Set the axis to vertical, alignment and distribution to fill, and set spacing to 24. Rename this to Review Stack View.
  8. Drag a View as a subview of the Review Stack View. Set the class to CosmosView. In the attribute inspector, set the start margin to 16 and star size to 50. Set the height constraint to 59.
  9. Drag a Stack View as a subview of the Review Stack View, below the Cosmos View. Add a label inside this stack view, comment label, TextView, and Button. Set the axis to vertical, alignment and distribution to fill, and set spacing to 8. Set the height constraint to 100. Make sure to set the Comment label text to comment.
  10. Drag a Button as a subview of the Review Stack View at the bottom. Set the text to Submit, and constraint the height to 40.
Alt text

Handle on receive notification and UI interaction in code.

Open the NotificationViewController.swift file. There is a NotificationViewController class that subclass UIViewController and implements the UNNotificationContentExtension. The didReceive(_:) method is invoked when the push notification arrives passing the payload. Here is the brief overview of the code handling for the view, properties setup, didReceive and interaction handler:

  1. Imports all the required frameworks, AVKit, UserNotifications, UserNotificationsUI, XCYoutubeKit, and Cosmos at the top of the source file.
  2. You need to also declare all the properties for labels, buttons, views, and text view.
  3. Declares all the @IBAction method handling when the user taps on the respective button. In this case, subscribe, favorite, review, and submit handler.
  4. Declares the properties for storing the state of isSubscribed and isFavorited with the property observer. In this case, the text and color of the button change depending on the boolean state.
  5. Declare the constants for storing the height of the view. In sake of this example, i already calculated the possible height when the view is expanded or collapsed.
  6. In viewDidLoad, set up the initial view state of buttons and stack views. The Review Stack View and Submit Label are hidden; the first time view appears.
  7. In the didReceive, we retrieve the content and set the text of labels for title and description. Also, we get the videoID and use the XCDYouTubeClient to get the video URL passing the identifier.
  8. After we successfully retrieve the URL, initialize AVPlayerViewController with the URL, embed the view inside the Player View container view, and play the content.
  9. When the user tap on review button, unhide the Review Stack View. When they tap on the submit button, hide the Review Stack View and show the submit label. For subscribing and favoriting, toggle the property to update the text and color of the buttons.
import UIKit
import AVKit
import UserNotifications
import UserNotificationsUI
import XCDYouTubeKit
import Cosmos

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    
    @IBOutlet weak var playerView: UIView!
    @IBOutlet weak var reviewStackView: UIStackView!
    @IBOutlet weak var reviewButton: UIButton!
    @IBOutlet weak var videoTitleLabel: UILabel!
    @IBOutlet weak var videoDescriptionLabel: UILabel!
    @IBOutlet weak var submitLabel: UILabel!
    @IBOutlet weak var subscribeButton: UIButton!
    @IBOutlet weak var favoriteButton: UIButton!
    
    var playerController: AVPlayerViewController!
    let standardHeight: CGFloat = 432
    let reviewHeight: CGFloat = 658
    
    var isSubscribed = false {
        didSet {
            self.subscribeButton.tintColor = self.isSubscribed ? UIColor.systemGray : UIColor.systemBlue
            self.subscribeButton.setTitle(self.isSubscribed ? " Added" : " Add", for: .normal)
        }
    }
    
    var isFavorited = false {
        didSet {
            self.favoriteButton.tintColor = self.isFavorited ? UIColor.systemGray : UIColor.systemBlue
            self.favoriteButton.setTitle(self.isFavorited ? " Favorited" : " Favorite", for: .normal)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        subscribeButton.setImage(UIImage(systemName: "calendar"), for: .normal)
        favoriteButton.setImage(UIImage(systemName: "star"), for: .normal)
        reviewButton.setImage(UIImage(systemName: "pencil"), for: .normal)

        reviewStackView.isHidden = true
        submitLabel.isHidden = true
    }
    
    func didReceive(_ notification: UNNotification) {
        playerController = AVPlayerViewController()
        preferredContentSize.height = standardHeight
        videoTitleLabel.text = notification.request.content.body
        videoDescriptionLabel.text = notification.request.content.userInfo["description"] as? String ?? ""
        
        guard let videoId = notification.request.content.userInfo["videoId"] as? String else {
            self.preferredContentSize.height = 100
            return
        }
        
        XCDYouTubeClient.default().getVideoWithIdentifier(videoId) { [weak self] (video, error) in
            guard let self = self else { return }
            
            if let error = error {
                print(error.localizedDescription)
                return
            }
            
            guard let video = video else {
                return
            }
            
            let streamURLS = video.streamURLs
            if let url = streamURLS[XCDYouTubeVideoQuality.medium360] ?? streamURLS[XCDYouTubeVideoQuality.small240] ?? streamURLS[XCDYouTubeVideoQuality.HD720] ?? streamURLS[18]   {
                self.setupPlayer(with: url)
            }
        }
    }
    
    private func setupPlayer(with url: URL) {
        guard let playerController = self.playerController else {
            return
        }
        
        let player = AVPlayer(url: url)
        playerController.player = player
        playerController.view.frame = self.playerView.bounds
        playerView.addSubview(playerController.view)
        addChild(playerController)
        playerController.didMove(toParent: self)
        player.play()
    }
    
    @IBAction func submitTapped(_ sender: Any) {
        UIView.animate(withDuration: 0.3) {
            self.reviewStackView.isHidden = true
            self.submitLabel.isHidden = false
            self.preferredContentSize.height = self.standardHeight
        }
    }
    
    @IBAction func reviewTapped() {
        UIView.animate(withDuration: 0.3) {
            self.preferredContentSize.height = self.reviewHeight
            self.reviewStackView.isHidden = false
            self.reviewButton.isHidden = true
        }
    }
    
    @IBAction func subscribeTapped(_ sender: Any) {
        self.isSubscribed.toggle()
    }
    
    @IBAction func favoriteTapped(_ sender: Any) {
        self.isFavorited.toggle()
    }
}

Before building and run, make sure to connect all the @IBOutlets and @IBActions from the storyboard to the code. To test, just drag and drop the apns file to the simulator, make sure to use the correct App ID. You can try to play with the videoId value of the file by using your video identifier. You can retrieve this from the URL parameters of the YouTube video in the browser address bar.

Conclusion

That's it! Congrats on your achievement on building the custom interactive push notification UI. There are many possibilities and use cases that you can explore using this new capability. To handle sharing data between the main app and extension target, you need to create app group id. Then, you can initialize a shared UserDefaults to store and retrieve local data. You can explore more about the UserNotifications framework from Apple documentation

Let's keep the lifelong learning goes on!