iOS14/iPadOS14 からの WidgetKit

iOS14/iPadOS14 からホーム画面にウィジェットを表示できる機能がつきました。当社でリリースしている予定管理アプリ「My Schedule」も本日のアップデートでウィジェット対応となり、「今日の予定」がアプリを起動せずに確認できるようになりました。

ウィジェットについて簡単にまとめると、

・サイズは3種類(small, medium, large)
・1つのアプリで複数のウィジェットを作成可能
・ビューはSwiftUIで作成

画像1

となっており、サイズに合わせてウィジェットに表示する情報量も変え、見やすいUIをつくっていきます。

今回はこの WidgetKit についてサンプルコードを中心に仕組みや作り方をご紹介します。

ウィジェット用のTarget作成

(公式: https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension

Xcode 12 を開き、新しいターゲットを追加すると、「Widget Extension」というのが追加されているので、それを選択します。

スクリーンショット 2020-09-28 10.44.26

Product Name を「SampleWidget」として作成すると、SampleWidget.swift というファイルが作成され、ウィジェット表示に必要なサンプルコードがすでに記載されています。Canvas で Resume を押してみると、small のサイズで現在日時が表示されるウィジェットがプレビューされます。

スクリーンショット 2020-09-28 10.51.24

それでは、ウィジェットが動作する仕組みと、このファイルで使用されているクラスについて具体的にご紹介していきます。

ウィジェットの仕組み

基本的には表示するデータを保持するクラス(モデルにあたります)、表示するビューを作成して、あとは指定時間にそのビューにモデルを渡してあげてレンダリングするだけです。時間を指定するだけでなく、アプリ側から任意のタイミングで更新することもできます。

では、上記の仕組みをサンプルコードでどう書かれているのか具体的に見ていきます。サンプルコードでは1時間ごとに現在日時を表示するウィジェットになっています。コードに入る前に図で簡単に表すと以下のようになっています。

画像4

最初にウィジェットの更新情報を5つ渡して、あとはそれをループさせているイメージです。ではクラスごとに見ていきましょう。

TimelineEntry

ウィジェットのビューに使用されるデータを保持するクラスです。

struct SimpleEntry: TimelineEntry {
   let date: Date
   let configuration: ConfigurationIntent
}

date はこの TimelineEntry を使用する日時を指定します。指定の時間になるとこの TimelineEntry がウィジェットに渡され、ビューが更新されます。date は実装が必須になっていますが、あとは表示に必要なプロパティを自由に定義できます。サンプルコードでは ConfigurationIntent というクラスを定義していますが、これはウィジェットの設定クラスです(後ほどご説明します)。

View

ウィジェットを表示するビューです。通常の SwiftUI のビューと同じです。サンプルでは TimelineEntry の date を使用するだけなので、Provider.Entry と単純に TimelineEntry のプロトコルを指定する形での宣言になっていますが、実際には var entry: SimpleEntry という形でカスタムクラスを宣言することの方が多いかと思います。

struct SampleWidgetEntryView : View {
    var entry: Provider.Entry
    var body: some View {
        Text(entry.date, style: .time)
   }
}

TimelineProvider

Timeline とは、ウィジェットの表示情報である TimelineEntry と、次に Timeline を更新する日時を格納したクラスです。Provider は「提供者」という意味で、要するにこのクラスは Timeline を提供するクラスという意味になります。サンプルコードの getTimeline メソッドでこの Timeline の配列を返しています。

func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    var entries: [SimpleEntry] = []
    
    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    let currentDate = Date()
    for hourOffset in 0 ..< 5 {
         let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
         let entry = SimpleEntry(date: entryDate, configuration: configuration)
         entries.append(entry)
    }
    
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
}

struct SimpleEntry: TimelineEntry {
   let date: Date
   let configuration: ConfigurationIntent
}

サンプルでは 1時間ごとに更新される TimelineEntry を5つ用意して Timeline を作成しています。もし非同期でサーバからデータを取ってくる必要がある場合は、データ取得が完了した後にcompletion(timeline) を呼べば大丈夫です。

そして、policy には次の Timeline をいつリクエストするか(TimelineReloadPolicy)を指定します。

atEnd(デフォルト値): 渡されたTimelineの中で一番最後の日時にリクエスト
never: 次のTimelineをリクエストしません。もし新しいTimelineが必要になった時はアプリからリクエストを飛ばします。
after(_ date: Date): 指定日にリクエスト

また、getTimeline の他に placeholder と getSnapshot の2つのメソッドがあります。

func placeholder(in context: Context) -> SimpleEntry {
    SimpleEntry(date: Date(), configuration: ConfigurationIntent())
}

func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
    let entry = SimpleEntry(date: Date(), configuration: configuration)
    completion(entry)
}

placeholder は TimelineEntry を返すメソッドなのですが、正直ドキュメントを読んでもどこで使われているのかイマイチわかりませんでした。Snapshot は、たとえばウィジェットギャラリー(ウィジェット一覧画面)で迅速なビューの表示が必要な場合に呼ばれるメソッドです。何かしらのサンプルデータを入れた TimelineEntry を返すようにします。

サンプルではProviderが IntentTimelineProvider のプロトコルを実装していますが、これは WidgetConfiguration(下記参照) で IntentConfiguration を指定した場合に使用するもので、StaticConfiguration を使用する場合は通常の TimelineProvider プロトコルを実装するだけで問題ありません。

Widget

文字通りウィジェットのクラスです。body プロパティでウィジェットの表示名やサイズ、使用するビューなどを指定した設定クラスを返します。

@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"
    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
   }
}

kind: ウィジェットの識別子です。一意の文字列を指定します。
WidgetConfiguration: ウィジェットの設定クラスです。Configuration には ユーザがウィジェットで表示する項目を選択できる IntentConfiguration と設定できない StaticConfigration があります。サンプルコードは単純に日付を表示するだけなのでユーザが表示する項目を設定するとかではないのですが、なぜか IntentConfiguration が使われています。
intent: IntentConfigurationを使用する場合に指定します。
provider: 使用する TimelineProvider のインスタンスを指定します。
content: ウィジェットに表示する SwiftUI のビューを返します。更新時間になると Provider からTimelineEntry パラメータが渡され、指定したビューを表示します。
configurationDisplayName:  ウィジェットを長押し→「ウィジェット名を編集」を押した時に表示されるウィジェット名。
description: ウィジェットを長押し→「ウィジェットを編集」を押した時に表示される説明文。

ちなみにサンプルでは定義していないですが、サイズの指定は .supportedFamilies([.systemSmall]) というように設定します。

※ ウィジェットを長押し→「ウィジェットを編集」は、IntentConfiguration を使用した場合のみ表示されます。IntentConfiguration についてサンプルコードにないので深くはご紹介しませんが、たとえばネイティブの天気アプリのウィジェットを長押しして「"天気"を編集」を押すと、天気を表示する対象の場所が選べたりしますが、これが「ユーザが表示項目を設定できる」ということです。ユーザが設定できるプロパティは SiriKit Intent Definition file を作成して宣言するなど少し複雑なので、詳しくは公式ドキュメントをご参照ください。
https://developer.apple.com/documentation/widgetkit/making-a-configurable-widget

その他

アプリからウィジェットを更新する場合は WidgetCenter を使用します。

WidgetCenter.shared.reloadAllTimelines()

上記のコードではすべてのウィジェットを更新します。特定のウィジェットのみ更新したい場合は、 getCurrentConfigurations メソッドでアプリから利用可能なウィジェット一覧を取得するとkind情報が取れるので、reloadAllTimelines に kind を渡してあげましょう。

おさらい

以上でサンプルコードをベースにした WidgetKit の説明は終わりになりますが、他にもウィジェットがスタックされた場合にどの Timeline を優先的に表示するか(Relevance)など、まだご紹介できていない範囲もあるので、一度以下の公式ドキュメントを一通り眺めてみるのをおすすめします。


この記事が気に入ったらサポートをしてみませんか?