毎朝ひとつだけ作品を届ける、小さな個人開発アプリを App Store で運用しています。プッシュ通知に画像は出せるようになったのですが、ある日ユーザーから「通知から直接その日の絵を保存したい」という要望が届きました。通知をタップして本体を開かせるのではなく、通知の中で完結させたい。これは標準のリッチ通知では届かない領域でした。
通知の見た目を変える仕組みには二つあり、最初に私自身がここを混同していたので、まずその切り分けから始めます。Rork Max はネイティブ Swift を生成してくれますが、「通知の中に自前の画面を描く」という部分は、生成された雛形をそのまま動かすだけでは出てきません。Extension のターゲット追加、カテゴリの一致、メモリの制約といった泥臭い箇所を、自分のアプリに組み込んだ順番で書いていきます。
Service Extension と Content Extension は役割が違う
UserNotifications には拡張ポイントが二つあります。混同すると「画像は出るのにボタンが効かない」「UI を作ったのに表示されない」という迷路にはまります。
拡張 動くタイミング できること できないこと
Notification Service Extension 通知が届いた直後・表示される前 ペイロードの書き換え、画像など添付の取得、復号 UI を描くこと・ユーザー操作に反応すること
Notification Content Extension ユーザーが通知を展開したとき 自前の ViewController で UI を描く、ボタンで UI を更新する 届く前のペイロード加工(それは Service 側の仕事)
整理すると、画像をダウンロードして通知に添付するところまでは Service Extension の担当、その通知を長押し(または下スワイプ)で展開したときに「保存」ボタン付きの自前カードを描くのが Content Extension の担当です。今回の要望は後者でした。二つは併用でき、実際の本番ではセットで使うことが多いと感じています。
カテゴリという「合言葉」で結びつける
Content Extension が表示されるかどうかは、すべてカテゴリ識別子の一致 で決まります。流れはこうです。
本体アプリで UNNotificationCategory を登録する(識別子と、付けたいアクションボタン)
プッシュのペイロードに同じ識別子を category として載せる
Content Extension の Info.plist の UNNotificationExtensionCategory に同じ識別子を書く
この三つが一文字でもずれると、カスタム UI は黙って出ません。エラーも出ないので、最初の失敗はたいていここです。
本体アプリ側の登録は、起動時に一度だけ行います。
import UserNotifications
enum NotificationSetup {
static func registerCategories () {
let save = UNNotificationAction (
identifier : "SAVE_PICK" ,
title : "保存" ,
options : []
)
let openShuffle = UNNotificationAction (
identifier : "SHUFFLE_PICK" ,
title : "別の作品を見る" ,
options : []
)
let dailyPick = UNNotificationCategory (
identifier : "DAILY_PICK" , // ← この合言葉が要
actions : [save, openShuffle],
intentIdentifiers : [],
options : []
)
UNUserNotificationCenter. current ()
. setNotificationCategories ([dailyPick])
}
}
送信するペイロード側は、同じ category を持たせ、Service Extension に画像を取りに行かせるため mutable-content も立てておきます。
{
"aps" : {
"alert" : { "title" : "今日の一枚" , "body" : "本日の作品が届きました" },
"mutable-content" : 1 ,
"category" : "DAILY_PICK"
},
"image_url" : "https://example.com/picks/2026-06-18.jpg"
}
Content Extension のターゲットと Info.plist
Rork Max が生成したプロジェクトに、Xcode で Notification Content Extension のターゲットを追加します(File → New → Target → Notification Content Extension)。生成されるのは UIViewController 一つと、専用の Info.plist です。
肝心なのは Info.plist の NSExtensionAttributes に並ぶ4つのキーです。ここの意味を取り違えると表示が崩れます。
キー 型 役割
UNNotificationExtensionCategoryString / Array 結びつけるカテゴリ識別子。本体・ペイロードと一致必須
UNNotificationExtensionInitialContentSizeRatioNumber 初期の高さ÷幅。0.5 なら横の半分の高さで開く
UNNotificationExtensionDefaultContentHiddenBool 標準のタイトル/本文を隠すか。自前 UI に集中させるなら true
UNNotificationExtensionUserInteractionEnabledBool カスタム UI 内のボタンを押せるようにするか。false だとボタンが死ぬ
私自身が最初に丸一日溶かしたのは、最後の UserInteractionEnabled を入れ忘れて「ボタンを置いたのに反応しない」状態に陥ったときでした。テンプレートには含まれないことがあるので、手で足すつもりでいたほうが安全です。
展開時に UI を描く
ViewController は UNNotificationContentExtension プロトコルに準拠し、didReceive(_:) で通知の内容を受け取って描きます。ここでは Service Extension が添付した画像と、その日のタイトルを表示します。
import UIKit
import UserNotifications
import UserNotificationsUI
final 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
// Service Extension が添付した画像を読む
if let attachment = content.attachments. first ,
attachment. url . startAccessingSecurityScopedResource () {
defer { attachment. url . stopAccessingSecurityScopedResource () }
imageView. image = UIImage ( contentsOfFile : attachment. url .path)
}
// App Group を見て、すでに保存済みかどうかを反映
statusLabel. text = SharedStore. isSaved ( id : content.threadIdentifier)
? "保存済み" : "通知から保存できます"
}
}
SwiftUI で組みたい場合は、この NotificationViewController の中に UIHostingController を子として載せれば、見た目だけ SwiftUI に寄せられます。私の場合はレイアウトが単純なうちは UIKit のまま置き、複雑になってから SwiftUI に切り替える進め方を推奨します。
ボタンを押しても通知を閉じない
ここが Content Extension のいちばん面白いところです。ユーザーが「保存」を押したとき、通知を閉じずにその場で「保存済み」へ表示を変えたい。それを実現するのが 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) // App Group に書く
statusLabel. text = "保存済み"
completion (.doNotDismiss) // ← 閉じずに UI を更新
case "SHUFFLE_PICK" :
completion (.dismissAndForwardAction) // 本体アプリへ引き継ぐ
default:
completion (.dismiss)
}
}
}
completion(.doNotDismiss) を返すと通知は開いたままになり、statusLabel の更新がそのまま画面に残ります。ここを .dismiss のままにしていると、保存はできても表示が変わる前に通知が閉じてしまい、「効いていないように見える」という分かりにくい不具合になります。私自身も本番運用で実際に踏みました。対処は単純で、保存処理のあとに必ず completion(.doNotDismiss) を返すことだけです。
「保存」を本体アプリと共有する
通知から保存した状態は、本体アプリを開いたときにも反映されていてほしいものです。Content Extension と本体アプリは別プロセスなので、ただ UserDefaults.standard に書いても相手からは見えません。ここで App Group を使います。
Xcode の Signing & Capabilities で、本体アプリと Content Extension の両方に同じ App Group(例: group.net.rorklab.dailypick)を追加してから、共有の suite を経由します。
enum SharedStore {
private static let suite = UserDefaults ( suiteName : "group.net.rorklab.dailypick" ) !
static func markSaved ( id : String ) {
var ids = suite. stringArray ( forKey : "savedIDs" ) ?? []
if ! ids. contains (id) { ids. append (id) }
suite. set (ids, forKey : "savedIDs" )
}
static func isSaved ( id : String ) -> Bool {
(suite. stringArray ( forKey : "savedIDs" ) ?? []). contains (id)
}
}
suiteName の文字列が両ターゲットで一致していないと、書いた側だけが知っている状態になり、本体アプリには永遠に届きません。私はここを group. の付け忘れで一度やられました。画像そのものを共有したいときは、FileManager の containerURL(forSecurityApplicationGroupIdentifier:) で取れる共有コンテナにファイルを置きます。
出ないときに見る6か所
カスタム UI が表示されないとき、原因はほぼ決まった場所にあります。私が毎回この順で確認しています。
カテゴリの不一致 — 本体の UNNotificationCategory / ペイロードの category / Info.plist の UNNotificationExtensionCategory の三者が完全一致しているか。最頻の原因です
通知を展開していない — バナーのままでは標準表示です。長押しか下スワイプで展開して確認します
UserInteractionEnabled が false — ボタンが押せないのはほぼこれ
メモリ上限 — Content Extension のプロセスはメモリ制約が厳しく、大きすぎる画像で落ちます。落ちるのを回避するには、表示用に縮小した画像を Service Extension 側で用意しておくことを推奨します
completion の戻し忘れ — didReceive(_ response:) で completion を呼ばないと UI が固まります
App Group の suiteName ずれ — 保存が本体に反映されないのはこれ
なぜこれが Rork Max を選ぶ理由になるのか
通知の中に自前の画面を描き、その場でボタン操作に反応させる仕組みは、Extension という別ターゲットを足し、本体と App Group で結ぶという「ネイティブ側の構造」を前提にしています。React Native(Expo)でクロスプラットフォームに素早く作る進め方では、この層に手を伸ばすのは骨が折れます。
逆に言えば、通知をタップさせて本体を開かせれば済むアプリなら、標準の Rork で十分です。通知の中で完結させたい、展開時の体験に価値があると判断したときに初めて、ネイティブ Swift を生成する Rork Max に移る意味が出てきます。私自身の運用でも、この線引きを「通知内で完結させる価値があるか」で測るようにしています。
まずは UNNotificationExtensionCategory を本体・ペイロードと揃えるところから一つ試してみてください。合言葉が噛み合った瞬間に、自前のカードが通知の中にすっと現れます。