Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

WidgetKitに入門してみた

 はじめまして。サービス開発パートナー事業部(旧:AIR Design for Apps事業部)iOSエンジニアの住山です。好きなキーボードはHHKBです。 さて今回はWidgetKitに入門してみたので、記事にしていきたいと思います。

実行環境

ディレクトリ構成

説明に不要なファイルは省略してます。

.
├── WidgetPractice.xcodeproj
├── WidgetPractice
│   ├── Assets.xcassets
│   ├── ContentView.swift
│   └── WidgetPracticeApp.swift
└── WidgetGlpgs
    ├── Assets.xcassets
    ├── Info.plist
    ├── WidgetGlpgs.swift
    ├── WidgetGlpgsBundle.swift
    └── WidgetGlpgsLiveActivity.swift

Xcodeの「File」→「New」→「Target」でWidget Extensionを追加すると、必要なファイルが追加されます。(上記ディレクトリ内のWidgetGlpgsフォルダ配下)

ゴール

みんな大好きGithubAPIからjsonを取得して、内容をWidgetに表示。レポジトリはこちら

github.com

ウィジェット作成の概要

  1. Widgetの各種設定
  2. Viewの作成
  3. タイムラインの作成
  4. Previewsの作成

1. Widgetの各種設定

コード

struct WidgetGlpgs: Widget {
    let kind: String = "WidgetGlpgs"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            WidgetGlpgsEntryView(entry: entry)
        }
        .configurationDisplayName("Widget Name")
        /// Widgetの説明。
        .description("Widgetの名前")
        /// Widgetのサイズ一覧。
        .supportedFamilies([
                            /// accessoryXXXはロック画面用。iOS16〜
                            .accessoryCircular,
                            .accessoryInline,
                            .accessoryRectangular,
                            /// サイズ一覧
                            /// ExtraLargeはiPadOS専用。(https://developer.apple.com/documentation/widgetkit/widgetfamily/systemextralarge)
                            .systemExtraLarge,
                            .systemLarge,
                            .systemMedium,
                            .systemSmall
        ])
    }
}

.configurationDisplayName("Widget Name")

ウィジェットの名前。

.description("Widgetの説明")

ウィジェットリストでの説明用。

.supportedFamilies([...])

サポートするサイズ一覧です。後述しますが、サイズ毎にViewを作成できます。

  • accessoryXXXはロック画面のWidget用。iOS16〜で使えます。
  • ExtraLargeはiPadOS専用のようです。

developer.apple.com

2. Viewの作成

コード

struct WidgetGlpgsEntryView : View {
    var entry: Provider.Entry
    @Environment(\.widgetFamily) private var widgetFamily

    var body: some View {
        /// サイズ毎にViewのカスタマイズ
        if entry.githubUserInfoModel == nil {
            Text("ロード中")
        } else {
            switch widgetFamily {
            case .systemMedium:
                HStack() {
                    Image(uiImage: ApiClient.getImage())
                        .resizable()
                        .scaledToFit()
                        .frame(height: 100)
                    VStack(alignment: .leading) {
                        Text(entry.githubUserInfoModel!.location ?? "")
                        Text(entry.githubUserInfoModel!.name)
                        Text("PubRepos:  \(entry.githubUserInfoModel!.publicRepos)")
                    }
                }
            default:
                Image(uiImage: ApiClient.getImage())
                    .resizable()
            }
        }
    }
}
  • @Environment(\.widgetFamily)でユーザが追加したWidgetのサイズを取得し、サイズ毎にViewを作成できます。

3. タイムラインの作成

IntentTimelineProvider を継承したProviderでライフサイクルを実装します。下記コードです。

struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent(), githubUserInfoModel: nil)
    }
    func getSnapshot(for configuration: ConfigurationIntent,
                                   in context: Context, 
                                   completion: @escaping (SimpleEntry) -> ()) {
                                            let entry = SimpleEntry(date: Date(),
                                                                    configuration: configuration, 
                                                                    githubUserInfoModel: GithubUserInfoModel.fake())
                                            completion(entry)}

    func getTimeline(for configuration: ConfigurationIntent, 
                     in context: Context,
                     completion: @escaping (Timeline<Entry>) -> ()) {
        Task {
            var entries: [SimpleEntry] = []
            let currentDate = Date()
            let githubUserInfoModel: GithubUserInfoModel?
           /// 10分毎にViewの更新を依頼。
            let entryDate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)!
            let githubUserInfoModel: GithubUserInfoModel? = {
             do {
                 return try await ApiClient.fetch()
             } catch {
                      return nil
                }
            }()
            let entry = SimpleEntry(date: entryDate, configuration: configuration, githubUserInfoModel: githubUserInfoModel)
            entries.append(entry)
            
            let timeline = Timeline(entries: entries, policy: .atEnd)
            completion(timeline)
        }
    }
}

struct SimpleEntry: TimelineEntry {
    var date: Date
    let configuration: ConfigurationIntent
    let githubUserInfoModel: GithubUserInfoModel?
}

placeholder()

Widgetを追加後、最初に仮で表示されるViewを設定します。

getSnapshot()

Widgetギャラリーの表示用Viewを設定します。

getTimeline()

ここでWidgetのライフサイクルを設定します。

  • Calendar.current.date(...)でViewの更新時間を設定します。上記の例では10分毎に更新するよう設定してます。 (試しに1分毎を設定してみましたが、3分前後で更新されました。Viewの更新タイミンについて、完全にはコントロールできないようです。)

    そのため頻繁にTimelineの更新を要求すると、逆にシステム側から制限をくらい、全然更新されない場合もあります。逆によく使われるアプリは更新頻度が高くても、許容されます。この部分に関しては公式の説明がないので、経験でしかなく、まだ謎は多いです。(下のブログから引用。)

engineering.dena.com

4. Previewsの作成

struct WidgetGlpgs_Previews: PreviewProvider {
    static var previews: some View {
        WidgetGlpgsEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), githubUserInfoModel: GithubUserInfoModel.fake()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

ここの記事がとても参考になりました。 qiita.com

おわりに

以前から気になっていたWidgetをブログ執筆を気に学べました。実務でも提案できるように、良いユーザ体験を与えられるようなWidgetを模索していきたいです。