Open2
iOS: Widgetのplaceholder,getSnapshotが何か調べる
iOS14以降のWidgetですが、TimelineProvider
プロトコルのplaceholder(in:)
ならびにgetSnapshot(in:completion:)
が何なのか、きちんと説明している記事が見つけられなかったので調べました。
placeholder
-
placeholder(in:)
は、Widgetがインストールされたタイミングで、サポートしているWidgetFamily
(small/medium/large)の数だけ呼ばれます。 - Widgetがインストールされるタイミング、というのが微妙で、通常はAppがインストールされたら同時にWidgetもインストールされるようですが、何かのタイミングによってはAppインストールではWidgetがインストールされず、App起動時にようやくWidgetがインストールされることがあるようです。
- さらに
placeholder(in:)
はWidgetがホーム画面に最初に追加されるタイミングで1回呼ばれます。もうちょっと言うと、おそらく「Widgetがインストールされたタイミングで、サポートしているWidgetFamily
(small/medium/large)の数だけ呼ばれ」る前にホーム画面にWidgetが追加されたときだけ1回呼ばれる、のではないかと思います。そんなことが起こるのはXcodeからデバッグするときだけではないかと推測しています。一度ホーム画面から削除して再追加しても呼ばれません。 - ただし初回だけ一度きりなどとは言うものの、
placeholder(in:)
のレンダリング結果がずっと残っているとも思えないので、何かのタイミングでリセットされるような気はしています。再起動後の初回表示時とかにはplaceholder(in:)
が再度呼ばれるかも知れません。これは未確認です。 -
placeholder(in:)
で返すエントリは、View
のアウトラインイメージ(フレームレベル)のレンダリングにのみ使われます。ここで返したエントリが実際の例えばText
の文字列のレンダリングに使われたりはしません。Shape
ぐらいはその形状通りにレンダリングされるようです。プレースホルダモード(厳密にレンダリングせずアウトラインでレンダリングするモード)という機能のようです。センシティブな情報を表示しない、というケースでもこの機能が使用されます。 - Xcodeのプレビューで、プレビュー対象のビューに
.redacted(reason: .placeholder)
のモディファイアを付けるとプレースホルダモードでレンダリングされます。placeholder(in:)
が返すエントリと同じものをプレビュー時のエントリとして渡せばplaceholder(in:)
時のレンダリングと同等のレンダリングイメージが確認できます[1]。 -
placeholder(in:)
で返したエントリでレンダリングされたイメージは、getSnapshot(in:completion:)
でレンダリングされたイメージで上書きされて、WidgetGallery表示されます。またホーム追加時には、getTimeline(in:completion:)
でレンダリングされたイメージで上書きされて表示されます。 -
getSnapshot(in:completion:)
が異常終了やタイムアウトするなどして、getSnapshot(in:completion:)
のcompletion
ハンドラが呼ばれなければ、placeholder(in:)
でレンダリングされたイメージがそのままWidgetGalleryでのWidgetのイメージになります。 -
getTimeline(in:completion:)
が異常終了やタイムアウトするなどしてgetTimeline(in:completion:)
のcompletion
ハンドラが呼ばれなければ、placeholder(in:)
でレンダリングされたイメージがそのままホーム画面に表示されるWidgetのイメージになります。
getSnapshot
-
getSnapshot(in:completion:)
は、WidgetGallery表示時に、サポートしているWidgetFamily
の数+α呼ばれます。最低1回は呼ばれますが、複数回呼ばれることもあります。実行するたびに呼ばれる回数が違うので、何かバグがあるのか、何か仕様があるのか、ちょっとよく分かりません。 -
getSnapshot(in:completion:)
は、WidgetGallery表示時にまれに変な回数呼ばれて、 small/medium/largeの3個でなく、倍の6個表示されることがあります。これはバグなのかな。ちょっとよく分かりません。
回数がおかしい件は、Xcodeでインストールしたときに前のバージョンのWidgetのハンドラが残っているタイミングがあるのかな、という気が何となくしています。実際にApp Storeでの配布時にバージョンアップした場合に、こういうことが起こらないと良いのですが。
ついでにgetTimeline
-
getTimeline(in:completion:)
は、ホーム画面にWidgetが追加されると呼ばれます。インストール後の初回追加時に限ってはplaceholder(in:)
のあとになります[2]。それ以降の再追加時はplaceholder(in:)
は呼ばれず、いきなりgetTimeline(in:completion:)
が呼ばれます。 -
getTimeline(in:completion:)
で返すエントリがなんであれ、ビューのレンダリングはそれらのエントリを用いて、getTimeline(in:completion:)
呼び出し直後にエントリ数だけ行われます。5個のエントリを入れたら5個のTimelineEntry.date
が一気に来て描画されます。そしてそれがキャッシュか何かに残り、各エントリの指定時刻になったら順にそれらが表示される、という仕組みのようです。その時刻になったらビューの描画コードが実行されて再描画される、とかではないので注意が必要です。指定時刻のレンダリング内容が決められない(計算で求めたり精度良く予測できたりができない)場合はそのエントリは入れるべきではないとも言えます。 -
getTimeline(in:completion:)
で返す最初のエントリは、TimelineEntry.date
に関係なく表示されます。日時そのもの、もしくは日時に紐付くデータを表示している場合、最初のエントリが未来の日時だとユーザは混乱します。
getTimeline(in:completion:)
内で少し時間のかかる処理を非同期なんかで呼び出す場合は、以下のようにTask
の中にawait
を付けて非同期呼び出しを入れて、最後にcompletion
ハンドラを呼べばよいと思います。
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
var entries: [SimpleEntry] = []
let widgetData = await WidgetData.loadFromCloudKitContainer() // ←各自、自分の処理に置き換えて
let entry = SimpleEntry(date: Date(), widgetData: widgetData) // ←各自、自分のEntryに置き換えて
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .never)
completion(timeline)
}
}
この場合、completion
が呼ばれるまではプレースホルダが表示されます。