ある朝、習慣トラッカーのウィジェットを眺めていて、ふと手が止まりました。チェックを付けるためだけにアプリを開き、ロード画面を待ち、該当の項目までスクロールする。たった1タップの記録に、5秒近くかかっています。ウィジェットの上で直接チェックできれば、この5秒はゼロになるはずでした。
iOS 17 以降、ウィジェットの中のボタンはアプリを起動せずに処理を実行できます。仕組みとしては知っていました。けれど Rork が生成する Expo ベースのアプリでこれを成立させるのは、想像よりずっと骨が折れました。React Native の世界とウィジェットの世界は、別のプロセスで、別の言語で動いているからです。
ここでは「ウィジェットのボタンをタップ→アプリを開かずに状態を更新→ウィジェットの見た目も即座に変わる」という往復を、Rork 製アプリで通すまでの手順を整理します。詰まった2か所も具体的に残しておきます。
なぜ「ボタンを追加して」では動かないのか
まず構造を押さえておきます。ホーム画面のウィジェットは、アプリ本体とは別の App Extension (拡張ターゲット)として動きます。この拡張プロセスの中には JavaScript エンジンが存在しません。つまり React Native のコードは1行も走りません。Rork のチャットに「ウィジェットにボタンを付けて」と頼んでも、返ってくるのはアプリ内画面のボタンであって、ホーム画面で完結するボタンにはならない理由がここにあります。
ウィジェット内のボタンが「アプリを開かずに」動くためには、3つの部品を自分で用意して噛み合わせる必要があります。
ひとつ目は App Intent 。Button(intent:) に渡せる、システムが裏側で実行してくれる処理単位です。ふたつ目は App Group 。アプリ本体と拡張ターゲットが同じデータを読み書きするための共有領域です。みっつ目は タイムラインの再読み込み 。Intent が状態を変えたあと、ウィジェットの絵を描き直させる引き金です。
Rork(標準版・Expo ベース)はこのうち1つも自動では用意しません。Rork Max(Swift ネイティブ生成)であれば WidgetKit 拡張ごと生成できますが、すでに Expo で公開済みのアプリに後付けする場合は、ここで述べる config plugin ルートが現実的です。
App Group を最初に通しておく
順序が大切です。ボタンの実装より先に、アプリ本体と拡張が同じデータを見られる状態を作ります。ここが通っていないと、あとで「ボタンは反応しているのに見た目が変わらない」という、原因の切り分けが難しいバグに化けます。
App Group の識別子を決めます(例: group.net.rorklab.habit)。Apple Developer のターゲット設定で、アプリ本体と拡張ターゲットの両方に同じ App Group を有効化します。Expo では app.json の entitlements に追記します。
{
"expo" : {
"ios" : {
"entitlements" : {
"com.apple.security.application-groups" : [
"group.net.rorklab.habit"
]
}
}
}
}
React Native 側からこの共有領域に書き込むには、UserDefaults(suiteName:) を叩くネイティブモジュールが必要です。expo-shared-defaults のようなライブラリ、あるいは config plugin から薄いブリッジを足します。最小の Swift ブリッジはこうなります。
import Foundation
@objc ( SharedDefaults )
class SharedDefaults : NSObject {
static let suite = UserDefaults ( suiteName : "group.net.rorklab.habit" )
@objc func setBool ( _ value: Bool , key : String ) {
SharedDefaults.suite ? . set (value, forKey : key)
}
@objc func getBool ( _ key: String , resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock) {
resolve (SharedDefaults.suite ? . bool ( forKey : key) ?? false )
}
}
これで「JS から書いた値をウィジェット拡張が読める」「拡張が書いた値を JS が読める」という双方向の土台ができます。最初にこれを単純な真偽値で往復確認しておくと、後段が一気に楽になります。
App Intent を拡張ターゲットに書く
ボタンの実体は App Intent です。拡張ターゲット側(Swift)に置きます。習慣トラッカーで「今日の完了をトグルする」Intent を例にします。
import AppIntents
import WidgetKit
struct ToggleHabitIntent : AppIntent {
static var title: LocalizedStringResource = "今日の習慣をトグル"
@Parameter (title : "Habit ID" )
var habitID: String
init () {}
init ( habitID : String ) { self .habitID = habitID }
func perform () async throws -> some IntentResult {
let defaults = UserDefaults ( suiteName : "group.net.rorklab.habit" )
let key = "done_ \( habitID ) _ \( todayKey () ) "
let current = defaults ? . bool ( forKey : key) ?? false
defaults ? . set ( ! current, forKey : key)
WidgetCenter.shared. reloadTimelines ( ofKind : "HabitWidget" )
return . result ()
}
private func todayKey () -> String {
let f = DateFormatter ()
f.dateFormat = "yyyy-MM-dd"
return f. string ( from : Date ())
}
}
perform() が呼ばれる時点で、アプリ本体は起動していません。だからここで AdMob の初期化や RN ブリッジに触れようとすると失敗します。perform() の中でできるのは、共有領域への読み書きと WidgetCenter への通知まで 、と割り切るのが安全です。重い処理はアプリ次回起動時に同期する設計にします。
最後の reloadTimelines(ofKind:) が、3部品目の「再読み込み」です。これを呼ばないと、状態は変わったのに絵が古いまま、という現象になります。
ウィジェット本体でボタンを描く
ウィジェットの SwiftUI ビューで、先ほどの Intent をボタンに結びつけます。Button(intent:) はこの用途のために用意された API です。
import SwiftUI
import WidgetKit
struct HabitWidgetView : View {
let entry: HabitEntry
var body: some View {
VStack ( alignment : .leading, spacing : 8 ) {
Text (entry.habitName). font (.headline)
Button ( intent : ToggleHabitIntent ( habitID : entry.habitID)) {
Label (entry.isDone ? "完了" : "未完了" ,
systemImage : entry.isDone ? "checkmark.circle.fill" : "circle" )
}
. tint (entry.isDone ? .green : .gray)
}
. containerBackground (.fill.tertiary, for : .widget)
}
}
HabitEntry は TimelineProvider が App Group から読んだ状態をそのまま持たせます。Provider が共有領域の真偽値を読み、ビューが描き、ボタンが Intent を呼び、Intent が共有領域を書き換えて reload する。この円環が閉じれば、アプリを開かずにチェックが付きます。
落とし穴その1:ボタンを押しても何も起きない
最初に2時間溶かしたのがこれでした。シミュレータでボタンを押しても、ログにも出ない、状態も変わらない。
原因は App Intent の置き場所でした。Button(intent:) から呼ばれる App Intent は、ウィジェット拡張のターゲットメンバーシップに含まれていなければ動きません 。アプリ本体側だけに Intent を置いて、拡張からはインポートしているつもりになっていると、ビルドは通るのに実行時に黙って無視されます。Xcode の File Inspector で、Intent の .swift ファイルが拡張ターゲットにチェックされているか確認してください。Expo の config plugin で拡張を生成している場合は、plugin が Intent ファイルを拡張のソースに含めているかを project.pbxproj 生成後に検証します。
もう一点、実機での確認が要ります。インタラクティブウィジェットの挙動はシミュレータと実機で微妙に違い、特に reload のタイミングは実機の方が正直です。
落とし穴その2:タップ直後に状態が巻き戻る
ボタンは効くようになった。けれど、チェックを付けた直後にアプリを前面に持ってくると、チェックが消えている。巻き戻りです。
これは「真実の源(source of truth)」が二重化していたのが原因でした。アプリ本体は自分の永続ストア(私の場合は MMKV)を真実とみなし、起動時にそれを App Group へ書き出していました。一方でウィジェットの Intent は App Group を直接書き換えます。アプリが前面に戻った瞬間、本体が「自分の方が正しい」とばかりに古い値を App Group へ上書きし、ウィジェットの変更を消していたのです。
解決は、書き込みの方向を一方通行に整理することでした。App Group を、ウィジェット由来の変更を受け取る「受信箱」として扱う 設計にします。アプリ復帰時には、App Group の値とアプリ本体の値を比べ、タイムスタンプの新しい方を採用してマージします。単純な上書きをやめるだけで、巻き戻りは止まりました。
// アプリ復帰時のマージ(擬似コード)
let widgetValue = sharedDefaults. bool ( forKey : key)
let widgetUpdatedAt = sharedDefaults. double ( forKey : key + "_ts" )
if widgetUpdatedAt > localStore. updatedAt ( for : key) {
localStore. set (widgetValue, for : key) // ウィジェット側が新しければ採用
}
// 逆方向は「ローカルが新しいときだけ」書き出す
源を一方向に保つこの考え方は、ウィジェットに限らず、複数プロセスが同じ状態に触れる場面すべてに効きます。
更新予算という現実的な制約
ひとつ実運用の感覚を共有します。インタラクティブウィジェットの reloadTimelines は即時の再描画を促しますが、ウィジェット全体には iOS が課す更新予算があり、1日あたりおおむね数十回程度の自動更新に収まるよう設計されています。ボタン操作による reload はユーザー起点なので比較的優遇されますが、Intent の perform() で外部 API を毎回叩くような重い設計にすると、応答が遅れてタップ感が損なわれます。
個人開発で複数のアプリを並行して運用していると、ウィジェットの応答速度はそのまま継続率に効いてきます。私の運用では、perform() は共有領域の更新と reload だけに留め、サーバー同期はアプリ本体の次回起動時にまとめて行う方針にしています。ウィジェットは「素早く反応する手元の操作面」、本体は「重い処理をまとめて引き受ける場所」と役割を分けると、体感も予算も両立します。
ここまで来たら、次の一歩
まずは真偽値ひとつのトグルで、ボタン→共有領域→reload→再描画の円環を最後まで閉じてみてください。この最小の往復が一度通れば、複数習慣のリスト、数量のインクリメント、複数サイズ対応へと、同じ型で広げていけます。
Expo の標準機能だけでは届かない領域に、config plugin と少量の Swift で橋を架ける。その境界線を一度自分の手で越えておくと、「これは Rork(Expo)のまま行ける」「ここからは Rork Max(ネイティブ)に寄せた方が早い」という判断が、推測ではなく実感で下せるようになります。同じ往復で詰まっている方の、手がかりになれば幸いです。