iOS14から新機能として、ホーム画面上で表示されるWidgetが提供されました、現在様々なアプリでもWidgetを使った便利な機能を提供しています。
今回は実際に簡単なWidgetを作成する過程を通し、公式ドキュメントの内容も交えて、詳しくWidgetを作る方法を説明します!
開発のマインド
まずWidgetの開発を始める前に、どのような技術Stackが必要なのか、簡単にみてみましょう。
- アプリ開発の基本知識
- SwiftUIの知識
- WidgetKitの使い方
アプリ開発の基本知識
まずWidget自体はApp Extensionの一種で、本体アプリとは独立な環境を持ちます。これはWidgetのライフサイクル、プロセス管理もアプリとは独立していることを意味します。
Widgetも本体アプリも交互のコードにアクセスできない、そのためWidgetではデータを取得際に独立したネットワーク機能やデータを保存する必要があればキャッシング機能も必要です。
SwiftUI
Widgetではそんなに複雑な画面は作らないので、基本のレイアウトに関する知識や簡単なViewの使い方がわかれば簡単に実装できます。
実際のSwiftUIの開発ではUIViewRepresentableを使ってUIKitで作ったViewを使うことができるんですが、残念ながらWidgetでは対応していません。ホーム画面に常に表示されるので、多分性能などを考慮してApple側で制限していると思われます。
- UIViewRepresentable https://developer.apple.com/forums/thread/652042
WidgetKitの使い方
Widgetは追加されれば常にホーム画面に存在しますが、Widgetで書いたコードはいつでも実行されるわけではないです。
Widgetにデータを渡してデータを更新したい場合、僕らが書いたコードはTimelineが更新される ( getTimeline
が呼ばれる)、WidgetをPreviewする際にgetSnapshot
が呼ばれるタイミングでしか実行されません。これに関しては後でコードを交えて説明します。
今回は以下のユーザに歩数情報を提供する、サンプルWidgetの実装を目指して、実際に作っていきます。
SwiftUIで画面を作る
最初に毎日の歩数データ、歩数の進捗プログレスバーを表示するWidgetを作ります。
これはImage + Text + ProgressBar (Text付き)の三部分にわかられます、この三つの部品をVStackで包みspacingを指定します。
ProgressViewに関しては、Rectangleの上にoverlayで少し小さいRectangleを被せれば出来上がりです。
実際のコードは以下になります、
struct HealthWidgetSmallView: View {
// entryのデータを元にViewの内容を表示
@ObservedObject var entry: HealthDataEntry = HealthDataEntry()
var body: some View {
VStack (spacing: 10){
Image(systemName: "figure.walk")
.font(Font.system(.largeTitle).bold())
Text("\(entry.step)歩").font(.system(size: 16))
ProgressView(progress: entry.stepProgress)
}
}
}
struct ProgressView: View {
var progress: CGFloat = 0.0
var progressValue: Int {
return Int(progress * 100.0)
}
var body: some View {
VStack(spacing: 3) {
Text("\(progressValue)%").font(.system(size: 12))
// Rectangleの上にRectangleを被せ、progressの値を元にwidthを決めます
Rectangle().fill(Color.gray)
.overlay(Rectangle().foregroundColor(.white)
.padding(2)
.cornerRadius(3)
.frame(width: progress * 100, height: 10), alignment: .leading
).frame(width: 100, height: 10).cornerRadius(5)
}
}
}
次に週間の歩数データを棒グラフで表示するWidgetを実装します。
この画面は、グラフ + Textの二部分に分けられます。これをHStackで包み、Spacerを入れて距離をとります。右上のアイコンはoffsetを使って正しい位置に表示されるように微調整します。
struct HealthWidgetMediumView: View {
@ObservedObject var entry: HealthDataEntry = HealthDataEntry()
let colorStyle = ChartStyle(backgroundColor: .white, foregroundColor: [
ColorGradient(ChartColors.orangeBright, ChartColors.orangeDark)
])
var body: some View {
VStack {
HStack {
VStack(spacing: 10) {
Text("今日の歩数:").font(.system(size: 10, weight: .semibold))
Text("\(entry.step)歩").foregroundColor(ChartColors.orangeBright).font(.system(size: 20))
}
VStack(spacing: 10) {
Text("今日の達成率:").font(.system(size: 10, weight: .semibold))
Text("80%").foregroundColor(Color.gray).font(.system(size: 20))
}
Spacer()
Image(systemName: "figure.walk.circle").foregroundColor(ChartColors.orangeBright)
.font(Font.system(size: 20)).offset(x: 0, y: -15)
}.padding(10)
BarChart().data(entry.weeklyData).chartStyle(colorStyle).padding(10)
}
}
}
SwiiftUIではVStack/HStackを上手く使えばいい感じに配置してくれ、微調整の際はoffset/postionで配置を調整します。
今回は試しにSmallとMediumサイズのWidgetを作成しました、これを出しわけする際は現在のViewの環境変数からWidgetの種類を元に、違うViewを表示させます。
struct HealthWidgetEntryView : View {
// Viewの環境変数からどのサイズのWidgetを知ることができる
@Environment(\.widgetFamily) var family: WidgetFamily
// Widgetを表示する際のtimelineのデータを渡します
var entry: HealthDataProvider.Entry
@ViewBuilder
var body: some View {
switch family {
case .systemSmall: HealthWidgetSmallView(entry: entry)
case .systemMedium: HealthWidgetMediumView(entry: entry)
default:
fatalError()
}
}
}
次にWidgetに表示できるサイズを指定して、SmallとMediumサイズだけ提供可能にします。
@main
struct HealthWidget: Widget {
let kind: String = "HealthWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: HealthDataProvider()) { entry in
HealthWidgetEntryView(entry: entry)
}
.supportedFamilies([.systemSmall, .systemMedium])
.description("This is an health widget example.")
}
}
これで作成したWidgetを追加して、実際のSimulatorで確認することができました。SwiftUIで画面ができたら、次は画面で表示するデータの取得を考えましょう。
Widgetで表示するdataの取得
- APIからデータ取得
- App Groupを通してキャッシュから取得
APIからデータを取得する
Widgetでも普通にURLSession
が使えるので、APIからデータを取得してそれをWidgetに渡せばいいだけです。
func fetchHealthData(onSuccess: @escaping(HealthData) -> Void) {
let task = URLSession.shared.dataTask(with: url) { (data, _, error) in
guard let data = data else { return }
let healthData = try! JSONDecoder().decode(HealthData.self, from: data)
onSuccess(healthData)
}
task.resume()
}
認証情報が必要なAPIの場合はWidget側で扱うのが難しいので、App Groupsを作成してメインアプリのキャッシュからデータを取得する手もあります。
App Groupsを通してキャッシュから取得
Widget ExtensionのTargetにApp Groupsを追加して、メインアプリと同じGroup IDを指定すれば、メインアプリのUserDefaultsのデータにアクセスすることができます。
// 同じGroupIdのUserDefaultsからデータを読み出す
if let userDefaults = UserDefaults(suiteName: "group.test") {
let value1 = userDefaults.string(forKey: "key1")
let value2 = userDefaults.string(forKey: "key2")
...
}
ただしこの場合、データの更新は完全にアプリ側の更新に依存するので、アプリ側でキャッシュデータの更新が行われない限り、Widget側のデータも古いままです。
Timelineを通してWidgetを更新
データ取得できたが、どのタイミングでデータを更新してWidgetに渡すのか、それに関わるのがTimelineです。
Widgetの更新はTimeline
更新に駆動されて行われます、Timeline
は複数のTimelineEntry
の配列から構成されています。一回に複数のTimelineEntryを渡せば、一連の違う状態の静的なViewを生成でき、指定された時間に表示してくれます。
TimelineEntry
を継承して、表示用のViewの生成時刻を指定するdate
とWidgetで表示するデータを持たせるようにします。
struct HealthDataProvider: IntentTimelineProvider {
// Timelineの更新に駆動されて、Widgetは更新します
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [HealthDataEntry] = []
let refresh = Calendar.current.date(byAdding: .minutes, value: 15, to: Date()) ?? Date()
healthDataStore.fetchHealthData { data
entries.append(HealthDataEntry(data))
let timeline = Timeline(entries: entries, policy: .after(refresh)
completion(timeline)
}
}
// ここではPreview用にmockデータを用意して、Widget Gallery内でもスケルトン表示ではなく、データが存在する状態で提供します
func getSnapshot(for configuration: Intent, in context: Context, completion: @escaping (HealthDataEntry) -> Void) {
let weeklySteps = [1000, 2000, 3000, 4000, 5000, 6000, 7000]
let sampleData = HealthData(step: 8000, stepProgress: 0.8, weeklySteps: weeklySteps)
compeltion(HealthDataEntry(sampleData))
}
// デフォルトではスケルトン表示してくれますが、データ取得できない場合に表示してきたいデータがあればデフォルトで指定しておく
func placeholder(in context: Context) -> HealthDataEntry {
return HealthDataEntry(HealthData(step: 0, stepProgress: 0, weeklySteps: []))
}
}
struct HealthDataEntry : TimelineEntry {
var date = Date()
let step: Int
let stepProgress: Double
let weeklySteps: [Int]
init(_ data: HealthData){
self.step = data.step
self.stepProgress = data.stepProgress
self.weeklySteps = data.weeklySteps
}
}
先ほど実装した、fetchHealthData
から取得できたデータからTimelineEntry
を生成して、Timeline
に渡してます。
getSnapshot
では一時的にWidgetで表示したいデータを渡して画面をレンダリングしてもらいます。例えば、Widget Galleryでサンプルデータを指定して表示させる、またはキャッシュから取得したデータを表示させる。データ取得に時間がかかる方法は推奨されていません。
placeholder
ここではデータ取得してWidgetのViewを生成する前に、placeholderとして表示するデータを返します。データが無い場合でも、デフォルトでスケルトン表示にしてくれます。
今回は一回に複数のデータを取得して、特定時間に特定のデータを表示する需要はないので、配列に一つのentryしか渡していません。代わりにデータを最新の状態に保持したいので、after(refresh)
で15分置きにTimelineの更新を要求しています、このReloadPolicyの設定に関しては後ほど説明します。
理想では、これで15分置きにTimelineが更新され、getTimeline
が呼ばれ、新しいデータを取得してWidgetを更新してくます。
Timelineを更新する方法は二つあります。
- system reload
- app-driven reload
system reload
これは名前の通り、システム側で制御してるTimeline更新の仕組みです。
WidgetでTimelineを生成する際にReloadPolicyを指定できます、さっきのコードで指定していた.after(refresh)
がReloadPolicyです。これはシステム側にTimelineの更新時期を要求する仕組みです。
atEnd
atEndに設定すると、最後のentryが表示された後、Timelineの更新を行います。
after()
afterで時刻設定すると、特定の時間でentry関わらずTimelineの更新を行います。
never
neverに設定すると、最後のentryが表示された後もずっと最後のentryの内容を表示するだけで、Timelineの更新は行いません。
Timelineのリロード時期は基本システム側で制御されているので、例えば .after(10:00PM)
を指定しても必ずこの時間に更新される保証はないです。ここで指定された時間は、10:00PMにシステム側にTimeline更新を要求することで、最終的な時間はシステム側の判断に委ねています。
そのため頻繁にTimelineの更新を要求すると、逆にシステム側から制限をくらい、全然更新されない場合もあります。逆によく使われるアプリは更新頻度が高くても、許容されます。この部分に関しては公式の説明がないので、経験でしかなく、まだ謎は多いです。
app-driven reload
Widget側で事前に更新時期を指定する以外でも、アプリを起動した際に、WidgetCenterを通して主動的にWidgetのTimelineを更新する方法も提供されています。
// 特定のWidgetを更新する
WidgetCenter.shared.reloadTimelines(ofKind: "HealthWidget")
// 一つのAppで複数のWidgetを作ることができるので、これで全部更新できます。
WidgetCenter.shared.reloadAllTimelines()
Widgetをタップした際の遷移
現状Widgetの重要な機能の一つとしてはアプリへの遷移を導くとこです、Widgetからアプリへの遷移はDeep Link
を使います。ここでDeep Link
の説明は省略します。
基本二種類の方法でWidgetからアプリに遷移するDeep Link
を付与することができます。そしてURLが付与されたWidgetをタップすると、アプリが開いてAppDelegate
のopenURL
が呼び出され、特定の画面へ遷移します。
- widgetURL
struct HealthWidgetSmallView: View {
var body: some View {
VStack (spacing: 20){
...
}.widgetURL(URL(string: "healthApp://stepDetail")
}
}
- Link
LinkもSwiftUIで提供されているViewModifierの一種で、urlを付与したViewを返します。一つのWidgetの画面を構成する各部品では違うLinkを使えます. ここで注意するのは、.systemSmall
サイズのWidgetにはLinkは使えません、そのためsmallサイズのWidgetではwidgetURL
を使って一つの遷移先しか指定できません。
struct HealthWidgetMediumView: View {
Link(destination: URL(string: "healthApp://stepDetail")!) {
HStack {
...
}
}
Link(destination: URL(string: "healthApp://stepDetailGraph")!) {
BarChart().data(entry.weeklyData).chartStyle(colorStyle).padding(10)
}
}
まとめ
これで基本なWidgetが完成しました、そのほかIntentConfiguration
を設定して (https://developer.apple.com/documentation/widgetkit/making-a-configurable-widget) 、ユーザにWidgetをカスタマイズさせることもできますが、今回は割愛します。
実際Widgetを提供することで、アプリは常にユーザのホーム画面に占有することができ、かつアプリを開かずともユーザに知りたい情報を提供することができます。
しかし、ユーザとのintractive操作は制限されていて、Gifやアニメ-ションも使えません。Widgets are not mini-apps
, 公式が言ってるように、Widgetはアプリではなく、現時点では静的な画面で情報を提供する、アプリへの導線の一つでしかない位置です。
現時点でのWidgetの良さと欠点をまとめると、
利点としては、
- Widgetをタップすることでアプリの特定機能に素早く遷移できる
- ホーム画面を占有して、アプリの利用度向上につなげる
- 複数のサイズとカスタマイズ性があり、ユーザ向けにカスタマイズしたデータを提供できる
欠点としては、
- リアルタイムにデータをアップデートできない(現状の最小間隔は15分との経験談、公式での説明が欠如)
- タッチ動作しか対応してない
- Gif画像やVideo、アニメーションも対応してない
ホーム画面でのWidgetはまだ提供されたばかりで、まだ提供される機能やカスタマイズ性は低いですが、今後の発展に期待です!どうでしょう、皆さんもぜひ自分のアプリでWidget開発を試してみてください!
この記事を読んで「勉強になった」「ここは違うかもしれない」など感想がある方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。