Chapters

Hide chapters

Push Notifications by Tutorials

Fourth Edition · iOS 16 · Swift 5 · Xcode 14

Section I: Push Notifications by Tutorials

Section 1: 15 chapters
Show chapters Hide chapters

11. Custom Interfaces
Written by Scott Grosch

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the last few chapters, you worked through most types of notifications, including those that present an attachment, such as an image or video, alongside the banner message; but if you really want to go hog wild, you can even customize the way the notification itself looks to your heart’s content! This can get quite complex, but it is worth the time to make an app that really shines. Custom interfaces are implemented as separate targets in your Xcode project, just like the service extension.

Your top-secret agency wants to send you the locations of your targets, so you’ll need to build a way to do that. In this chapter, you’ll create a notification that displays a location on the map, with the ability to comment on that location right from the notification, all without opening the app.

Configuring Xcode for a Custom Notification UI

After opening up the starter project for this chapter, set the team signing as discussed in Chapter 7, “Expanding the Application”. Don’t forget to also set the team signing for the Payload Modification target just as you did in the previous chapter, Chapter 10, “Modifying the Payload”.

First, you’ll create a new Notification Content Extension that will handle showing your custom UI.

  1. In Xcode, select FileNewTarget….
  2. Makes sure iOS is selected and choose the Notification Content Extension.
  3. Press Next.
  4. For the Product Name field type Custom UI.
  5. Press Finish.
  6. If asked about scheme activation, select Cancel.

Note: You don’t actually run a Notification Content Extension, so that’s why you didn’t let it make the new target your active scheme.

You can name the new target anything that makes sense for you, but it can be helpful to use the above name because, when you glance at your project, you will immediately know what that target is doing.

Custom interfaces are triggered by specifying a category, just as you learned about with custom actions in Chapter 9, “Custom Actions”.

Every custom UI must have its own unique category identifier. Bring up the Project navigator ( + 1) and select your project. Then, select the newly created target and go to the Info tab. You’ll see an item labeled NSExtension. Expand that all the way out and find a key labeled UNNotificationExtensionCategory. This identifier connects your main target, registering the identifier, with the correct content extension.

If your push notification contains a category key that matches this, the UI in your content extension will be used. Update this value to ShowMap.

If you have multiple category types that will all use the same UI, simply change the type of UNNotificationExtensionCategory from String to Array and list each category name that you’d like to support.

Designing the Interface

You’ll notice that your new target includes a storyboard and view controller for you to utilize. Wait…storyboard? But we want SwiftUI!

import SwiftUI

struct MapView: View {
  let mapImage: Image

  var body: some View {
    mapImage
      .resizable()
      .aspectRatio(contentMode: .fit)
  }
}

struct MapView_Previews: PreviewProvider {
  static var previews: some View {
    MapView(mapImage: Image(systemName: "globe.americas"))
  }
}

Decoding the Payload

When the notification arrives, you’ll need to decode the payload to get the coordinates. You’ll be working with MapKit, so in NotificationViewController.swift, add the follow two imports:

import CoreLocation
import MapKit
var region: MKCoordinateRegion!
private func decodeUserInfo(_ notification: UNNotification) {
  let userInfo = notification.request.content.userInfo

  guard
    let latitude = userInfo["latitude"] as? CLLocationDistance,
    let longitude = userInfo["longitude"] as? CLLocationDistance,
    let radius = userInfo["radius"] as? CLLocationDistance
  else {
    // Default to Apple Park if nothing provided
    region = .init(
      center: .init(latitude: 37.334886, longitude: -122.008988),
      span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
    )

    return
  }

  let location = CLLocation(latitude: latitude, longitude: longitude)
  region = .init(
    center: location.coordinate,
    latitudinalMeters: radius,
    longitudinalMeters: radius
  )
}

Adding a UIHostingController

A custom UI for a push notification is required to use UIKit and a UIViewController. However, with a bit of magic, you can still utilize SwiftUI for the display. You’ll implement what’s known as a Container View Controller. Essentially you take a view controller and make it a child of another view controller.

var mapViewHost: UIHostingController<MapView>!

Receiving the Notification

When a notification arrives, iOS will call the didReceive(_:) method. Replace the contents of the method with the following:

// 1
decodeUserInfo(notification)

// 2
let mapView = MapView(mapImage: Image(systemName: "globe.americas"))
mapViewHost = UIHostingController(rootView: mapView)

// 3
addChild(mapViewHost)
view.addSubview(mapViewHost.view)

// 4
mapViewHost.view.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
  mapViewHost.view.topAnchor.constraint(equalTo: view.topAnchor),
  mapViewHost.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  mapViewHost.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  mapViewHost.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])

// 5
mapViewHost.didMove(toParent: self)

MKMapSnapshotter

The solution is to use an MKMapSnapshotter. Given an MKCoordinateRegion, iOS will take a picture of the map for that region and provide it to your code as a UIImage. There are two methods you can use:

// 1
var mapImage = Image(systemName: "globe.americas")

// 2
let group = DispatchGroup()
group.enter()

// 3
let options = MKMapSnapshotter.Options()
options.region = region

let snapshotter = MKMapSnapshotter(options: options)

// 4
snapshotter.start(with: .global(qos: .userInitiated)) { (snapshot, _) in
  // 5
  if let image = snapshot?.image {
    mapImage = Image(uiImage: image)
  }

  // 6
  group.leave()
}

// 7
group.wait()

// 8
let mapView = MapView(mapImage: mapImage)

Setting the Entry Point

Remember that the default template expects to display a storyboard. Edit the Info.plist file, the expand the NSExtension key. You will see a key labeled NSExtensionMainStoryboard. Replace the key with NSExtensionPrincipalClass, then replace the value with $(PRODUCT_MODULE_NAME).NotificationViewController. Using the NSExtensionPrincipalClass key lets iOS know how to start your custom UI as you no longer have a storyboard to load.

{
  "aps": {
    "alert" : {
      "title" : "The Sydney Observatory"
    },
    "category" : "ShowMap",
    "sound": "default"
  },
  "latitude" : -33.859574,
  "longitude" : 151.204576,
  "radius" : 500
}

Resizing the Initial View

If you watch really closely while your custom UI comes into place, you’ll probably notice that it might start a bit too big and then shrink down to the proper size. Apple, without explaining why, implemented the initial height of the view as a percentage of the width, instead of letting you specify a specific size.

Accepting Text Input

At times, you may want to allow your users to type some text in response to a push notification. With the previous map push, people may want to tell you how jealous they are that you’re there or the awesome things they saw last time they went themselves. Or, in your spy app, you might want to request additional information about your target.

private enum ActionIdentifier: String {
  case comment
}
private static let categoryIdentifier = "ShowMap"

static func registerCustomActions() {
  let ident = ActionIdentifier.comment.rawValue
  let comment = UNTextInputNotificationAction(
    identifier: ident,
    title: "Comment"
  )

  let category = UNNotificationCategory(
    identifier: categoryIdentifier,
    actions: [comment],
    intentIdentifiers: []
  )

  UNUserNotificationCenter.current().setNotificationCategories([category])
}
PushNotifications.registerCustomActions()

func didReceive(
  _ response: UNNotificationResponse
) async -> UNNotificationContentExtensionResponseOption {
  guard let response = response as? UNTextInputNotificationResponse else {
    return .dismiss
  }

  let text = response.userText

  // Process the text as appropriate.

  return .dismiss
}

Changing Actions

It’s also possible to modify the action buttons dynamically inside of your Notification Content Extension. If you’re sending a social media notification, for example, you may want to provide a button to let the end-user “like” your content. Once you’ve tapped the “Like” button, it only makes sense to now provide an “Unlike” button in its place. In the case of your spy app, you’ll add “Accept” and “Cancel” buttons, to accept your next target and cancel the mission if anything goes wrong.

enum ActionIdentifier: String {
  case accept
  case cancel
}
func didReceive(
  _ response: UNNotificationResponse
) async -> UNNotificationContentExtensionResponseOption {
  let accept = ActionIdentifier.accept.rawValue
  let cancel = ActionIdentifier.cancel.rawValue
  let currentActions = extensionContext?.notificationActions ?? []

  switch response.actionIdentifier {
  case accept:
    let cancel = UNNotificationAction(identifier: cancel, title: "Cancel")
    extensionContext?.notificationActions = currentActions
      .map { $0.identifier == accept ? cancel : $0 }

  case cancel:
    let accept = UNNotificationAction(identifier: accept, title: "Accept")
    extensionContext?.notificationActions = currentActions
      .map { $0.identifier == cancel ? accept : $0 }

  default:
    break
  }

  return .doNotDismiss
}
let acceptAction = UNNotificationAction(
  identifier: ActionIdentifier.accept.rawValue,
  title: "Accept")
extensionContext?.notificationActions = [acceptAction]
let category = UNNotificationCategory(
  identifier: categoryIdentifier,
  actions: [],
  intentIdentifiers: [])

UNUserNotificationCenter.current().setNotificationCategories([category])

Attachments

If your project also includes a Service Notification Extension, it will be executed before your Notification Content Extension. A frequent reason you’d have both extensions is that the former will download an attachment that the latter wants to use. It’s not enough to just know where your mission’s target is. You also need to know what they look like; that’s why you’ll add a small image of your target’s headshot to your notification.

let targetImage: Image?
.overlay(alignment: .topTrailing) {
  if let targetImage {
    targetImage
      .resizable()
      .aspectRatio(contentMode: .fit)
      .frame(width: 80.0, height: 80.0)
  }
}
struct MapView_Previews: PreviewProvider {
  static var previews: some View {
    MapView(mapImage: Image(systemName: "globe.americas"), targetImage: nil)
  }
}
private func getImage(_ notification: UNNotification) -> Image? {
  // 1
  guard
    let attachment = notification.request.content.attachments.first,
    attachment.url.startAccessingSecurityScopedResource()
  else {
    return nil
  }
  // 2
  defer { attachment.url.stopAccessingSecurityScopedResource() }
  // 3
  guard
    let data = try? Data(contentsOf: attachment.url),
    let uimage = UIImage(data: data)
  else {
    return nil
  }

  return Image(uiImage: uimage)
}
let mapView = MapView(region: region, image: getImage(notification))
{
  "aps": {
    "alert" : {
      "title" : "The Sydney Observatory"
    },
    "category" : "ShowMap",
    "sound": "default",
    "mutable-content": 1
  },
  "latitude" : -33.859574,
  "longitude" : 151.204576,
  "radius" : 500,
  "media-url": "https://www.gravatar.com/avatar/8477f7be4418a0ce325b2b41e5298e4c.jpg"
}

Video Attachments

Things get more complicated when your attachment is a video file, however. While this is out-of-scope for your spy app, it’s still a valuable feature to know about.

// 1
var mediaPlayPauseButtonType:
  UNNotificationContentExtensionMediaPlayPauseButtonType {  
  return .overlay
}

// 2
var mediaPlayPauseButtonFrame: CGRect {
  return CGRect(x: 0, y: 0, width: 44, height: 44)
}

// 3
var mediaPlayPauseButtonTintColor: UIColor {
  return .purple
}

Custom User Input

While action buttons and the keyboard are great, sometimes you really just want your own custom interface for user input – a grid of buttons, sliders, etc…

Adding a Payment Action

Agents need to get paid! You’ll add a slider that the agents can use to select how much they want to get paid for the job. Head back into your app’s PushNotifications.swift file and add a new case to the ActionIdentifier enum:

case payment
let identifier = ActionIdentifier.payment.rawValue
let payment = UNNotificationAction(
  identifier: identifier,
  title: "Payment")

let category = UNNotificationCategory(
  identifier: categoryIdentifier,
  actions: [payment],
  intentIdentifiers: [])

UNUserNotificationCenter.current().setNotificationCategories([category])
let acceptAction = UNNotificationAction(
  identifier: ActionIdentifier.accept.rawValue,
  title: "Accept")
extensionContext!.notificationActions = [acceptAction]
PushNotifications.registerCustomActions()

The First Responder

Remember way back when you first learned iOS programming, there was that pesky responder chain that never made much sense? Well, it’s finally time to do something useful with it!

override var canBecomeFirstResponder: Bool {
  return true
}
_ = becomeFirstResponder()
return .doNotDismiss

The User Input

If you become the first responder, iOS will expect you to return a view via the inputView property that contains your custom user interaction view. The download materials for this chapter includes a PaymentView for you that will display a slider for selecting payments. Drag the PaymentView.swift file from the projects folder into the Custom UI group in Xcode. Make sure Copy items if needed is checked, and also that the Custom UI target is checked.

private lazy var paymentView: PaymentView = {
  let paymentView = PaymentView()
  paymentView.onPaymentRequested = { [weak self] payment in
    self?.resignFirstResponder()
  }
  return paymentView
}()

override var inputView: UIView? {
  return paymentView
}

Hiding Default Content

If you’re creating a custom UI, odds are that you’re already presenting the title and body of the notification somewhere in your UI. If that’s the case, you can tell iOS to not present that default data under your view by editing the content extension’s Info.plist. Expand the NSExtension property again. This time, under NSExtensionAttributes, add a new Boolean key called UNNotificationExtensionDefaultContentHidden and set its value to YES.

Interactive UI

If you want to support interactive touches on your custom user interface, you need to edit the Info.plist of your extension and add the UNNotificationExtensionUserInteractionEnabled attribute key with a value of YES inside NSExtensionAttributes.

Launching the App

Depending on the content of your UI, it may make sense to have a button tap launch your app. This is as simple as calling a single method:

extensionContext?.performNotificationDefaultAction()

Dismissing the UI

Similarly to being able to launch your app, you can also dismiss the UI based on a button tap. As usual, you’ll want to call a method on the extensionContext:

extensionContext?.dismissNotificationContentExtension()

Debugging

Debugging a UI extension works almost the same as any other Xcode project. However, because it’s a target and not an app, you have to take a few extra steps.

Print With Breakpoints

Because your custom interface runs as a separate process, you will not see any print statements that you place in your code. Instead, you’ll need to make use of Xcode breakpoints.

Key Points

  • You can customize the look of a push notification; custom interfaces are implemented as separate targets in your Xcode project, just like the service extension.
  • Custom interfaces are triggered by specifying a category and every custom UI must have its own unique category identifier.
  • There are a number of customizations you can make such as allowing your user to respond to a push notification with text, changing action buttons, allowing attachements and tailoring your interface for user input like payment actions. You can also hide default content and create an interactive UI. All of these features will enhance your user experience and make your app really stand out.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now