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

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!

Go ahead and delete the MainInterface.storyboard file with extreme prejudice, then create a new SwiftUI View file called MapView.swift with the following contents:

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"))
  }
}

Note: There’s currently a bug that prevents Xcode from displaying the preview in a content extension.

The view will display an image of the map location.

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

None of the code supplied by Xcode’s template will be necessary, so you should remove the entire contents of the NotificationViewController‘s implementation. Ignore the error saying that NotificationViewController does not conform to the UNNotificationContentExtension protocol. You’ll fix that in just a bit.

When working with maps, you need to specify the region that will be displayed, so add a new property to NotificationViewController:

var region: MKCoordinateRegion!

Then, implement the following method:

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

Your view controller has access to the full payload that was sent over by accessing the userInfo property of the UNNotification instance. You’re simply pulling the latitude, longitude and radius from your payload, constructing the appropriate CoreLocation objects, and then returning an MKCoordinateRegion that your map will display.

To keep the sample code simple, if anything is missing, or fails to convert property, the app defaults to showing Apple Park’s coordinates. A production app would likely have more logic in the MapView to show something different with a message saying there was a problem with the coordinates.

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.

Apple has provided the UIHostingController class to support using SwiftUI views inside of a UIKit framework. By making the hosting controller a child of the custom UI’s view controller, you now have a way to show your SwiftUI view in a custom push notification.

Replace the import of UIKit with SwiftUI, then add a UIHostingController property to the class:

var mapViewHost: UIHostingController<MapView>!

The mapViewHost property is the UIKit enabled wrapper for your SwiftUI 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)

In the preceding code:

  1. You first decode the payload that was sent via the notification.
  2. Next, you create a UIHostingController that wraps your SwiftUI view. This is your child view controller.
  3. Add the hosting controller as a child of the current controller and add the map’s root view as a child of the current view.
  4. It’s important to tell iOS not to add any constraints as you’re going to handle them yourself. Specify that the map’s view should take up the entire view, regardless of size.
  5. Finally, tell the child controller that it has moved to its parent.

With that simple trick, you’ve ensured that every time a remote push notification is received, iOS will create a new SwiftUI view and attach it to the normal UIViewController that you’re still required to use.

Note: loadView is called before didReceive(_:). Do not attempt to create the MapView outside of the didReceive(_:) method.

The previous code is forcing a globe to always be shown, which isn’t very useful. You’re probably wondering why the MapView doesn’t use the actual MapKit framework. Unfortunately, the map in iOS 16 is very memory intensive. If you try to use it in a custom interface, your notification will crash due to memory overload.

Note: Apple is aware of the issue and is researching a fix, as of iOS 16 Beta 4.

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:

  • start(with:completionHandler:)
  • start(with:) async throws

While you would normally prefer the second method, iOS does not yet allow for an async version of didRecieve(_:). The first version is equally problematic due to the completion handler. All of your work must be completed before leaving the didReceive(_:) method.

This is one of the rare cases where you’ll need to explicitly block the thread and wait for a task to complete. By utilizing a DispatchGroup you can call the method taking a completion handler, then block progress until the completion handler completes.

Replace the MapView creation line with the following:

// 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)

If you aren’t familiar with iOS concurrency programming, check out Concurrency by Tutorials from our professional subscription package. Here’s what’s happening:

  1. It’s always possible something could go wrong, bad coordinates were given, etc…You’ll default to showing a globe if that happens.
  2. Entering a DispatchGroup tells iOS to start keeping track of which groups have started and completed.
  3. You’re initializing an MKMapSnapshotter for the region specified in the payload.
  4. The start(with:completionHandler:) tells iOS to generate a map at the given region and then, once it’s done so, to call the completion handler with the image of that map. You want the action to run as fast as possible, so you’re specifying the .userInitiated quality of service.
  5. If iOS successfully create a snapshot image, you replace the default globe with the snapshot.
  6. Calling leave lets iOS know that this group has completed.
  7. The wait method forces iOS to stop execution at this point until every call to enter has had a corresponding leave call. If you enter 5 groups, you must leave 5 groups before wait will move on.
  8. Finally, you now create the MapView with the snapshotted image.

Forcing iOS to block and wait is always a bad idea if there’s any other option. Unfortunately, this is a case where you have no choice.

If you were to build and run your app, the custom UI wouldn’t display.

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.

Build and run your app so that you can test everything. There shouldn’t be any warnings or errors from the build. If you haven’t set up your PushNotifications tester app, do so now as described in Chapter 5, “Sending Your First Push Notification”. Make sure you change your payload to the following JSON:

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

Now, send the push notification. You should see a notification come in and, by long-pressing it, you should see the location on a map right inside the notification!

You’ll quickly notice, if you try to pan or zoom the map, the custom UI view controller, while fully functional, does not accept any type of user input. Keep this in mind while designing your interface. In a map example, it probably doesn’t make sense to place any pins on the view as the end user won’t be able to touch them to get more information, which could lead to confusion.

Also keep in mind that your custom interface is still just an iOS target. This means that you can easily share properly encapsulated UIViews between your main target and the content extension. Just add the UIView to the content extension target in the File Inspector ( + + 1), and you can use it like any other view! You can refer back to Chapter 10, “Modifying the Payload”, in which you added the UserDefaults.swift file to the service extension, if you need a reminder of how this works.

Note: There is an issue in the iOS 16 beta where the map view sometimes causes the notification UI to crash and display a plain white view. Apple has told us they are working on a fix, but for now you can send yourself another notification in case the first one crashes.

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.

In the Info.plist of your target extension, you can expand the NSExtension row again, where you’ll see a setting for UNNotificationExtensionInitial ContentSizeRatio, which defaults to 1. You should set this to a decimal value less than or equal to 1, representing the ratio of the height to the width. If you specify 0.8, for example, the UI will start with a height that is 80% as tall as the width. Trial and error are your friend in getting this just right.

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.

Head over to the PushNotifications.swift file. First, add the following enum inside of the PushNotifications enum:

private enum ActionIdentifier: String {
  case comment
}

Even though it’s just a single action, you should still use an enum so that additions are easier in the future with less code refactoring.

You’ll need to create your registerCustomActions() method to include an action button. This time, though, you’ll use the UNTextInputNotificationAction type. Still inside the PushNotifications enum, add the following code:

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])
}

Note that you’re asking for text input instead of a button click, so be sure you use the UNTextInputNotificationAction action type.

Finally, call your new method at the end of application(_:didRegisterForRemoteNotificationsWithDeviceToken:) in AppDelegate.swift:

PushNotifications.registerCustomActions()

If you build and run the app, then send that same push notification to yourself again, you should now have a keyboard on screen!

Note: Remember that custom actions won’t work in the simulator, so you’ll need to run on a physical device.

You’ll notice that you received a keyboard directly and not a Comment button. iOS is smart enough to realize that, if your only action is a keyboard action, it should just show the keyboard by default. If you were to add another action, however, you’d instead get an actual button labeled Comment that you’d tap to open the keyboard.

Showing a keyboard is great, but now you’ve got to know what was said! To get the text that was typed by the user, you must implement a new delegate method in your Content UI extension. In NotificationViewController.swift, add:

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
}

By looking at the type of response, you can determine whether or not you’ve received text from the end user to process. If you didn’t get text, then simply return .dismiss so that the notification goes away.

All that’s left to do is grab the text the user typed and process it. Frequently, this will mean calling some web service that you’ve implemented to store the response and possibly send it back out to other users.

Note: The didReceive(_:) synchronous method is called when the notification is displayed to configure the UI itself. The didReceive(_:) async method, that returns a UNNotificationContentExtensionResponseOption, is called in response to tapping an action button or by pressing Send on the keyboard.

When using a custom UI, it’s not immediately obvious what to do with the notification after you’ve tapped a button or sent text. Is that it? Should iOS now dismiss the notification? Usually, the answer is yes, but sometimes you’ll want to send text and be able to hit a social media like-type button. In the latter case, you wouldn’t want the notification window to go away.

If multiple interactions with your UI are possible, you’d instead want to return .doNotDismiss.

There is a third, not normally used, possibility. You can specify .dismissAndForwardAction to simply dismiss the custom UI and send the notification straight to your main app.

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.

By simply modifying the notificationActions property on the extensionContext variable you can do just that!

First, in NotificationViewController.swift, add the following enum to the top of the class:

enum ActionIdentifier: String {
  case accept
  case cancel
}

These are identifiers for your Accept and Cancel actions. Next, update the asynchronous didReceive(_:) with the following code:

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
}

The actionIdentifier property inside the notification response tells you which button was tapped. If the user tapped the Accept button, you’ll create a Cancel button and replace the existing Accept button with it. Similarly, if the user tapped the Cancel button, you’ll replace it with the Accept button. You’ll make these changes by modifying the notificationActions property of the content extension’s context.

While you could make this a tiny bit easier to read by simply replacing the index of the button directly, as opposed to using the map, this is definitely much more future-proof. This way, you don’t have to worry if you decide to add new buttons that change the order of your actions.

Still in NotificationViewController.swift, add the following lines to the bottom of the didReceive(_:) synchronous method:

let acceptAction = UNNotificationAction(
  identifier: ActionIdentifier.accept.rawValue,
  title: "Accept")
extensionContext?.notificationActions = [acceptAction]

This will make sure the Accept action shows up when you receive a notification.

Finally, you have to remove the comment action. Head to PushNotifications.swift and modify the contents of the registerCustomActions method to the following:

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

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

Since you’re setting the actions inside the UI extension, there’s no need to call registerCustomActions in AppDelegate.swift, so remove the call.

Build and run your app. You should see an Accept button on the notification and, when you tap it, it should change into a Cancel button.

Note: iOS will sometimes cache your content extension and it will stay the same between builds even though you changed your code. If that happens, delete the app from your device and rebuild the project from Xcode. Note that this might change your device token used for sending notifications.

By using this in conjunction with modifying the custom UI in response to tapping on an action button, you can now present a very rich user experience.

The fact that Apple now makes the action buttons dynamic also means that you’re no longer required to set up all of your actions when you register your category. You might, for example, simply register the category to trigger the extension and then dynamically generate all of your buttons based on the content of the payload, which provides major flexibility benefits.

You’re also able to present layered actions, but you need to again think very carefully about your user experience doing this. For example, the “Like” button may replace all the existing buttons with something like Love, Like, Kind of Like, and Meh. However, just because you can do something doesn’t mean that you should do something!

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.

In the previous chapter, Chapter 10, “Modifying the Payload”, you used a notification service extension to download a video. A similar service extension is already included in your starter project. It will try to download an image and a video, and then add them as attachments to the notification. This lets you use those attachments in your content extension.

Edit MapView.swift to allow for the display of the target’s picture by adding the following property:

let targetImage: Image?

You won’t always have an image, thus you make the property optional. To display the image, add the following code right after the aspectRatio(contentMode: .fit) line:

.overlay(alignment: .topTrailing) {
  if let targetImage {
    targetImage
      .resizable()
      .aspectRatio(contentMode: .fit)
      .frame(width: 80.0, height: 80.0)
  }
}

If the image is present, it will appear as an 80x80 picture at the top of the map, on the trailing edge.

You’ll also need to update the PreviewProvider to pass a nil to the constructor:

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

In NotificationViewController.swift, add the following method:

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

Getting the image requires a couple steps:

  1. Due to the way iOS performs its sandboxing, for security reasons, you can’t just directly access the attachment. You must first access the scoped resource.
  2. You’ll want to stop accessing the resource regardless of how you exit the method.
  3. If you’re not able to download and decode a valid image, then just return nil.
  4. If everything worked, generate a new SwiftUI Image.

All that’s left to do is update the MapView creation to pass the image:

let mapView = MapView(region: region, image: getImage(notification))

Build and run the app. In the push notification tester app, update the payload to the following JSON. It now includes the mutable-content key, so that the payload modification target runs, and the media-url, that points to your target’s image:

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

Send the push notification. You should see an attached image on the notification and, when you press into it, you should see an image of your next target:

Note: The image frequently fails to display properly in the simulator, so test on a real device.

Whoa! Looks like Shai is in for some big trouble.

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.

As you remember, custom UI notifications are not interactive by default, meaning you can’t simply tap on a media player to start and stop the video like you normally would.

If you have a video player as part of your custom notification UI, you’ll need to implement at least two of the three optional delegate properties:

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

Here’s what these lines of code are for:

  1. You ask iOS to draw a button that either disappears on play (.overlay) or stays onscreen (.default).
  2. You must tell iOS exactly what CGRect to use for positioning and sizing the Play button.
  3. Optionally, you can specify the tinting of the button to match your theme.

The button iOS draws for you will be tappable. When tapped, the UNNotificationContentExtension delegate methods mediaPlay and mediaPause will be called so that you can take action on your video player controller.

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…

Note: If you provide a custom input, you can’t also have an option for the keyboard to appear. You need to pick one or the other.

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

Next, replace the contents of registerCustomActions with the following:

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

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

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

Here, you set the new action as a category on the notification, as you did before.

Next, in NotificationViewController.swift, delete the following lines from didReceive(_:):

let acceptAction = UNNotificationAction(
  identifier: ActionIdentifier.accept.rawValue,
  title: "Accept")
extensionContext!.notificationActions = [acceptAction]

Finally, add a call to registerCustomActions back to the end of application(_:didRegisterForRemoteNotificationsWithDeviceToken) in AppDelegate.swift:

PushNotifications.registerCustomActions()

This will make sure the new payment action shows up below the notification.

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!

Still in NotificationViewController.swift, add an override to the top of the class to tell the system that you can, in fact, become the first responder:

override var canBecomeFirstResponder: Bool {
  return true
}

When the user taps on your Payment button, you want to become the first responder so that you can present a custom user interaction view. Replace the contents of the asynchronous didReceive(_:) with the following to make that happen:

_ = 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.

Back in NotificationViewController.swift, add the following properties to the top of the class to tell the system to use your new view:

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

override var inputView: UIView? {
  return paymentView
}

When the view controller becomes the first responder, iOS will ask it for the input view to display. Build and run the app and send yourself another push notification.

After tapping on the Payment button, you’ll see a slider to select your payment:

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.

Delivering a notification with this setting will show the same custom UI, without the title text:

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.

At this point, you can create an IBOutlet like you would on a normal view controller and link appropriate actions to them. It’s important to remember that you are responsible for handling all of the actions and callbacks once you’ve done this. Tapping on the UI will no longer open your app, for example.

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

Once that’s called, your app’s userNotificationCenter(_:didReceive:) delegate method, from UNUserNotificationCenterDelegate, will be called, and the identifier will be set to UNNotificationDefaultActionIdentifier.

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.

  1. Open up your NotificationViewController.swift file and set a breakpoint where you need to start debugging.
  2. Build and run your app.
  3. In Xcode’s menu bar choose DebugAttach to Process by PID or Name….
  4. In the dialog window that appears, enter Custom UI, or whatever you named your target.
  5. Press the Attach button.

If you switch over to the Debug Navigator ( + 7) you’ll see that Xcode is waiting for your target to start before it can attach to it.

If you send yourself another push and open up the custom UI, Xcode will show that it’s attached to your process.

It’s important to point this out as you need to wait for the process to be attached before you interact with your user interface beyond the initial long-press to open the UI. If you tap on anything before Xcode has attached, you won’t actually hit your breakpoint.

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

Set the Action dropdown to Log Message. You can surround variable names with @ symbols to display the value of a variable. The message you display will appear in the Xcode console.

Be sure that you also select to Automatically continue after evaluating actions so that your app doesn’t stop at the 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.
© 2026 Kodeco Inc.