Rork Max × WidgetKit・ライブアクティビティ実装ガイド 2026 — Dynamic Island 対応まで
個人開発で運用している壁紙アプリに「今日の一枚」を映すウィジェットを載せた夜、私が最初に面食らったのは、更新のされなさでした。getTimeline で数分おきに新しい画像へ差し替えるつもりでいたのですが、iOS は省電力のために再読み込みをかなり間引きます。翌朝ホーム画面を見ると、前日の絵柄がそのまま残っておりました。
そこで方針を変えました。一日分のエントリをあらかじめ Timeline に積んでおき、深夜0時に一度だけ全体を組み直す設計です。リクエストの回数は最小限に収まり、それでいてユーザーから見れば毎朝きちんと絵柄が変わります。この「先に焼き込む」考え方にたどり着けたのは、実機のホーム画面を何日も眺めたあとでした。いま思えば、Widget を本体アプリの縮小版として捉えていたことが遠回りの原因だったのかもしれません。
Widget は「呼ばれたときに作る画面」ではなく、「先に用意しておく紙芝居」です。 この見方に切り替えてから、更新まわりで手が止まる時間がはっきり減りました。
Rork Max はネイティブ Swift コードを生成できる AI アシスト開発環境です。WidgetKit・ActivityKit の実装もプロンプトドリブンで進められますが、SwiftUI の仕組みと Widget 固有の制約を押さえていないと、出力されたコードに誤りが混ざっていても気づけません——そして Widget の誤りは、クラッシュではなく「何も表示されない空白」として現れるぶん、なおさら見つけにくいのです。以降の各セクションでは、実装の原理と、実運用でつまずいた箇所の手触りを並べてお伝えします。
1. WidgetKit の基本アーキテクチャを理解する
WidgetKit はアプリ本体とは別プロセスで動作する Widget Extension として実装されます。アプリのコードを直接呼び出せないため、AppGroup や UserDefaults(suite 付き)を使ってデータを共有する設計が必要です。
Widget の核となるコンポーネントは3つです。
TimelineEntry。 特定の時刻における Widget の状態を表す構造体です。表示したいデータをすべてここに持たせます。
TimelineProvider。 iOS が「次のタイムラインをいつ更新するか」を判断するために呼び出すプロトコルです。placeholder・getSnapshot・getTimeline の3メソッドを実装します。
EntryView。 TimelineEntry を受け取って SwiftUI ビューを返す型です。ウィジェットの見た目はすべてここに書きます。
// 最小構成のWidgetKit実装
import WidgetKit
import SwiftUI
// 1. TimelineEntry — ウィジェットに表示するデータ
struct AppEntry : TimelineEntry {
let date: Date
let title: String
let progress: Double // 0.0 〜 1.0
}
// 2. TimelineProvider — タイムライン供給ロジック
struct AppProvider : TimelineProvider {
func placeholder ( in context: Context) -> AppEntry {
AppEntry ( date : .now, title : "読み込み中..." , progress : 0.5 )
}
func getSnapshot ( in context: Context,
completion : @escaping (AppEntry) -> Void ) {
let entry = AppEntry ( date : .now, title : "今日のタスク: 5件" , progress : 0.6 )
completion (entry)
}
func getTimeline ( in context: Context,
completion : @escaping (Timeline<AppEntry>) -> Void ) {
// AppGroupからデータを取得
let defaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
let title = defaults ? . string ( forKey : "widget_title" ) ?? "タスクなし"
let progress = defaults ? . double ( forKey : "widget_progress" ) ?? 0.0
let entry = AppEntry ( date : .now, title : title, progress : progress)
// 15分後に再更新をリクエスト
let nextUpdate = Calendar.current. date ( byAdding : .minute, value : 15 , to : .now) !
let timeline = Timeline ( entries : [entry], policy : . after (nextUpdate))
completion (timeline)
}
}
// 3. EntryView — SwiftUIビューの定義
struct AppWidgetEntryView : View {
var entry: AppProvider.Entry
var body: some View {
VStack ( alignment : .leading, spacing : 8 ) {
Text (entry.title)
. font (.headline)
. lineLimit ( 2 )
ProgressView ( value : entry.progress)
. tint (.blue)
Text ( " \( Int (entry. progress * 100 ) ) % 完了" )
. font (.caption)
. foregroundStyle (.secondary)
}
. padding ()
. containerBackground (.background, for : .widget)
}
}
// 4. Widget定義
@main
struct AppWidget : Widget {
let kind = "AppWidget"
var body: some WidgetConfiguration {
StaticConfiguration ( kind : kind, provider : AppProvider ()) { entry in
AppWidgetEntryView ( entry : entry)
}
. configurationDisplayName ( "進捗ウィジェット" )
. description ( "タスクの進捗を確認できます。" )
. supportedFamilies ([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular])
}
}
期待する出力 : ホーム画面に「今日のタスク: 5件 / 60% 完了」のウィジェットが表示されます。
Rork Max にこの構造を理解させてプロンプトを書くと、実用的なコードが出力されます。「WidgetKit Extension を作成して、AppGroup 経由でデータを受け取り、ホーム画面とロック画面に対応したウィジェットを実装してください。supportedFamilies は small・medium・accessoryCircular・accessoryRectangular を含めてください」のように、具体的な要件を指定するのがコツです。
2. Rork Max でのプロジェクトセットアップ
Rork Max でウィジェットを実装する前に、Xcode 側での準備が必要です。Rork Max は基本的なファイル生成は行いますが、Xcode のターゲット設定や Capability の追加は手動で行う必要があります。
2-1. Widget Extension ターゲットを追加する
Xcode のメニューから「File → New → Target → Widget Extension」を選択します。このとき「Include Live Activity」にもチェックを入れておくと、ActivityKit 用のボイラープレートも生成されます。
ターゲット名は AppNameWidget のように命名するのが慣例です。バンドル ID は com.yourapp.widget という形式になります。
2-2. AppGroup を設定する
アプリ本体と Widget Extension の間でデータを共有するには AppGroup が必須です。Xcode の「Signing & Capabilities」タブから両ターゲットに同じ AppGroup ID(例: group.com.yourapp.shared)を追加します。
2-3. Rork Max へのプロンプト設計
セットアップが完了したら、Rork Max に次のようにプロンプトを渡します。
Widget Extension ターゲット「TaskWidget」が追加済みです。
AppGroup ID は「group.com.yourapp.shared」です。
以下の仕様で TaskWidgetBundle.swift・TaskWidget.swift を実装してください:
- アプリ側では UserDefaults(suiteName:) でタスク件数と完了率を保存する
- ウィジェット側では同じ UserDefaults から値を読み取り表示する
- 対応ファミリー: systemSmall, systemMedium, accessoryCircular, accessoryRectangular
- iOS 17 の .containerBackground モディファイアを使用する
具体的なファイル名・AppGroup ID・対応ファミリーを明示することで、Rork Max の出力精度が大幅に向上します。
2-4. 複数のウィジェットは1つの Extension にまとめる
ウィジェットの種類が増えたとき、Extension をその都度追加したくなります。ただ、実際に運用してみると分割は割に合いません。Extension ごとに署名・Entitlements・Privacy Manifest の管理が増え、App Store の審査でも個別に見られます。
WidgetBundle を使えば、1つの Extension の中に複数の Widget と ActivityConfiguration を並べられます。
import WidgetKit
import SwiftUI
@main
struct AppWidgetBundle : WidgetBundle {
var body: some Widget {
AppWidget () // ホーム画面・ロック画面用
ConfigurableProjectWidget () // ユーザーが表示対象を選べるウィジェット
DeliveryActivityWidget () // ライブアクティビティ
}
}
@main を付けるのは WidgetBundle 側だけです。個々の Widget 構造体に @main が残っていると 'main' attribute cannot be used in a module that contains top-level code でビルドが止まります。Rork Max に単体のウィジェットを1つずつ生成させると、それぞれに @main が付いてくることがあるため、束ねる段階で一度確認しておくと手戻りがありません。
3. ロック画面ウィジェット(Accessory Family)の実装
iOS 16 から追加されたロック画面ウィジェットは、accessoryCircular・accessoryRectangular・accessoryInline の3サイズがあります。アプリの認知度向上に非常に効果的で、特に習慣化系・健康系・生産性系アプリとの相性が抜群です。
// ロック画面ウィジェット向けビュー分岐の実装
struct TaskWidgetEntryView : View {
var entry: TaskProvider.Entry
@Environment (\.widgetFamily) var widgetFamily
var body: some View {
switch widgetFamily {
case .systemSmall :
SmallWidgetView ( entry : entry)
case .systemMedium :
MediumWidgetView ( entry : entry)
case .accessoryCircular :
// ロック画面: 円形
Gauge ( value : entry.progress) {
Image ( systemName : "checkmark.circle" )
} currentValueLabel : {
Text ( " \( Int (entry. progress * 100 ) ) " )
. font (.caption2)
}
. gaugeStyle (.accessoryCircular)
case .accessoryRectangular :
// ロック画面: 矩形
VStack ( alignment : .leading) {
Label ( " \( entry. completedCount ) / \( entry. totalCount ) 件" , systemImage : "checklist" )
. font (.headline)
Text (entry.nextTaskTitle)
. font (.caption)
. foregroundStyle (.secondary)
. lineLimit ( 1 )
}
case .accessoryInline :
// ロック画面: 1行テキスト
Label ( " \( entry. completedCount ) / \( entry. totalCount ) タスク完了" , systemImage : "checklist" )
default:
SmallWidgetView ( entry : entry)
}
}
}
ロック画面ウィジェットは モノクロ表示 (システムが色を管理)である点に、先に気を配っておきたいところです。@Environment(\.widgetRenderingMode) で現在の描画モードを取得し、フルカラー・ビブラント・アクセントカラーを適切に使い分けることができます。
4. ライブアクティビティと Dynamic Island の実装
iOS 16.1 で導入されたライブアクティビティは、ActivityKit フレームワークを通じてロック画面と Dynamic Island にリアルタイムな情報を表示します。配達追跡・スポーツスコア・フードデリバリーなど「進行中の状態」を持つアプリに最適です。
4-1. ActivityAttributes の定義
ライブアクティビティのデータ構造は2種類に分かれます。変化しない情報 (ContentState に含めない)とリアルタイムに更新される情報 (ContentState)です。
import ActivityKit
// ライブアクティビティのデータ定義
struct DeliveryAttributes : ActivityAttributes {
// 変化しない情報(アクティビティ開始時のみ設定)
let orderId: String
let storeName: String
let customerName: String
// リアルタイムに更新される情報
struct ContentState : Codable , Hashable {
var status: DeliveryStatus
var estimatedMinutes: Int
var currentLocation: String
enum DeliveryStatus : String , Codable {
case preparing = "準備中"
case picked = "ピックアップ済み"
case delivering = "配達中"
case arrived = "到着"
}
}
}
4-2. Dynamic Island の UI 実装
Dynamic Island は Compact Leading・Compact Trailing・Minimal・Expanded の4つの表示形式があります。
struct DeliveryLiveActivityView : Widget {
var body: some WidgetConfiguration {
ActivityConfiguration ( for : DeliveryAttributes. self ) { context in
// ロック画面 / バナー表示
LockScreenLiveActivityView ( context : context)
} dynamicIsland : { context in
DynamicIsland {
// Expanded(長押し展開時)
DynamicIslandExpandedRegion (.leading) {
VStack ( alignment : .leading) {
Label (context.attributes.storeName,
systemImage : "bag.fill" )
. font (.caption)
Text (context.state.currentLocation)
. font (.caption2)
. foregroundStyle (.secondary)
}
}
DynamicIslandExpandedRegion (.trailing) {
VStack ( alignment : .trailing) {
Text ( "あと" )
. font (.caption2)
Text ( " \( context. state . estimatedMinutes ) 分" )
. font (.title2. bold ())
. foregroundStyle (.orange)
}
}
DynamicIslandExpandedRegion (.bottom) {
HStack {
ForEach (DeliveryAttributes.ContentState.DeliveryStatus.allCases,
id : \. self ) { step in
StatusStepView (
step : step,
isActive : context.state.status == step,
isCompleted : step.order < context.state.status.order
)
}
}
. padding (.horizontal)
}
} compactLeading : {
// Compact Leading(通常表示・左側)
Image ( systemName : "bicycle" )
. foregroundStyle (.orange)
} compactTrailing : {
// Compact Trailing(通常表示・右側)
Text ( " \( context. state . estimatedMinutes ) 分" )
. font (.caption. bold ())
. foregroundStyle (.orange)
} minimal : {
// Minimal(複数アクティビティ競合時)
Image ( systemName : "bicycle" )
. foregroundStyle (.orange)
}
. widgetURL ( URL ( string : "yourapp://delivery/ \( context. attributes . orderId ) " ))
. keylineTint (.orange)
}
}
}
4-3. アクティビティの開始・更新・終了
アプリ側からライブアクティビティを制御するコードは次のようになります。
import ActivityKit
class DeliveryActivityManager {
private var currentActivity: Activity<DeliveryAttributes> ?
// アクティビティを開始する
func startActivity ( orderId : String , storeName : String ) throws {
guard ActivityAuthorizationInfo ().areActivitiesEnabled else {
throw ActivityError.notEnabled
}
let attributes = DeliveryAttributes (
orderId : orderId,
storeName : storeName,
customerName : "政樹"
)
let initialState = DeliveryAttributes. ContentState (
status : .preparing,
estimatedMinutes : 30 ,
currentLocation : "店舗で準備中"
)
currentActivity = try Activity. request (
attributes : attributes,
content : . init ( state : initialState, staleDate : nil ),
pushType : .token // APNs経由のプッシュ更新を使う場合
)
print ( "✅ アクティビティ開始: \( currentActivity ? . id ?? "" ) " )
}
// 状態を更新する(例: 配達員がピックアップした)
func updateActivity ( status : DeliveryAttributes.ContentState.DeliveryStatus,
minutes : Int , location : String ) async {
let newState = DeliveryAttributes. ContentState (
status : status,
estimatedMinutes : minutes,
currentLocation : location
)
// アラート付きで更新(バナー通知が表示される)
let alertConfig = AlertConfiguration (
title : "配達状況が更新されました" ,
body : " \( location ) — あと \( minutes ) 分" ,
sound : .default
)
await currentActivity ? . update (
. init ( state : newState, staleDate : nil ),
alertConfiguration : alertConfig
)
}
// アクティビティを終了する
func endActivity () async {
let finalState = DeliveryAttributes. ContentState (
status : .arrived,
estimatedMinutes : 0 ,
currentLocation : "配達完了"
)
await currentActivity ? . end (
. init ( state : finalState, staleDate : nil ),
dismissalPolicy : . after (.now. addingTimeInterval ( 30 )) // 30秒後に消える
)
}
}
期待する出力 : アプリがバックグラウンドにあっても Dynamic Island に「🚴 あと12分」が表示され続け、状態更新時にバナー通知が表示されます。
5. Timeline 更新戦略と更新頻度の最適化
WidgetKit の Timeline 更新は OS によって最適化されており、短すぎる更新間隔を指定しても OS が制限します。一般的に、バックグラウンドでの更新は1日あたり約40〜70回が目安です。
5-1. 更新ポリシーの選択
// 3種類の更新ポリシー
Timeline ( entries : entries, policy : .atEnd) // 最後のエントリが表示されたら更新
Timeline ( entries : entries, policy : . after (date)) // 指定時刻以降に更新
Timeline ( entries : entries, policy : .never) // アプリからの明示的なリロードのみ
静的コンテンツ (天気・カレンダー・タスク)には .after(date) が向いています。15〜60分の間隔を設定するのが現実的です。
動的コンテンツ (スポーツスコア・株価)は .atEnd と短い entries 配列を組み合わせ、アプリ側から WidgetCenter.shared.reloadTimelines(ofKind:) を使って能動的にリロードします。
5-2. アプリ側からのリロードトリガー
import WidgetKit
// アプリのデータが更新されたときにウィジェットを再描画する
func updateWidget ( title : String , progress : Double ) {
// AppGroupにデータを保存
let defaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
defaults ? . set (title, forKey : "widget_title" )
defaults ? . set (progress, forKey : "widget_progress" )
// Widgetのタイムラインを即座にリロード
WidgetCenter.shared. reloadTimelines ( ofKind : "AppWidget" )
// または全てのウィジェットをリロード
// WidgetCenter.shared.reloadAllTimelines()
}
6. App Intents を使ったインタラクティブ Widget
iOS 17 からウィジェット上で直接タップ操作が可能になりました。AppIntent を使えば、アプリを開かずにタスクを完了したりトグルをオンにしたりできます。これはユーザー体験の大幅な改善につながります。
import AppIntents
import WidgetKit
// ボタンタップ時に実行されるIntent
struct CompleteTaskIntent : AppIntent {
static var title: LocalizedStringResource = "タスクを完了"
@Parameter (title : "タスクID" )
var taskId: String
init () {}
init ( taskId : String ) {
self .taskId = taskId
}
func perform () async throws -> some IntentResult {
// AppGroupを通じてタスクを完了状態に更新
let defaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
var completedIds = defaults ? . stringArray ( forKey : "completed_task_ids" ) ?? []
if ! completedIds. contains (taskId) {
completedIds. append (taskId)
defaults ? . set (completedIds, forKey : "completed_task_ids" )
}
// Widgetを即座に更新
WidgetCenter.shared. reloadTimelines ( ofKind : "TaskWidget" )
return . result ()
}
}
// インタラクティブなウィジェットビュー
struct InteractiveTaskRow : View {
let task: TaskItem
var body: some View {
Button ( intent : CompleteTaskIntent ( taskId : task.id)) {
HStack {
Image ( systemName : task.isCompleted
? "checkmark.circle.fill" : "circle" )
. foregroundStyle (task.isCompleted ? .green : .secondary)
Text (task.title)
. strikethrough (task.isCompleted)
. foregroundStyle (task.isCompleted ? .secondary : .primary)
}
}
. buttonStyle (.plain)
}
}
この実装により「ウィジェット上のチェックボックスをタップするだけでタスクが完了する」体験が実現します。Rork Max にこのパターンを実装させる場合は、「AppIntent を使ったインタラクティブ Widget を実装してください。Intent は AppGroup の UserDefaults を更新してから WidgetCenter.reloadTimelines を呼び出す設計にしてください」と明示するのが重要です。
ウィジェットの設計思想はネイティブ開発の原則と深く関わっています。
7. よくあるエラーと対処法
Widget 開発では特有のトラブルが発生しやすいです。代表的なものをまとめます。
「Widget が更新されない」問題。
最も多いのが AppGroup の設定漏れです。アプリ本体と Widget Extension の両方に同じ AppGroup ID が追加されているか、Entitlements ファイルに正しく記述されているかを確認してください。シミュレーターでは WidgetCenter.shared.reloadAllTimelines() を呼び出してもすぐに反映されないことがあります。実機でテストするか、Widget の Preview を使いましょう。AppGroup は正しいのに値が古いまま残る場合は、書き込み側と読み出し側で suiteName の解決タイミングがずれていることがあります。この切り分けはRork アプリの iOS ウィジェットが「古いまま」になる原因と App Group 共有の設計 にまとめています。
「ライブアクティビティが開始できない」問題。
Info.plist に NSSupportsLiveActivities キーが YES で追加されているかを、まず確認します。ここで一つ訂正しておきたいことがあります。私は長らく「ライブアクティビティは実機でしか確認できない」と思い込んでおりました。実際にはシミュレーターでも表示できますし、ActivityKit のプッシュ通知の検証も、Apple T2 セキュリティチップまたは Apple シリコンを積んだ Mac で macOS 13 以降であれば、シミュレーター上で行えます。この条件は Starting and updating Live Activities with ActivityKit push notifications に明記されています。実機を1台占有せずに済むと分かってから、検証のテンポがずいぶん変わりました。
「Compact/Minimal が正しく表示されない」問題。
Dynamic Island の Compact 表示は iPhone 14 Pro 以降の機種でしか確認できません。Xcode のシミュレーターで「iPhone 15 Pro」以降を選んで確かめます。
「containerBackground の警告が出る」問題。
iOS 17 以降では .containerBackground(.background, for: .widget) が必須です。古いコードでは ZStack + .background(Color(...)) パターンが使われていましたが、iOS 17 以降はシステムが背景を管理するためこの方法は非推奨になっています。
「Timeline の更新頻度が期待通りでない」問題。
OS のバッテリー最適化により、更新間隔は保証されません。低電力モード時や特定の条件下では更新が大きく遅れることがあります。確実に届けたい更新がある場合は、次の第8節で扱う APNs 経由の更新に寄せるほうが安全です。
8. ライブアクティビティと APNs プッシュ更新
ライブアクティビティはアプリがバックグラウンドにある間も APNs 経由で状態を更新できます。サーバー側から最新情報をプッシュする設計により、アプリのプロセスを起動することなく Dynamic Island の内容を更新できます。
// ActivityKit の pushToken を取得してサーバーへ送信
func startActivityWithPushToken ( orderId : String ) throws {
let activity = try Activity < DeliveryAttributes > . request (
attributes : DeliveryAttributes ( orderId : orderId, storeName : "カフェ" , customerName : "受取人" ),
content : . init ( state : . init ( status : .preparing, estimatedMinutes : 30 , currentLocation : "準備中" ),
staleDate : Date (). addingTimeInterval ( 15 * 60 )),
pushType : .token // Push更新を有効化
)
// pushTokenUpdates は終端しない非同期シーケンスです。
// 呼び出し元で await すると、この関数から戻ってこなくなります。
Task {
for await data in activity.pushTokenUpdates {
let token = data. map { String ( format : "%02x" , $0 ) }. joined ()
// 新しいトークンを受け取ったら、サーバー側で古いトークンを無効化します
await sendTokenToServer ( activityId : activity.id, pushToken : token)
}
}
}
pushTokenUpdates を await の直列に置いてしまう書き方は、私も一度やりました。ライブアクティビティ自体は開始されるのに、呼び出し元の処理がその先へ進まず、「開始ボタンを押すと画面が固まる」という形で表面化します。トークンはこのシーケンスから何度でも届きますので、監視は独立した Task に逃がし、届いたら古いトークンをサーバー側で無効化する——この2点はセットで考えておくと安全です。
APNs 証明書の設定と端末トークンの扱いは通常のプッシュ通知と共通です。ペイロードを組み立てる側の勘所はRork アプリに Notification Service Extension を後付けする に、通知許可をどう取るかは許可を聞かずに通知を届ける — provisional 認可の設計 にまとめています。ライブアクティビティ向けのペイロードだけが content-state を持つ点が異なります。
サーバー側では次のようなペイロードで APNs に送信します。
{
"aps" : {
"timestamp" : 1712300000 ,
"event" : "update" ,
"content-state" : {
"status" : "delivering" ,
"estimatedMinutes" : 12 ,
"currentLocation" : "あなたの近くにいます"
},
"alert" : {
"title" : "配達状況が更新されました" ,
"body" : "あと12分で到着します"
}
}
}
ペイロードの形だけ整えても、ヘッダーが揃っていなければ APNs は受け付けてくれません。ここでつまずくと原因が見えにくいので、必要なヘッダーを先に並べておきます。
更新頻度は「10 を使わない設計」から考えます。 ライブアクティビティ向けのプッシュには時間あたりの予算があり、apns-priority: 10 はこの予算を消費します。予算を超えるとスロットリングの対象になり、届いてほしい場面ほど届かなくなります。一方 apns-priority: 5 は予算を消費しません。配達アプリであれば、1分ごとの位置更新は 5、「配達員が到着しました」だけ 10 という具合に、混ぜて使うのが素直です。
スポーツの実況のように、そもそも予算に収まらない用途もあります。その場合は Info.plist に NSSupportsLiveActivitiesFrequentUpdates を YES で追加すると、高頻度の更新が許されます。ただしこれは利用者が「設定」アプリからオフにできる項目です。アプリ側は ActivityAuthorizationInfo().frequentPushesEnabled で現在の値を読み、frequentPushEnablementUpdates で変化を監視して、オフになっていればサーバーへ伝えて送信頻度を落とす——この往復まで含めて初めて設計が閉じます。
アプリを開かせずにライブアクティビティを開始する
iOS 17.2 以降は、ユーザーがアプリを起動しなくてもサーバー側からライブアクティビティを開始できます。Activity<DeliveryAttributes>.pushToStartTokenUpdates を購読して push-to-start トークンを受け取り、サーバーへ預けておく形です。開始のためにアプリ内で一度ライブアクティビティを走らせておく必要はありません。
// 起動時に一度だけ購読しておく(開始用トークン・iOS 17.2 以降)
if #available ( iOS 17.2 , * ) {
Task {
for await tokenData in Activity < DeliveryAttributes > .pushToStartTokenUpdates {
let token = tokenData. map { String ( format : "%02x" , $0 ) }. joined ()
await sendPushToStartTokenToServer (token)
}
}
}
開始用のペイロードは更新用と似ていますが、event が start になり、attributes-type と attributes、そして alert が必須になります。alert が必須なのは、身に覚えのない表示が突然ロック画面に現れる事態を避けるためです。
{
"aps" : {
"timestamp" : 1712300000 ,
"event" : "start" ,
"attributes-type" : "DeliveryAttributes" ,
"attributes" : { "orderId" : "A-1042" , "storeName" : "カフェ" , "customerName" : "受取人" },
"content-state" : { "status" : "preparing" , "estimatedMinutes" : 30 , "currentLocation" : "準備中" },
"input-push-token" : 1 ,
"alert" : { "title" : "注文を受け付けました" , "body" : "準備を始めています" }
}
}
input-push-token: 1 は iOS 18 以降で効くキーで、開始と同時に更新用のプッシュトークンを受け取る指定です。チャンネル経由で更新したい場合は代わりに input-push-channel にチャンネルIDを渡します。なお、ブロードキャスト(チャンネル)でライブアクティビティを開始 することはできません。開始は push-to-start トークン、以降の更新はチャンネル、という組み合わせで考えます。
届かなかった時間をどう見せるか
私がいちばん時間をかけたのは、更新が成功する経路ではなく、届かなかったときの見え方でした。地下やトンネルで通信が切れれば、ライブアクティビティは古い値を表示し続けます。ここで効くのが stale-date です。ペイロードに未来の時刻を入れておくと、その時刻を過ぎた時点で activityState が .stale へ変わります。アプリ側は activityStateUpdates を監視しておき、Widget 側では「最終更新から時間が経っています」と正直に出すようにしています。古い数字を出し続けるより、こちらのほうが信頼を損ないません。
終了後の後片付けにも既定値があります。ライブアクティビティを終了しても、ロック画面には最大4時間そのまま残ります。すぐ消したい場合は dismissal-date に過去の時刻を、少しだけ残したい場合は4時間以内の時刻を指定します。
複数のライブアクティビティが同時に走る場合は relevance-score を添えます。指定がなければ最初に開始したものが Dynamic Island に出るため、後から始まったより重要な進行が埋もれます。重要な更新に 100、そうでないものに 50 といった相対値を割り振り、どの値を誰に渡したかを自分の側でも控えておくと、順序の入れ替えに困りません。
サーバー更新の設計は、通信が成功した前提を捨てるところから始まります。 ヘッダー・優先度・staleDate の3つを最初に決めてしまえば、あとの実装は素直に進みます。
9. 収益化・リテンションへの活用
Widget は「無料でも使える機能」として提供するか、「プレミアム限定機能」として収益化のフックにするかで戦略が異なります。
プレミアム限定ウィジェット戦略。 基本的なウィジェット(Small サイズのみ)は無料で提供し、Medium/Large サイズや Dynamic Island 対応のライブアクティビティはプレミアム限定にするモデルが有効です。サブスクリプション設計そのものはRork Max で SwiftUI ネイティブアプリを収益化する に、ペイウォールの出し分けと A/B テストはRork Max × RevenueCat Paywalls SDK で変わるペイウォール開発 にまとめています。ウィジェット以外のネイティブ機能を足していく段階では、Rork Max で画面録画と配信を実装する やRork Max × Apple TipKit 実装ガイド が近い設計判断を扱っています。ウィジェット追加画面でプレミアム限定のサイズを「ロック」アイコン付きで表示し、タップしたときにペイウォールを見せる設計が自然なアップセルになります。
エンゲージメント向上の測定。 ウィジェットからアプリが開かれた場合は .widgetURL に ディープリンク URL を設定し、アプリ側で計測します。
// ウィジェット → アプリの遷移を計測
. widgetURL ( URL ( string : "yourapp://widget?kind=small&source=homescreen" ))
アプリ側の onOpenURL ハンドラでこの URL を受け取り、Firebase Analytics や Mixpanel にイベントとして記録することで、Widget がどれだけコンバージョンに寄与しているかを把握できます。
また、アプリのアイコンとウィジェットのデザインを統一することでブランド認知度が高まります。ストアのスクリーンショットにウィジェットが映り込んだ画像を使うと ASO(App Store Optimization)でも効果が出ます。
10. 設定可能ウィジェット — 表示する対象をユーザーに選ばせる
ここまでのウィジェットは、表示する内容を開発者が決めていました。iOS には、ウィジェットを長押しして「編集」から表示対象を選ばせる仕組みがあります。どのプロジェクトの進捗を出すか、どの銘柄を見るか、誰の誕生日を数えるか。アプリを開かずに切り替えられるので、同じウィジェットを何枚も並べて使い分ける読者が現れます。
実装は StaticConfiguration を AppIntentConfiguration に置き換える形になります。
import WidgetKit
import AppIntents
import SwiftUI
// ウィジェット編集画面に出るパラメータを定義する
struct SelectProjectIntent : WidgetConfigurationIntent {
static var title: LocalizedStringResource = "プロジェクトを選択"
static var description = IntentDescription ( "表示するプロジェクトを選びます。" )
@Parameter (title : "プロジェクト" , default: "すべて" )
var projectName: String
}
struct ConfigurableProjectWidget : Widget {
let kind = "ConfigurableProjectWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration (
kind : kind,
intent : SelectProjectIntent. self ,
provider : ConfigurableProjectProvider ()
) { entry in
ConfigurableProjectEntryView ( entry : entry)
. containerBackground (.background, for : .widget)
}
. configurationDisplayName ( "プロジェクト進捗" )
. description ( "選んだプロジェクトの進み具合を表示します。" )
. supportedFamilies ([.systemSmall, .systemMedium])
}
}
Provider 側は TimelineProvider ではなく AppIntentTimelineProvider に変わり、各メソッドがユーザーの選択を受け取ります。
struct ConfigurableProjectProvider : AppIntentTimelineProvider {
func placeholder ( in context: Context) -> ProjectEntry {
ProjectEntry ( date : .now, projectName : "プロジェクト" , progress : 0.4 , taskCount : 10 )
}
func snapshot ( for configuration: SelectProjectIntent,
in context: Context) async -> ProjectEntry {
// ウィジェットギャラリーのプレビュー用。通信せず即座に返す
ProjectEntry ( date : .now, projectName : configuration.projectName,
progress : 0.6 , taskCount : 8 )
}
func timeline ( for configuration: SelectProjectIntent,
in context: Context) async -> Timeline<ProjectEntry> {
let defaults = UserDefaults ( suiteName : "group.com.yourapp.shared" )
let key = configuration.projectName
let progress = defaults ? . double ( forKey : " \( key ) _progress" ) ?? 0.0
let count = defaults ? . integer ( forKey : " \( key ) _count" ) ?? 0
let entry = ProjectEntry ( date : .now, projectName : key,
progress : progress, taskCount : count)
let next = Calendar.current. date ( byAdding : .minute, value : 30 , to : .now) !
return Timeline ( entries : [entry], policy : . after (next))
}
}
動作の目安。 ウィジェットを長押しして「ウィジェットを編集」を選ぶと「プロジェクトを選択」の項目が現れ、選び直した瞬間に timeline(for:in:) が呼び直されます。
一点だけ注意があります。snapshot(for:in:) はウィジェットギャラリーの一覧描画で呼ばれるため、ここでネットワークを叩くとギャラリーのスクロールが目に見えて重くなります。プレビュー用のダミー値を即座に返す実装にしておくのが安全です。Rork Max に生成させると timeline と同じ処理を snapshot にも書いてくることがあるので、この二つは役割が違うと明示してプロンプトを渡すと精度が上がります。
11. アクセシビリティとローカライズ
ウィジェットは中に入って操作できません。VoiceOver の読者にとっては、そのウィジェットが1つの塊として読み上げられるだけです。だからこそ、ラベルを付けるかどうかで伝わり方が大きく変わります。
Gauge ( value : entry.progress) {
Image ( systemName : "checkmark.circle" )
} currentValueLabel : {
Text ( " \( Int (entry. progress * 100 ) ) " )
}
. gaugeStyle (.accessoryCircular)
. accessibilityLabel ( "タスク完了率" )
. accessibilityValue ( " \( Int (entry. progress * 100 ) ) パーセント" )
accessibilityLabel が「何を表す数字か」、accessibilityValue が「いまの値」です。両方を分けて渡すと、読み上げが「タスク完了率、60 パーセント」という自然な語順になります。アイコンだけのロック画面ウィジェットでは特に効きます。
ローカライズで見落としがちなのは、文字列リソースの置き場所です。Widget Extension は本体アプリとは別バンドルとして扱われるため、Localizable.strings を本体側だけに置いても参照されません。Extension のターゲットにも追加しておきます。
Text ( String ( localized : "tasks_remaining \( entry. remainingCount ) " ))
数値・日付・通貨は端末のロケール設定に従わせます。ホーム画面という毎日目に入る場所で表記が崩れていると、アプリ本体の作り込みまで疑われます。海外配信を視野に入れているなら、疑似ローカライズで長い言語を流し込み、レイアウトが破綻しないかを先に見ておくと安心です。
12. パフォーマンスと Xcode Preview で回す検証
Widget Extension のメモリ上限はおよそ30MBです。本体アプリの感覚で画像を扱うと、描画されずに空白のまま置き去りにされます。
getTimeline には重い処理を置かないようにしています。 並べ替え・絞り込み・整形は、本体アプリ側で済ませて共有ストレージに書き出しておきます。Provider の仕事は読み出しだけに留めるのが基本です。
画像はネットワークから取らないようにしています。 getTimeline の中で通信を始めると、完了を待たずに Extension が打ち切られます。本体アプリで先に取得し、App Group のコンテナに保存したものを読むだけにします。
エントリはまとめて焼き込んでおきます。 予定表や日替わりコンテンツのように未来の値が分かっているものは、先の分まで一度に積んでおきます。OS が Extension を起こす回数そのものを減らせます。
// 30分刻みで先の4時間分(8エントリ)を一度に生成する
func getTimeline ( in context: Context, completion : @escaping (Timeline<AppEntry>) -> Void ) {
var entries: [AppEntry] = []
let now = Date ()
for offset in 0 ..< 8 {
let date = Calendar.current. date ( byAdding : .minute, value : 30 * offset, to : now) !
let snapshot = loadSnapshot ( for : date) // 共有 UserDefaults から読むだけ
entries. append ( AppEntry ( date : date, title : snapshot.title, progress : snapshot.progress))
}
completion ( Timeline ( entries : entries, policy : .atEnd))
}
ビューは素朴に保ちます。 ウィジェットはスナップショットとして描かれます。込み入ったアニメーションや深い入れ子は描画時間を押し上げるだけで、見返りがありません。iOS 17 の Button(intent:) を除けば、状態を持たない静的な描画として設計するのが正解です。
検証は Xcode の Preview で回すのが最短です。実機のホーム画面に配置し直す往復は、一度きりなら軽くても、余白と文字サイズを詰める作業では効いてきます。
#Preview ( "Small" , as : .systemSmall) {
AppWidget ()
} timeline : {
AppEntry ( date : .now, title : "打ち合わせ準備" , progress : 0.3 )
AppEntry ( date : .now, title : "コードレビュー" , progress : 0.7 )
}
#Preview ( "ロック画面 Circular" , as : .accessoryCircular) {
AppWidget ()
} timeline : {
AppEntry ( date : .now, title : "タスク" , progress : 0.6 )
}
timeline: に複数のエントリを渡すと、キャンバス上で時間送りしながら見比べられます。ロック画面のアクセサリはシステムが単色でレンダリングするため、ホーム画面では成立していた配色が読めなくなることがあります。両方を横に並べて確認できるのは、この Preview の一番の利点です。
全体を振り返って
WidgetKit・ライブアクティビティ・Dynamic Island は、アプリを開いていない時間にも存在を保ち続けるための仕組みです。Rork Max はコードの大半を書いてくれますが、どのデータを AppGroup に置くか、どの頻度で焼き直すか、APNs を使うかどうか——この設計判断だけは渡す側が決めることになります。プロンプトに設計の前提をどれだけ書けるかが、そのまま出力の質になります。
作り始めるなら、いきなり Dynamic Island を目指さないことをおすすめします。共有ストレージから値をひとつ読むだけの Small ウィジェットを先に通し、データが確かに流れることを確認してから、ロック画面、そしてライブアクティビティへと積み上げていきます。この順番なら、うまくいかないときに疑う場所がいつも一箇所に絞られます。
長い記事を最後までお読みいただき、ありがとうございました。