●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities React Native cannot reach: AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, Siri Intents, and HealthKit●RN — Standard Rork builds cross-platform apps with React Native (Expo), a good fit when you want something working fast●CHOICE — Pick React Native for speed, or Rork Max when you need Apple hardware and OS integration●PRICE — Rork is free to start with paid plans from $25/mo; Rork Max is $200/mo●FLOW — Describe the app you want in plain language and Rork produces working code you can ship to the stores●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities React Native cannot reach: AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, Siri Intents, and HealthKit●RN — Standard Rork builds cross-platform apps with React Native (Expo), a good fit when you want something working fast●CHOICE — Pick React Native for speed, or Rork Max when you need Apple hardware and OS integration●PRICE — Rork is free to start with paid plans from $25/mo; Rork Max is $200/mo●FLOW — Describe the app you want in plain language and Rork produces working code you can ship to the stores
A Custom Screen Appears When You Long-Press a Notification in a Rork Max App — Implementing a Notification Content Extension
How to add a Notification Content Extension to a Swift app generated by Rork Max so a custom UI is drawn only when the notification expands. Covers the split with the Service Extension, updating the in-notification UI from a button, sharing state via an App Group, and the order to check when nothing shows — all with working code.
I run a small indie app on the App Store that delivers a single piece of artwork every morning. Push notifications could already show an image, but one day a user wrote in asking to save the day's picture straight from the notification. Not tap through to open the app — finish the whole thing inside the notification. That is a place standard rich notifications cannot reach.
There are two mechanisms for changing how a notification looks, and I confused them myself at first, so let me start with that distinction. Rork Max generates native Swift, but "drawing your own screen inside the notification" does not appear by running the generated scaffold as-is. Adding the extension target, matching categories, working within the memory limits — I'll walk through the dirt in the order I wired it into my own app.
The Service Extension and the Content Extension do different jobs
UserNotifications has two extension points. Confuse them and you wander a maze of "the image shows but the button does nothing" and "I built the UI but it never appears."
Extension
When it runs
What it can do
What it cannot do
Notification Service Extension
Right after delivery, before display
Rewrite the payload, fetch attachments like images, decrypt
Draw UI or respond to interaction
Notification Content Extension
When the user expands the notification
Draw UI in your own view controller, update it from a button
Pre-delivery payload work (that is the Service side's job)
To put it plainly: downloading the image and attaching it to the notification is the Service Extension's job, and drawing your own card with a "Save" button when that notification is expanded (long-press or pull down) is the Content Extension's job. This request was the latter. The two work together, and in production I find they usually ship as a pair.
A category is the "password" that ties it together
Whether the Content Extension appears at all comes down entirely to a matching category identifier. The flow is this:
The main app registers a UNNotificationCategory (its identifier and the action buttons you want)
The push payload carries the same identifier as category
The Content Extension's Info.plist sets the same identifier in UNNotificationExtensionCategory
If those three drift by even one character, the custom UI silently fails to appear. No error is raised, so this is where the first failure almost always lives.
Registration on the main app side happens once, at launch.
import UserNotificationsenum NotificationSetup { static func registerCategories() { let save = UNNotificationAction( identifier: "SAVE_PICK", title: "Save", options: [] ) let openShuffle = UNNotificationAction( identifier: "SHUFFLE_PICK", title: "See another piece", options: [] ) let dailyPick = UNNotificationCategory( identifier: "DAILY_PICK", // ← this password matters actions: [save, openShuffle], intentIdentifiers: [], options: [] ) UNUserNotificationCenter.current() .setNotificationCategories([dailyPick]) }}
The payload you send carries the same category, plus mutable-content so the Service Extension goes and fetches the image.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Separate the Service Extension (rewrite before delivery) from the Content Extension (draw on expand) and decide which one your app actually needs
✦Get the four Info.plist keys including UNNotificationExtensionCategory and UserInteractionEnabled, plus working code that returns completion(.doNotDismiss) to update the in-notification UI on a button tap
✦Learn the App Group design that shares the saved state with the main app, and the six things to check, in order, when the custom UI refuses to appear
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
In the project Rork Max generated, add a Notification Content Extension target in Xcode (File → New → Target → Notification Content Extension). What it creates is a single UIViewController and a dedicated Info.plist.
What matters are the four keys under NSExtensionAttributes in that Info.plist. Misread their meaning and the layout breaks.
Key
Type
Role
UNNotificationExtensionCategory
String / Array
The category identifier to bind to. Must match the main app and payload
UNNotificationExtensionInitialContentSizeRatio
Number
Initial height ÷ width. 0.5 opens at half the width in height
UNNotificationExtensionDefaultContentHidden
Bool
Whether to hide the standard title/body. Set true to focus on your own UI
UNNotificationExtensionUserInteractionEnabled
Bool
Whether buttons inside the custom UI are tappable. false kills the buttons
What cost me a full day at first was forgetting that last one, UserInteractionEnabled, and landing in the "I placed a button but it does nothing" state. The template does not always include it, so plan to add it by hand.
Drawing the UI on expand
The view controller conforms to the UNNotificationContentExtension protocol and draws by receiving the notification in didReceive(_:). Here it shows the image the Service Extension attached and the title for the day.
import UIKitimport UserNotificationsimport UserNotificationsUIfinal class NotificationViewController: UIViewController, UNNotificationContentExtension { private let imageView = UIImageView() private let titleLabel = UILabel() private let statusLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.cornerRadius = 12 titleLabel.font = .preferredFont(forTextStyle: .headline) statusLabel.font = .preferredFont(forTextStyle: .footnote) statusLabel.textColor = .secondaryLabel let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, statusLabel]) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), stack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 0.66) ]) } func didReceive(_ notification: UNNotification) { let content = notification.request.content titleLabel.text = content.title // Read the image the Service Extension attached if let attachment = content.attachments.first, attachment.url.startAccessingSecurityScopedResource() { defer { attachment.url.stopAccessingSecurityScopedResource() } imageView.image = UIImage(contentsOfFile: attachment.url.path) } // Reflect whether this was already saved, via the App Group statusLabel.text = SharedStore.isSaved(id: content.threadIdentifier) ? "Saved" : "Save it from the notification" }}
If you want to build it in SwiftUI, host a UIHostingController as a child inside this NotificationViewController and the look can lean on SwiftUI. While the layout is simple I leave it in UIKit, and switch to SwiftUI once it grows complex — that is the progression I use.
Tapping a button without dismissing the notification
This is the most interesting part of the Content Extension. When the user taps "Save," I want to change the display to "Saved" right there without closing the notification. What makes that possible is how you return from didReceive(_ response:completionHandler:).
extension NotificationViewController { func didReceive( _ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void ) { let id = response.notification.request.content.threadIdentifier switch response.actionIdentifier { case "SAVE_PICK": SharedStore.markSaved(id: id) // write to the App Group statusLabel.text = "Saved" completion(.doNotDismiss) // ← keep it open, update the UI case "SHUFFLE_PICK": completion(.dismissAndForwardAction) // hand off to the main app default: completion(.dismiss) } }}
Returning completion(.doNotDismiss) keeps the notification open, and the update to statusLabel stays on screen. Leave it at .dismiss and the save still happens, but the notification closes before the display changes — a confusing bug that looks like "it isn't working." I hit it in production.
Sharing "Save" with the main app
A state saved from the notification should also be reflected when the user opens the main app. The Content Extension and the main app are separate processes, so writing to UserDefaults.standard alone is invisible to the other side. This is where an App Group comes in.
In Xcode's Signing & Capabilities, add the same App Group (for example group.net.rorklab.dailypick) to both the main app and the Content Extension, then go through the shared suite.
If the suiteName string does not match across both targets, only the writer knows the state and the main app never sees it. I once got burned here by forgetting the group. prefix. To share the image itself, drop the file into the shared container you get from FileManager's containerURL(forSecurityApplicationGroupIdentifier:).
Six places to look when nothing shows
When the custom UI does not appear, the cause is almost always in a fixed set of places. I check them in this order every time.
Category mismatch — Are the main app's UNNotificationCategory, the payload's category, and the Info.plist's UNNotificationExtensionCategory an exact match? This is the most frequent cause
The notification wasn't expanded — A banner stays in the standard layout. Long-press or pull down to expand and check
UserInteractionEnabled is false — Buttons that won't respond are nearly always this
Memory limit — The Content Extension process is tightly memory-constrained and crashes on oversized images. Prepare a downscaled display image on the Service Extension side to be safe
Forgot to return completion — Not calling completion in didReceive(_ response:) freezes the UI
App Group suiteName drift — A save not reflected in the main app is this
Why this becomes a reason to choose Rork Max
Drawing your own screen inside a notification and responding to a button there on the spot assumes a "native-side structure": adding a separate extension target and tying it to the main app through an App Group. In the React Native (Expo) approach of building cross-platform quickly, reaching into this layer is hard work.
Put the other way, if your app is fine making the user tap through to open the main app, standard Rork is plenty. Only when you decide there's value in finishing inside the notification, in the expanded experience itself, does moving to Rork Max — which generates native Swift — start to make sense. In my own operation, I measure that line by asking "is there value in completing this inside the notification?"
Try one thing first: line up UNNotificationExtensionCategory with the main app and the payload. The moment the password clicks, your own card slides quietly into the notification.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.