【Swift UI】Widget(ウィジェット)の実装方法!TimelineProviderとは?
この記事からわかること
- Swift UIでWidgetを実装する方法
- TimelineProviderやTimelineEntry、Timeline構造体の役割と使い方
- placeholder/getSnapshot/getTimelineメソッドの意味
- StaticConfigurationとIntentConfigurationの違い
index
[open]
\ アプリをリリースしました /
環境
- Xcode:16.3
- iOS:18.4
- Swift:6
- macOS:Sequoia 15.6.1
Widget(ウィジェット)とは?
Widget(ウィジェット)とはiOS14以降から追加されたデバイスのホーム画面上にビューを設置できる機能のことです。
アプリ内のデータをホーム画面上に表示できることでアプリを起動させなくてもデータを閲覧できたり、またそこから簡単にアプリへアクセスすることができるようになります。
Widget(ウィジェット)機能はWidgetKitフレームワークとして提供されておりimportして使用します。ですがSwiftUIフレームワークを使用しているプロジェクトにのみ導入できるようになっており、UIKitフレームワークの場合は導入できないようです。
実装するにはプロジェクト内に新しくWidget Extensionを追加する必要があります
プロジェクトに「Widget Extension」の追加
アプリ内にWidgetを実装するにはまずメニューから「File」>「 New 」>「Target...」を選択後「Widget Extension」を追加します。
Widget名を記述したら「Inclued Configuration Intent」のチェックを外して「Finish」をクリックします。
するとプロジェクト内に新しくフォルダが生成され中には最初からデモウィジェットが表示できるように記述されたSwiftファイルが作られています。Widgetのみをビルドするには上部のビルド対象を追加したWidget Extensionに変更して「 」ボタンを押します。
StaticConfigurationとIntentConfiguration
作成できるWidgetには2つのプロトコルに準拠したものがあり、作成目的の用途にあった方のプロトコルに準拠した方を選択する必要があります。大きな違いはユーザーがカスタマイズできるプロパティを保持しているかどうかです。
ユーザーが構成可能なプロパティを持たないウィジェット用。株式市場ウィジェットやニュースウィジェットなど
ユーザー設定可能なプロパティを持つウィジェット用。SiriKitカスタムインテントを使用してプロパティを定義。都市の郵便番号が必要な天気予報ウィジェットや追跡番号が必要な荷物追跡ウィジェットなど
この設定の切り替えは「Widget Extension」の追加時に「Inclued Configuration Intent」のチェックを入れるか入れないかによって選択することができます。
コードの中身と役割
Widget Extensionを追加後に生成されるStaticConfigurationの場合のファイルのコードの中身の役割を見ておきます。新規で追加するとサンプルとして現在時刻を表示するビューを持ったWidgetが生成されます。
生成されているのは以下の5つの構造体です。
- Provider:TimelineProviderに準拠
- SimpleEntry:TimelineEntryに準拠
- TestWidgetEntryView:Viewに準拠
- TestWidget:Widgetに準拠
- TestWidget_Previews:PreviewProviderに準拠
それぞれの役割
- Provider:TimelineEntry(表示データと表示更新時刻設定)の作成
- SimpleEntry:TimelineEntryの構造を定義
- TestWidgetEntryView:アクティブのTimelineEntryを反映させるビューを構築
- TestWidget:Widgetの説明を定義
- TestWidget_Previews:プレビュー用
import WidgetKit
import SwiftUI
// MARK: - (1)
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date())
completion(entry)
}
func getTimeline(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)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
// MARK: - (2)
struct SimpleEntry: TimelineEntry {
let date: Date
}
// MARK: - (3)
struct TestWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
// MARK: - (4)
@main
struct TestWidget: Widget {
let kind: String = "TestWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TestWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
// MARK: - (5)
#Preview(as: .systemSmall) {
TestWidget()
} timeline: {
TestWidgetEntryView(entry: SimpleEntry(date: Date()))
}
TimelineProviderプロトコル
// MARK: - (1)
struct Provider: TimelineProvider {
/// 仮で表示させたいビュー
func placeholder(in context: Context) -> SimpleEntry {
}
/// 一時的な(最初の)ビュー&Widget Galleryのプレビュー
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
}
/// 時間と共に変化させる間隔とビュー
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
}
}
公式リファレンス:TimelineProviderプロトコル
TimelineProviderプロトコルはウィジェットの表示を更新するタイミングを通知する処理を提供します。メソッドに初期表示されるビューや時間の経過と共に表示させるデータを変更させる処理が備わっています。元から実装されているメソッドは以下の通りです。
- placeholder:仮で表示させたいビュー
- getSnapshot:一時的な(最初の)ビュー&Widget Galleryのプレビュー
- getTimeline:時間と共に変化させる間隔とビュー
これらのメソッドは後述するTimelineEntry型が大きく関係してきます。
placeholderメソッド
placeholderメソッドはウィジェットが初めて表示される時のビューをレンダリングします。placeholderとは「仮の確保場所」のような意味を持つのですぐに置き換えられる可能性があり、仮で表示させたいビューを指定します。
サンプルで実装されているウィジェットでは現在時刻がセットされています。
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
getSnapshotメソッド
getSnapshotメソッドはWidget Galleryで表示させるプレビューを定義するメソッドです。またWidgetが追加された時に最初に表示されるビューにもなります。Snapshotとは「その瞬間のもの〜」といった意味を持つので一時的な(最初だけの)ビューを指定します。
ここもサンプルでは現在時刻がセットされています。
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date())
completion(entry)
}
Widget Galleryの表示方法は下記記事を参考にしてください。
getTimelineメソッド
getTimelineメソッドは表示されているWidgetのビューを更新するタイミング(日時)と更新するデータを提供するメソッドです。後述するTimelineEntry型の構造で日付やデータは保持されます。
このサンプルでは現在時刻と1,2,3,4時間後のTimelineEntryを生成し、entriesプロパティに格納しています。最後はTimeline構造体としてTimelineEntry配列を返しています。
func getTimeline(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 { // 0 〜 4 まで繰り返す
// 現在時刻 , 現在時刻+1時間 , 現在時刻+2時間 ... を生成
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
// TimelineEntry型に日付情報を保持させる
let entry = SimpleEntry(date: entryDate)
// 配列に追加
entries.append(entry)
}
// Widgetを更新するタイムラインを構築
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
Widgetの更新頻度は公式によると1日単位で40〜70回程度、時間間隔にして最短15分おきが推奨されています。
公式リファレンス:Keeping a Widget Up To Date
Timeline構造体
Widgetの更新タイミングはTimeline構造体によって指定されます。引数にはタイムラインを構築する日付情報を持ったTimelineEntry型の配列と更新ポリシーを保持しています。
struct Timeline<EntryType> where EntryType : TimelineEntry {
let entries: [EntryType] // タイムラインを構築する日付情報を持ったTimelineEntry型の配列
let policy: TimelineReloadPolicy // 更新ポリシー
}
更新ポリシー(getTimelineメソッドを再度呼び出すタイミング)はTimelineReloadPolicyの値を指定します。
- .atEnd:配列内の日付が最後までいったら呼び出す
- .after(_ date: Date):指定時刻に呼び出す
- .never:呼び出さない
TimelineEntryプロトコル
TimelineProviderのメソッドでも扱っていたTimelineEntry型はWidgetを表示する日付(時刻)とデータを提供するプロトコルです。日付(Date)型のdateプロパティを保持しており、TimelineEntryが持つ日付(時刻)にWidgetが表示されるような仕組みになっています。
なのでplaceholderとgetSnapshotメソッドには現在時刻をプロパティにセットしていたので即座に表示され、getTimelineでは任意の時間間隔を持たせた日付(時刻)をセットしていたのでその時刻になったら表示されるようになっています。
サンプルではTimelineEntryプロトコルに準拠させたSimpleEntry構造体が定義されています。
// MARK: - (2)
struct SimpleEntry: TimelineEntry {
let date: Date
}
実際のビュー:View
Widgetに表示させるビュー構造を記述する構造体です。この中に表示させたいビューを好きなように配置していきます。ここにHello World!のような固定値を入力することもできますが、主にアクティブになっているentryに定義されているデータを参照することで可変的なビューを構築していくことが多いと思います。
// MARK: - (3)
struct TestWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
HStack{
Image(systemName: "iphone").font(.system(size: 40))
VStack{
Text("Hello World!")
Text(entry.date, style: .time)
}
}
}
}
Widgetの説明:Widget
表示されるWidget名とWidgetの説明を定義する構造体です。この構造体の中に最初に紹介したStaticConfigurationまたはIntentConfigurationが定義されそのメソッドとして名称や説明を定義することができます。チェックの有無で自動的に適したConfiguration構造体が記述されます。
- kind:Widgetを識別する一意の文字列
- provider:ビューの更新のタイミングを管理するオブジェクト
- configurationDisplayName:Widget名
- description:Widgetの説明
// MARK: - (4)
@main
struct TestWidget: Widget {
let kind: String = "TestWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TestWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
プレビュー:PreviewProvider
// MARK: - (5)
#Preview(as: .systemSmall) {
TestWidget()
} timeline: {
TestWidgetEntryView(entry: SimpleEntry(date: Date()))
}<
Widgetにも通常のSwift UIで作成したビューのようにプレビューを表示させるコードが記述されています。表示させるサイズは列挙型WidgetFamilyの値から指定して変更できます。iOSでは3種類(小・中・大)、iPadでは4種類(小・中・大・特大)から選択できます。
enum WidgetFamily {
case systemSmall // 小サイズ
case systemMedium // 中サイズ
case systemLarge // 大サイズ
case systemExtraLarge // 特大サイズ
}
またWidgetのUIを実装している側でも表示しているサイズに応じてViewをだし分けることが可能です。@Environment(\.widgetFamily)でWidgetサイズを取得できるのでそれに合わせて分岐してあげればOKです。
struct TestWidgetEntryView: View {
var entry: Provider.Entry
/// Widgetサイズを取得
@Environment(\.widgetFamily) var family
var body: some View {
// WidgetサイズごとにViewを分岐
switch family {
case .systemSmall:
smallView
case .systemMedium:
mediumView
case .systemLarge:
largeView
case .systemExtraLarge:
largeView
default:
mediumView
}
}
}
Cycle inside プロジェクト名; building could produce unreliable results. Cycle details:
Widgetを追加してビルドを試みた際に以下のようなエラーが発生しました。
Cycle inside プロジェクト名; building could produce unreliable results. Cycle details:
このエラーは「Build Phases」内の「Embed Foundation Extensions」位置を上に持ってくることで解消することができました。詳細な原因までは分かりませんがCocoa Pods周りかクラッシュリティクス周りのスクリプトなどと相性が悪かったのかもしれません。
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。





