Rork Max のネイティブ Swift で AlarmKit のタイマーを実装する — 消音・集中モードを越えて鳴らす
ローカル通知が消音モードや集中モードで鳴らない問題を、iOS 26 の AlarmKit で解決する実装メモです。認可・カウントダウンの schedule・ロック画面と Dynamic Island の Live Activity・実行中タイマーの一覧までの Swift コードと、Rork Max のネイティブ生成や Expo からの橋渡しの境界設計を、実際にハマった点を添えてまとめます。
struct CountdownText: View { let state: AlarmPresentationState var body: some View { if case let .countdown(countdown) = state.mode { Text(timerInterval: Date.now ... countdown.fireDate) .monospacedDigit() .lineLimit(1) } }}
struct TimerRow: View { let alarm: Alarm var body: some View { HStack { switch alarm.state { case .countdown: Text("実行中") case .alerting: Text("鳴動中") case .paused: Text("一時停止") default: EmptyView() } Spacer() Button("キャンセル", systemImage: "xmark") { try? AlarmManager.shared.cancel(id: alarm.id) } .labelStyle(.iconOnly) } }}
cancel(id:) で個別に止められます。この「生の残り時間はアプリ側に来ない」という設計は最初は不便に感じますが、カウントダウンの責務を Live Activity に一本化することで、アプリが前面にいなくても表示が破綻しないようになっている、と理解すると腑に落ちます。
Rork Max のネイティブ Swift と、Expo からの橋渡し
ここが、Rork を使う私たちにとっての本題です。AlarmKit は純粋なネイティブ iOS フレームワークで、Swift から呼ぶことが前提です。通常の Rork が生成する React Native(Expo)アプリからは、JavaScript の世界だけで AlarmKit に触れることはできません。一方、Rork Max はネイティブ Swift を生成するため、AlarmKit のような「React Native の標準では届かない」フレームワークを、生成コードの一部として組み込めます。
私の場合は、判断軸をこう置いています。アプリの中核がタイマー・アラームで、ロック画面の体験まで作り込みたいなら、最初から Rork Max のネイティブ Swift で組むことをお勧めします。一方、すでに Expo で大半を作っていて、一部の画面にだけ確実に鳴るタイマーが欲しい、という場合は、Expo Modules API でネイティブモジュールを切り出して橋渡しします。次の表が、その使い分けです。
状況
推奨アプローチ
橋渡しの境界
タイマー・アラームが中核機能
Rork Max のネイティブ Swift で実装
橋渡し不要。SwiftUI から直接 AlarmKit を呼ぶ
既存の Expo アプリに一部だけ追加
Expo Modules API でネイティブモジュール化
「開始・キャンセル・状態購読」だけを公開し、UI は JS 側
iOS 26 未満も同一バイナリで対応
availability で分岐し、旧 OS はローカル通知へ
モジュール内で if #available(iOS 26, *) 分岐
Expo Modules API で橋渡しする場合、JavaScript 側に公開するのは最小限に絞るのが肝心です。私は「タイマーを開始する」「id を指定してキャンセルする」「実行中の id 一覧を流す」の3つだけを境界にしました。AlarmPresentation や AlarmAttributes の組み立て、Live Activity の描画といった Swift 固有の部分は、すべてネイティブモジュールの内側に閉じ込めます。境界を薄く保つほど、後で AlarmKit 側の API が変わっても影響範囲が小さくて済みます。
// Expo Modules API で公開する最小の境界(イメージ)import ExpoModulesCoreimport AlarmKitpublic class AlarmTimerModule: Module { public func definition() -> ModuleDefinition { Name("AlarmTimer") AsyncFunction("startTimer") { (seconds: Double) -> String in // 認可確認 → AlarmKit の schedule を呼び、id 文字列を返す let id = UUID() // ...(前述の schedule をここで実行)... return id.uuidString } AsyncFunction("cancelTimer") { (id: String) in if let uuid = UUID(uuidString: id) { try? AlarmManager.shared.cancel(id: uuid) } } }}
注意したいのは、if #available(iOS 26, *) の分岐を必ず入れることです。Expo アプリは幅広い OS バージョンの端末に配られます。AlarmKit が使えない端末では、同じ「タイマー開始」の呼び出し口から、静かに UserNotifications のフォールバックへ切り替える設計にしておくと、JS 側のコードを OS バージョンで分岐させずに済みます。