Chapters

Hide chapters

Push Notifications by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section I: Push Notifications by Tutorials

Section 1: 14 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 custom 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. You’re going to present your users with a map of the coordinates that you send them via a push notification.

import MapKit
@IBOutlet var label: UILabel?
@IBOutlet private weak var mapView: MKMapView?

func didReceive(_ notification: UNNotification) {
  guard let mapView = mapView else { return }

  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 {
    return
  }

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

  mapView.setRegion(region, animated: false)
}
{
  "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 let categoryIdentifier = "ShowMap"

private 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])
}
registerCustomActions()

func didReceive(
  _ response: UNNotificationResponse,
  completionHandler completion:
  @escaping (UNNotificationContentExtensionResponseOption) -> Void
) {
  // 1
  defer { completion(.dismiss) }
  // 2
  guard let response = response as? UNTextInputNotificationResponse else {
    return
  }
  // 3
  let text = response.userText
}

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,
  completionHandler completion:
  @escaping (UNNotificationContentExtensionResponseOption) -> Void
) {
  completion(.doNotDismiss)

  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
  }
}
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.

@IBOutlet private weak var imageView: UIImageView?
let images: [UIImage] = notification.request.content.attachments
  .compactMap { attachment in
    guard attachment.url.startAccessingSecurityScopedResource(),
      let data = try? Data(contentsOf: attachment.url),
      let image = UIImage(data: data) else {
      return nil
    }

    attachment.url.stopAccessingSecurityScopedResource()
    return image
  }

imageView?.image = images.first
{
  "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 AppDelegate.swift file and change your text action back to a normal action to represent the payment.

public enum ActionIdentifier: String {
  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]

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()
completion(.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.

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. Set a breakpoint like you normally would, right-click on the breakpoint and choose Edit Breakpoint….

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