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 custom UI
After opening up the starter project for this chapter, remember to turn on the Push Notifications capability as discussed in Chapter 4, “Xcode Project Set Up,” and 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.
- In Xcode, select File ▸ New ▸ Target….
- Makes sure iOS is selected and choose the Notification Content Extension.
- Press Next.
- For the Product Name field type Custom UI.
- Press Finish.
- 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.” The sample project has already registered a category for you in AppDelegate.swift’s registerCustomActions(), with a category identifier of ShowMap.
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.
Open up the MainInterface.storyboard from your UI target (not the Main.storyboard from your app itself) and make the following changes:
- Select the View and in the Size inspector change the view’s height to be
320. - Remove the Label.
- Drag an
MKMapViewonto the view. - Constrain it to all four edges of the superview with a constant of
0. - In NotificationViewController.swift, add to the top of the file:
import MapKit
And then, replace:
@IBOutlet var label: UILabel?
With:
@IBOutlet var mapView: MKMapView!
- Back in MainInterface.storyboard, connect your
MKMapViewoutlet.
That’s all you have to do in your storyboard. Now, open up NotificationViewController.swift and replace the didReceive(_:) method with:
func didReceive(_ 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 {
return
}
let location = CLLocation(latitude: latitude,
longitude: longitude)
let region = MKCoordinateRegion(center: location.coordinate,
latitudinalMeters: radius,
longitudinalMeters: radius)
mapView.setRegion(region, animated: false)
}
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 end up by telling the map to display that region.
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, “Apple Push Notification Servers.” 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.
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.
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 AppDelegate.swift file. First, add the following enum to the top of the file, right underneath the import statements:
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 update your registerCustomActions() method in the AppDelegate.swift file to include an action button. This time, though, you’ll use the UNTextInputNotificationAction type. Replace registerCustomActions() with:
private func registerCustomActions() {
// 1
let ident = ActionIdentifier.comment.rawValue
let comment = UNTextInputNotificationAction(identifier: ident,
title: "Comment")
// 2
let category = UNNotificationCategory(identifier: categoryIdentifier,
actions: [comment],
intentIdentifiers: [])
UNUserNotificationCenter.current()
.setNotificationCategories([category])
}
A couple points to note in the previous code:
- You’re asking for text input instead of a button click, so be sure you use the
UNTextInputNotificationActionaction type. - The only change here is remembering to pass in your action.
If you build and run the app, then send that same push notification to yourself again, you should now have a keyboard on screen!
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, didReceive(_:completionHandler:). In your UI extension, in NotificationViewController.swift, add:
func didReceive(_ response: UNNotificationResponse,
completionHandler completion: @escaping
(UNNotificationContentExtensionResponseOption) -> Void) {
// 1
defer { completion(.dismiss) }
// 2
guard let resp = response as? UNTextInputNotificationResponse else {
return
}
// 3
let text = resp.userText
}
Here’s what’s going on in the code above:
-
As with most of the notification delegates, you must call the completion handler no matter how you exit the method. See below for an explanation of the parameter.
-
By looking at the type of response, you can determine whether or not you’ve received text from the end user to process.
-
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:
didReceive(_:)is called when the notification is displayed to configure the UI itself.didReceive(_:completionHandler:)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 pass .doNotDismiss to the completion handler.
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 an “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 didReceive(_:completionHandler:) with the following code:
func didReceive(_ response: UNNotificationResponse,
completionHandler completion: @escaping
(UNNotificationContentExtensionResponseOption) -> Void) {
let accept = ActionIdentifier.accept.rawValue
let cancel = ActionIdentifier.cancel.rawValue
switch response.actionIdentifier {
case accept:
let cancelAction = UNNotificationAction(identifier: cancel,
title: "Cancel")
let currentActions = extensionContext?.notificationActions ?? []
extensionContext?.notificationActions = currentActions
.map { $0.identifier == accept ? cancelAction : $0 }
case cancel:
let acceptAction = UNNotificationAction(identifier: accept,
title: "Accept")
let currentActions = extensionContext?.notificationActions ?? []
extensionContext?.notificationActions = currentActions
.map { $0.identifier == cancel ? acceptAction : $0 }
default:
break
}
completion(.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.
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(_:) method, right after you set the region on the map view:
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 AppDelegate.swift and modify the contents of the registerCustomActions method to the following:
private func registerCustomActions() {
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 set them in AppDelegate.
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.
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.
Head over to MainInterface.storyboard and drag an Image View into the View. Add the following constraints to the image view:
- A width constraint with a constant value of
80. - A height constraint with a constant value of
80. - Trailing Space to Safe Area from the image view to the View with a constant of 8.
- Top Space to Safe Area from the image view to the View with a constant of 8.
Next, add another outlet in NotificationViewController.swift:
@IBOutlet var imageView: UIImageView!
Go back to MainInterface.storyboard and connect your new Image View to your new outlet.
Now, it’s time to set the image on the image view. In NotificationViewController.swift, add the following code to the bottom of didReceive(_:), after setting the map’s location and the notification actions:
var images: [UIImage] = []
notification.request.content.attachments.forEach { attachment in
if attachment.url.startAccessingSecurityScopedResource() {
if let data = try? Data(contentsOf: attachment.url),
let image = UIImage(data: data) {
images.append(image)
}
attachment.url.stopAccessingSecurityScopedResource()
}
}
imageView.image = images.first
Here, you fetch the image from the notification content. Due to the way iOS performs its sandboxing, for security reasons, you can’t just directly access the attachment. You must wrap access to the attachments in calls to start and stop accessing scoped resources.
Build and run the app. In the push notification tester app, set the payload to the following JSON:
{
"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:
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.
Given 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:
- You ask iOS to draw a button that either disappears on play (
.overlay) or stays onscreen (.default). - You must tell iOS exactly what
CGRectto use for positioning and sizing the Play button. - 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 AppDelegate.swift file and change your text action back to a normal action to represent the payment.
In AppDelegate.swift, add a new case to the ActionIdentifier enum:
case payment
Next, replace registerCustomActions() with the following:
private func registerCustomActions() {
let ident = ActionIdentifier.payment.rawValue
let payment = UNNotificationAction(identifier: ident,
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]
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 your didReceive(_:completionHandler:) method with the following to make that happen!
func didReceive(_ response: UNNotificationResponse,
completionHandler completion: @escaping
(UNNotificationContentExtensionResponseOption) -> Void) {
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 sample project already includes a PaymentView for you that will display a slider for selecting payments. Drag the PaymentView.swift file from the starter project 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.
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:withCompletionHandler:) delegate method 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.
- Open up your NotificationViewController.swift file and set a breakpoint where you need to start debugging.
- Build and run your app.
- In Xcode’s menu bar choose Debug ▸ Attach to Process by PID or Name….
- In the dialog window that appears, enter Custom UI, or whatever you named your target.
- 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.