Open2

iOS: Widgetのplaceholder,getSnapshotが何か調べる

kabeyakabeya

iOS14以降のWidgetですが、TimelineProviderプロトコルのplaceholder(in:)ならびにgetSnapshot(in:completion:)が何なのか、きちんと説明している記事が見つけられなかったので調べました。

placeholder

  1. placeholder(in:)は、Widgetがインストールされたタイミングで、サポートしているWidgetFamily(small/medium/large)の数だけ呼ばれます。
  2. Widgetがインストールされるタイミング、というのが微妙で、通常はAppがインストールされたら同時にWidgetもインストールされるようですが、何かのタイミングによってはAppインストールではWidgetがインストールされず、App起動時にようやくWidgetがインストールされることがあるようです。
  3. さらにplaceholder(in:)はWidgetがホーム画面に最初に追加されるタイミングで1回呼ばれます。もうちょっと言うと、おそらく「Widgetがインストールされたタイミングで、サポートしているWidgetFamily(small/medium/large)の数だけ呼ばれ」る前にホーム画面にWidgetが追加されたときだけ1回呼ばれる、のではないかと思います。そんなことが起こるのはXcodeからデバッグするときだけではないかと推測しています。一度ホーム画面から削除して再追加しても呼ばれません。
  4. ただし初回だけ一度きりなどとは言うものの、placeholder(in:)のレンダリング結果がずっと残っているとも思えないので、何かのタイミングでリセットされるような気はしています。再起動後の初回表示時とかにはplaceholder(in:)が再度呼ばれるかも知れません。これは未確認です。
  5. placeholder(in:)で返すエントリは、Viewのアウトラインイメージ(フレームレベル)のレンダリングにのみ使われます。ここで返したエントリが実際の例えばTextの文字列のレンダリングに使われたりはしません。Shapeぐらいはその形状通りにレンダリングされるようです。プレースホルダモード(厳密にレンダリングせずアウトラインでレンダリングするモード)という機能のようです。センシティブな情報を表示しない、というケースでもこの機能が使用されます。
  6. Xcodeのプレビューで、プレビュー対象のビューに.redacted(reason: .placeholder)のモディファイアを付けるとプレースホルダモードでレンダリングされます。placeholder(in:)が返すエントリと同じものをプレビュー時のエントリとして渡せばplaceholder(in:)時のレンダリングと同等のレンダリングイメージが確認できます[1]
  7. placeholder(in:)で返したエントリでレンダリングされたイメージは、getSnapshot(in:completion:)でレンダリングされたイメージで上書きされて、WidgetGallery表示されます。またホーム追加時には、getTimeline(in:completion:)でレンダリングされたイメージで上書きされて表示されます。
  8. getSnapshot(in:completion:)が異常終了やタイムアウトするなどして、getSnapshot(in:completion:)completionハンドラが呼ばれなければ、placeholder(in:)でレンダリングされたイメージがそのままWidgetGalleryでのWidgetのイメージになります。
  9. getTimeline(in:completion:)が異常終了やタイムアウトするなどしてgetTimeline(in:completion:)completionハンドラが呼ばれなければ、placeholder(in:)でレンダリングされたイメージがそのままホーム画面に表示されるWidgetのイメージになります。

getSnapshot

  1. getSnapshot(in:completion:)は、WidgetGallery表示時に、サポートしているWidgetFamilyの数+α呼ばれます。最低1回は呼ばれますが、複数回呼ばれることもあります。実行するたびに呼ばれる回数が違うので、何かバグがあるのか、何か仕様があるのか、ちょっとよく分かりません。
  2. getSnapshot(in:completion:)は、WidgetGallery表示時にまれに変な回数呼ばれて、 small/medium/largeの3個でなく、倍の6個表示されることがあります。これはバグなのかな。ちょっとよく分かりません。

回数がおかしい件は、Xcodeでインストールしたときに前のバージョンのWidgetのハンドラが残っているタイミングがあるのかな、という気が何となくしています。実際にApp Storeでの配布時にバージョンアップした場合に、こういうことが起こらないと良いのですが。

ついでにgetTimeline

  1. getTimeline(in:completion:)は、ホーム画面にWidgetが追加されると呼ばれます。インストール後の初回追加時に限ってはplaceholder(in:)のあとになります[2]。それ以降の再追加時はplaceholder(in:)は呼ばれず、いきなりgetTimeline(in:completion:)が呼ばれます。
  2. getTimeline(in:completion:)で返すエントリがなんであれ、ビューのレンダリングはそれらのエントリを用いて、getTimeline(in:completion:)呼び出し直後にエントリ数だけ行われます。5個のエントリを入れたら5個のTimelineEntry.dateが一気に来て描画されます。そしてそれがキャッシュか何かに残り、各エントリの指定時刻になったら順にそれらが表示される、という仕組みのようです。その時刻になったらビューの描画コードが実行されて再描画される、とかではないので注意が必要です。指定時刻のレンダリング内容が決められない(計算で求めたり精度良く予測できたりができない)場合はそのエントリは入れるべきではないとも言えます。
  3. getTimeline(in:completion:)で返す最初のエントリは、TimelineEntry.dateに関係なく表示されます。日時そのもの、もしくは日時に紐付くデータを表示している場合、最初のエントリが未来の日時だとユーザは混乱します。
脚注
  1. プレースホルダモードによらず、実際のビューとプレビューは若干違う動きをします。具体的にはWidgetEntryViewが、実際のビューはvar bodyの中身がVStackでくるまれているかのような動きをするのに対し、プレビューではvar bodyの中身のビューが別々のビューになってしまいます ↩︎

  2. placeholderの項も参照 ↩︎

kabeyakabeya

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が呼ばれるまではプレースホルダが表示されます。