癒し系アプリで「今日のひとこと」を通知したとき、ふと気づいたことがあります。ユーザーはその通知をタップしてアプリを開き、メッセージを読み、また閉じる。たったそれだけのために、わざわざアプリ全体を起動させていたのです。読むだけなら、通知の中で完結させた方が親切ではないか——そう考えて取り組んだのが、インタラクティブ通知アクションでした。
インタラクティブ通知アクションとは、通知を長押し(または下にスワイプ)したときに現れる「ボタン」や「テキスト入力欄」のことです。リマインダー通知に「完了」「あとで」のボタンを付けたり、メッセージ通知に返信欄を出したりするあの仕組みです。アプリを開かずにその場で操作を終えられるので、ユーザーの手数が一気に減ります。
Rork が生成するのは通知を「送る・受け取る」までの基本コードが中心で、アクションの定義やバックグラウンド処理までは含まれないことが多いです。ここでは Expo(React Native)アプリにこの仕組みを足す設計を、App Store と Google Play で実際にアプリを運用してきた個人開発の経験を踏まえて残しておきます。
アクションは「カテゴリ」にまとめて定義する
通知アクションは個別のボタンをバラバラに定義するのではなく、「カテゴリ」という単位でまとめます。たとえば「リマインダー」というカテゴリに「完了」「1時間後に再通知」の2ボタンを束ね、通知を送るときにそのカテゴリを指定する、という構造です。
Expo では expo-notifications の setNotificationCategoryAsync で定義します。
// notificationCategories.ts — アプリ起動時に一度だけ登録する
import * as Notifications from "expo-notifications";
export async function registerCategories() {
await Notifications.setNotificationCategoryAsync("reminder", [
{
identifier: "complete",
buttonTitle: "完了",
options: { opensAppToForeground: false }, // アプリを開かず裏で処理
},
{
identifier: "snooze",
buttonTitle: "1時間後に再通知",
options: { opensAppToForeground: false },
},
]);
await Notifications.setNotificationCategoryAsync("daily_word", [
{
identifier: "reply",
buttonTitle: "感想を書く",
textInput: {
submitButtonTitle: "送信",
placeholder: "今の気持ちを一言…",
},
options: { opensAppToForeground: true }, // 入力後にアプリで保存
},
]);
}opensAppToForeground の値が、この設計の肝になります。false にするとアプリを前面に出さずに処理が走り、true にするとアクション後にアプリが開きます。「完了」ボタンのように一瞬で終わる処理は false、テキスト入力のように後で画面を見せたい処理は true、というのが基本的な使い分けです。
通知を送る側では、対応するカテゴリ ID を categoryIdentifier に指定します。
await Notifications.scheduleNotificationAsync({
content: {
title: "水やりの時間です",
body: "観葉植物に水をあげましょう",
categoryIdentifier: "reminder", // 上で定義したカテゴリ
},
trigger: { hour: 9, minute: 0, repeats: true },
});アクションを受け取って処理を分岐する
ユーザーがボタンを押した結果は、addNotificationResponseReceivedListener で受け取ります。どのアクションが押されたかは actionIdentifier、テキスト入力の中身は userText に入ります。
// notificationHandler.ts
import * as Notifications from "expo-notifications";
export function listenForActions() {
return Notifications.addNotificationResponseReceivedListener(async (res) => {
const action = res.actionIdentifier;
const data = res.notification.request.content.data;
if (action === "complete") {
await markReminderDone(data.reminderId);
} else if (action === "snooze") {
await Notifications.scheduleNotificationAsync({
content: { title: "再通知", body: data.body, categoryIdentifier: "reminder" },
trigger: { seconds: 3600 },
});
} else if (action === "reply") {
// テキスト入力の中身は userText に入る
await saveReflection(res.userText ?? "");
}
});
}ここで注意したいのは、actionIdentifier には OS が予約した特別な値も来る点です。通知本体を普通にタップした場合は Notifications.DEFAULT_ACTION_IDENTIFIER が来ます。自分で定義したアクションだけを処理し、デフォルトタップは通常の画面遷移に回す、という分岐を忘れると、本体タップが無反応になります。私はここを抜かして「通知を普通にタップしても何も起きない」という不具合を一度出してしまいました。