はじめまして。サービス開発パートナー事業部(旧: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
- Widgetの各種設定
- Viewの作成
- タイムラインの作成
- Previewsの作成
コード
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")
.description("Widgetの名前")
.supportedFamilies([
.accessoryCircular,
.accessoryInline,
.accessoryRectangular,
.systemExtraLarge,
.systemLarge,
.systemMedium,
.systemSmall
])
}
}
ウィジェットの名前。
ウィジェットリストでの説明用。
.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 {
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?
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

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を模索していきたいです。