個人開発で壁紙系や引き寄せ系のアプリを長く運用していると、「ホーム画面に日替わりで1枚見せたい」という要望は必ず出てきます。Rork Max がネイティブ Swift でウィジェットを出力できるようになって、私自身も既存アプリに WidgetKit を載せ直してみたのですが、最初のビルドで素直に詰まりました。シミュレータでは日替わりに見えるのに、実機に入れて翌朝確認すると、ウィジェットが前日の1枚のまま固まっていたのです。
原因は、生成された TimelineProvider が「いつ・どれだけ更新を予約するか」を決めていなかったことでした。WidgetKit のタイムラインは cron ではありません。ここを設計せずに Rork Max の出力をそのまま出すと、初日は動いて2日目から止まる、という最もたちの悪い壊れ方をします。
ウィジェットが止まるのは「予算」を使い切るから
WidgetKit はバッテリーを守るため、各ウィジェットが受け取れる更新回数に1日あたりの上限(更新予算)を設けています。Apple の公開ガイダンスでは、ホーム画面に置かれ頻繁に閲覧されるウィジェットでおおむね 1日40〜70回 が目安とされ、TimelineProvider が返したタイムラインのエントリ消化や reloadTimelines(ofKind:) の呼び出しはこの予算から差し引かれます。
ここで重要なのは、タイムラインの最後のエントリを過ぎても、次のタイムラインの再生成は自動では走らない という点です。getTimeline で「今日の0時のエントリ1件だけ」を返し、reloadPolicy を指定しなかった場合、システムは「いつ次を読み込むべきか」を知りません。結果として、表示は最後のエントリのまま固定されます。ここが最大の落とし穴です。シミュレータで気づけないのは、デバッグ実行のたびにタイムラインが再生成されるためで、本番運用の挙動とは別物です。
予算は「使い切ったら次の日まで回復しない」性質があるので、設計の目標は2つになります。第一に、1日の更新回数を予算内に収めること。第二に、その範囲で確実に翌日分へ繋ぐこと。日替わり表示なら、本当に必要な更新は1日1回です。予算を気にして過剰に減らすより、reloadPolicy を正しく使って「次の更新時刻」を明示するほうが安全です。
reloadPolicy で「次にいつ起こすか」を必ず宣言する
Timeline(entries:policy:) の policy には3種類があります。.atEnd は最後のエントリを過ぎたら再読み込み、.after(date) は指定時刻に再読み込み、.never は再読み込みしないという意味です。日替わりウィジェットでは、翌日0時を明示する .after が最も予測可能です。
import WidgetKit
import SwiftUI
struct DailyEntry : TimelineEntry {
let date: Date
let title: String
let imageName: String
}
struct DailyProvider : TimelineProvider {
func placeholder ( in context: Context) -> DailyEntry {
DailyEntry ( date : Date (), title : "今日の一枚" , imageName : "placeholder" )
}
func getSnapshot ( in context: Context, completion : @escaping (DailyEntry) -> Void ) {
completion ( currentEntry ())
}
func getTimeline ( in context: Context, completion : @escaping (Timeline<DailyEntry>) -> Void ) {
let entry = currentEntry ()
// 翌日0時(端末ローカル)を次の更新時刻として明示する
let calendar = Calendar.current
let startOfTomorrow = calendar. startOfDay (
for : calendar. date ( byAdding : .day, value : 1 , to : entry.date) !
)
let timeline = Timeline ( entries : [entry], policy : . after (startOfTomorrow))
completion (timeline)
}
}
ここでの肝は、エントリを「今日の1件」に絞り、reloadPolicy を .after(翌日0時) にしている点です。こうすると、システムは翌日0時前後にもう一度 getTimeline を呼び、そこで「次の日の1枚」が確定します。1日の更新は実質1回で済み、予算の枯渇を回避できます。
.atEnd でも動きますが、エントリを1件しか積んでいないと「最後のエントリ=今表示中」になり、再読み込みのタイミングが曖昧になります。確実に日付の境界で切り替えたいなら、.after で時刻を握るほうが意図どおりに動きます。私自身は、.atEnd で組んだ初回ビルドが翌日に固まり、.after に変えて解消した経緯があるので、日替わり用途では .after を既定にしています。個人的にも、日替わり表示ではこの方針を推奨します。注意点として、.after の時刻は端末ローカルの0時に合わせるのが無難です。
App Group でアプリ本体とウィジェットを同じ真実につなぐ
ウィジェットはアプリ本体とは別プロセスで動くため、UserDefaults.standard やアプリのサンドボックス内ファイルを直接は読めません。日替わりの「今日はどの1枚か」をアプリ本体で決めて、ウィジェットがそれを読むには、App Group 共有コンテナを経由します。
Rork Max の出力にはエンタイトルメントの App Group 設定が抜けていることがあり、ここが原因でウィジェットが常にプレースホルダーのままになるケースを何度か見ました。手順は次の3つです。
アプリ本体とウィジェット拡張の両方の Signing & Capabilities に、同一の App Group(例 group.net.dolice.example)を追加する
アプリ本体で当日の選択結果を共有 UserDefaults に書き込む
ウィジェットの getTimeline で同じ App Group から読み出す
// アプリ本体側: 当日分を確定して共有コンテナに書く
enum DailyStore {
static let suiteName = "group.net.dolice.example"
static func writeToday ( title : String , imageName : String ) {
let defaults = UserDefaults ( suiteName : suiteName)
defaults ? . set (title, forKey : "today.title" )
defaults ? . set (imageName, forKey : "today.image" )
defaults ? . set ( Date (), forKey : "today.savedAt" )
}
}
// ウィジェット側: 共有コンテナから読む(なければフォールバック)
func currentEntry () -> DailyEntry {
let defaults = UserDefaults ( suiteName : DailyStore.suiteName)
let title = defaults ? . string ( forKey : "today.title" ) ?? "今日の一枚"
let image = defaults ? . string ( forKey : "today.image" ) ?? "placeholder"
return DailyEntry ( date : Date (), title : title, imageName : image)
}
この設計の利点は、ウィジェット側でネットワークを叩かなくてよい ことです。当日のコンテンツはアプリ起動時や日付変更通知のタイミングで本体が先に確定しておき、ウィジェットは共有コンテナを読むだけにします。ウィジェットの getTimeline 内で重いダウンロードを走らせると、更新予算と実行時間の両方を圧迫し、結果的に表示が遅延・欠落しやすくなります。本体で確定・ウィジェットは読むだけ、という分担が安定します。
アプリ本体が当日分を書き換えたら、明示的にウィジェットへ再読み込みを促します。
import WidgetKit
// 本体で当日分を更新したあとに呼ぶ
DailyStore. writeToday ( title : newTitle, imageName : newImage)
WidgetCenter.shared. reloadTimelines ( ofKind : "DailyWallpaperWidget" )
reloadTimelines(ofKind:) も更新予算を消費するので、ユーザー操作のたびに連打しないことが大切です。日替わりなら「日付が変わったとき」と「ユーザーが手動で今日の1枚を変更したとき」に限定すれば、予算内に十分収まります。
ウィジェットのタップを正しい画面へ繋ぐ
日替わりウィジェットは、タップしたら該当の壁紙詳細やアファメーション画面に飛んでほしいものです。systemSmall は領域全体が1つのタップ対象になるため widgetURL を、systemMedium 以上で要素ごとにリンクを分けたい場合は Link を使います。
struct DailyWidgetEntryView : View {
var entry: DailyEntry
var body: some View {
VStack {
Image (entry.imageName). resizable (). scaledToFill ()
Text (entry.title). font (.caption)
}
// small サイズはウィジェット全体に1つの URL を割り当てる
. widgetURL ( URL ( string : "dolice-example://daily?image= \( entry. imageName ) " ))
}
}
受け側はアプリの onOpenURL(SwiftUI)か application(_:open:options:) でスキームを解釈します。ここでよくある失敗が2つあります。1つは、Info.plist に URL Scheme を登録し忘れていて、タップしてもアプリが開くだけで画面遷移しないこと。もう1つは、widgetURL と Link を同じビュー階層で混在させてしまい、systemSmall で Link 側が無視されて意図しない遷移になることです。サイズごとに片方だけを使う、と決めておくと切り分けが楽になります。
// 受け側(アプリ本体)
. onOpenURL { url in
guard url.scheme == "dolice-example" ,
url.host == "daily" ,
let image = URLComponents ( url : url, resolvingAgainstBaseURL : false ) ?
.queryItems ? . first ( where : { $0 .name == "image" }) ? . value
else { return }
router. openWallpaper ( named : image)
}
実機で「翌日も動く」ことを確認する手順
シミュレータは毎回タイムラインを再生成するため、この種のバグを実機でしか踏めません。私が日替わり系のウィジェットを App Store に出す前に必ず通している確認は次の流れです。
実機にインストールし、ウィジェットを systemSmall でホーム画面に置く
端末の日付を手動で翌日に進め、数分待ってウィジェットが切り替わるか見る
端末を再起動し、reloadPolicy の予約が再起動後も尊重されるか確認する
アプリ本体から WidgetCenter.shared.reloadTimelines(ofKind:) を呼び、即時反映を確認する
機内モードで2と4を再実行し、ネットワークなしでも当日分が出ることを確認する
特に5番は、ウィジェット側がうっかりネットワーク依存になっていないかを暴くのに有効です。当日分が App Group に書かれていれば、機内モードでも問題なく表示されます。表示されないなら、ウィジェットの getTimeline の中で本来やってはいけない通信をしている可能性が高いです。
どこまで Rork Max に任せ、どこから握るか
Rork Max のネイティブ Swift 出力は、ウィジェットのビューや基本構造を一気に用意してくれる点ではとても助かります。一方で、reloadPolicy の選択・App Group のエンタイトルメント・更新予算の前提といった「目に見えない運用の勘所」は、生成物をそのまま信じると初日だけ動いて翌日に壊れる、という形で表面化します。
私の判断としては、ビューと配線は Rork Max に任せ、getTimeline の reloadPolicy と共有ストレージの読み書きは自分で握る、という線引きが現実的です。日替わり1枚という小さな機能でも、更新予算という制約を踏まえると設計上の判断が必要になります。まずは手元のアプリに systemSmall を1つ載せ、端末の日付を翌日に進めて切り替わるかを確かめてみてください。そこが通れば、複数サイズや要素別リンクへ広げていく土台ができます。