Rork Max × WidgetKit・ライブアクティビティ実装ガイド 2026 — Dynamic Island 対応まで
iOS 16 以降、Widget はホーム画面だけでなくロック画面にも配置できるようになりました。さらに Dynamic Island と連携したライブアクティビティが加わり、アプリのエンゲージメントを大きく高める機能として定着しています。ユーザーがアプリを開かなくても最新情報を届けられる Widget は、リテンション向上と収益化の両面で重要な役割を果たします。
Rork Max はネイティブ Swift コードを生成できる AI アシスト開発環境です。WidgetKit・ActivityKit の実装も Rork Max のプロンプトドリブン開発で行えますが、SwiftUI の仕組みや Widget 固有の制約を理解していないと、Rork Max が出力したコードに誤りが含まれていても気づけません。ここでは実装の原理から丁寧に解説し、プロが使える水準のウィジェットを構築するための知識を体系的に提供します。
私自身、個人開発で運用している壁紙アプリに「今日の一枚」を映すウィジェットを載せたとき、最初に面食らったのが更新のされなさでした。getTimeline で数分おきに新しい画像へ差し替えるつもりが、iOS は省電力のために再読み込みをかなり間引きます。そこで方針を変え、一日分のエントリをあらかじめ Timeline にまとめて積んでおき、深夜0時に一度だけ全体を組み直す設計にしました。リクエスト回数を最小限に抑えつつ、ユーザーから見れば毎朝きちんと絵柄が変わる。この「先に焼き込む」発想にたどり着くまで、何度も実機の挙動を観察した記憶があります。以降の各セクションでも、こうした実運用での手触りを添えながら進めていきます。
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 で追加されているか確認します。また、iOS 16.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 のバッテリー最適化により、更新間隔は保証されません。低電力モード時や特定の条件下では更新が大きく遅れることがあります。重要な更新が必要な場合はプッシュ通知(APNs)経由で Widget に送信する「Push-to-Widget」パターンを検討してください。
8. ライブアクティビティと APNs プッシュ更新
ライブアクティビティはアプリがバックグラウンドにある間も APNs 経由で状態を更新できます。サーバー側から最新情報をプッシュする設計により、アプリのプロセスを起動することなく Dynamic Island の内容を更新できます。
// ActivityKit の pushToken を取得してサーバーへ送信
func startActivityWithPushToken ( orderId : String ) async throws {
let activity = try Activity < DeliveryAttributes > . request (
attributes : DeliveryAttributes ( orderId : orderId, storeName : "カフェ" , customerName : "政樹" ),
content : . init ( state : . init ( status : .preparing, estimatedMinutes : 30 , currentLocation : "準備中" ), staleDate : nil ),
pushType : .token // Push更新を有効化
)
// pushToken の変化を監視してサーバーへ送信
for await data in activity.pushTokenUpdates {
let token = data. map { String ( format : "%02x" , $0 ) }. joined ()
print ( "Push Token: \( token ) " )
// サーバーのAPIエンドポイントへトークンを送信
await sendTokenToServer ( activityId : activity.id, pushToken : token)
}
}
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分で到着します"
}
}
}
この設計により、サーバーから直接 Dynamic Island の表示を更新でき、バッテリー効率が大幅に向上します。
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 ウィジェットを先に通し、データが確かに流れることを確認してから、ロック画面、そしてライブアクティビティへと積み上げていく。この順番なら、うまくいかないときに疑う場所がいつも一箇所に絞られます。
長い記事を最後までお読みいただき、ありがとうございました。