この記事は and factory Advent Calendar 2020 の15日目の記事です。
昨日は @MatsuNaoPen さんの GASを使ったwrikeのwebhook でした。
はじめに
Apple が今年の WWDC2020 で WidgetKit を発表して早半年。
瞬く間に iOS エンジニア内で WidgetKit の知見が共有されました。
そこで今回は個人開発しているアプリケーションに WidgetKit を追加するまでの軌跡を記事にしました。
個人アプリの紹介
クラッシュ・ロワイヤル(以下クラロワと呼びます)というゲームのツールアプリを現在開発しています。
主な機能としては以下3つ。
トロフィー(強さを示す数値)の期間毎の推移 | デッキを作成してクラロワ本体のアプリにコピー機能 | RoyaleAPIというウェブを閲覧できる機能 |
---|---|---|
そこで今回はウィジェットと相性が良さそうな下記機能でウィジェットを作ってみることにしました。
トロフィー(強さを示す数値)の期間毎の推移
この機能を選んだ理由としてクラロワをプレイしたあとにアプリを起動せずともホームに置いてあるウィジェットにその内容が反映されたら便利だと思ったためです。
【完成図】
直近の勝敗とトロフィー数がどれだけ変わったのか、あとは現在のトロフィー数と最後に更新された時間を載せたシンプルなウィジェットになっています。
実装
1. Widget Extension を追加する
Xcode の File -> New -> Target
を選択。
Widget
と検索して Widget Extension
を追加します。
Extension 作成後に出てくる上記は Activate
を選択します。
そうすると上記のようにスキーマに TodaysWidgetExtension
になっていて左のプロジェクトフォルダに TodaysWidget
があれば作成成功です。
この時点でビルドすると現在時刻だけが表示されるウィジェットがシミュレータ上で確認できます!(すごく簡単!)
2. Widget のソースコードに手を加える
先ほどのスクショでも挙げたように基本となるソースコードは Extension 以下にある1つのファイルです。
(今回だとTodaysWidget.swift)
なのでそのソースコードを見ていきます
//
// TodaysWidget.swift
// TodaysWidget
//
// Created by nakandakari on 2020/12/13.
//
import WidgetKit
import SwiftUI
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)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
@main
struct TodaysWidget: Widget {
let kind: String = "TodaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TodaysWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
struct TodaysWidget_Previews: PreviewProvider {
static var previews: some View {
TodaysWidgetEntryView(entry: SimpleEntry(date: Date()))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
2.1 Widget 本体部分
まずはエントリーポイントである @main
が付いた以下の箇所から紐解いていきます
@main
struct TodaysWidget: Widget {
let kind: String = "TodaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
TodaysWidgetEntryView(entry: entry)
}
.configurationDisplayName("My Widget")
.description("This is an example widget.")
}
}
まずは Configuration についてですが、ウィジェットで表す情報をユーザが動的に変更できるかどうかで Configuration が変わってきます。
種類 | 内容 |
---|---|
StaticConfiguration | ユーザが特にデータをカスタマイズしない場合に使用される。 |
IntentConfiguration | ユーザがデータをカスタマイズしたい場合に使用される。 例えば天気アプリなどで現在地ではなく指定した場所の情報を取得したいケースなどに使われる。 |
IntentConfiguration だと下記のようにウィジェットを長押しする事で取得するデータをカスタマイズする事が出来ます。
各プロパティの説明は以下の通りです。
プロパティ | 意味 |
---|---|
Kind | そのウィジェットを示す固有の文字列。基本的にはそのウィジェットを説明する文字列が推奨される。 |
Provider | ウィジェットにはタイムラインという概念があり、一定時間毎にどういったデータ(=Entryと呼ばれるオブジェクト)をウィジェットに伝えるかというルールを決める役割。 |
Content Closure | Provider から受け取ったデータ(=Entry)を使って SwiftUI の View を返す。 |
ConfigurationDisplayName | ウィジェットを追加する際に表示されるタイトル文言。 |
Description | ウィジェットを追加する際に表示される説明文言。 |
2.2 Entry と View 部分
続いてはウィジェットの中身を表す SwiftUI の View 部分を見ていきます
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
Text(entry.date, style: .time)
}
}
まず View は Entry というデータを受け取って中身を表示すると説明しました。
ここでは SimpleEntry
がそのデータになります。
また補足ですが TimelineEntry
のプロトコルは以下のようになっています
public protocol TimelineEntry {
/// The date for WidgetKit to render a widget.
var date: Date { get }
/// The relevance of a widget’s content to the user.
var relevance: TimelineEntryRelevance? { get }
}
また View も TodaysWidgetEntryView
というカスタムビューの Struct 定義して、さきほどの SimpleEntry を使って、時間を表示しています。
※今回は SwiftUI の記法や実装についてはあまり言及しません。そこまで複雑な見た目では無いのでソースコードの雰囲気でなんとなく分かるかと思います。
この SimpleEntry がデータであり、それを受け取って TodaysWidgetEntryView で表示するビューを作っている事が分かりました。
なので今回はそこに手を加えていけば良いので、以下のようにしてみました。
struct SimpleEntry: TimelineEntry {
let date: Date
let todaysResult: TodaysResultInfo // 必要なデータ群を追加
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack(alignment: .center, spacing: 5, content: {
Text("Recenlty Result")
// self.entry.todaysResult を使ってビューを作っていく
})
}
}
struct TodaysResultInfo {
let win : Int
let lose : Int
let draw : Int
let changes : Int
let currentTrophy : Int
init(win: Int, lose: Int, draw: Int, changes: Int, currentTrophy: Int) {
self.win = win
self.lose = lose
self.draw = draw
self.changes = changes
self.currentTrophy = currentTrophy
}
}
extension TodaysResultInfo {
var changesLabel: String {
if self.changes > 0 {
return "+\(changes)" // プラスの時は +◯ と表記したい
}
return "\(changes)"
}
}
extension TodaysResultInfo {
// Preview のテスト用データ
static var dummyData: TodaysResultInfo {
TodaysResultInfo(win: 4, lose: 2, draw: 1, changes: 50, currentTrophy: 2300)
}
}
それでは View も作っていきます。
//
// TodaysResultView.swift
// WidgetTest
//
// Created by nakandakari on 2020/12/13.
//
import SwiftUI
import WidgetKit
struct TodaysResultView: View {
let entry: SimpleEntry
var body: some View {
VStack(alignment: .center, spacing: 10, content: {
Text("Recentlty Result")
HStack {
LabelAndCountView(label: "Win", count: self.entry.todaysResult.win)
LabelAndCountView(label: "Lose", count: self.entry.todaysResult.lose)
LabelAndCountView(label: "Draw", count: self.entry.todaysResult.draw)
}
HStack {
Text("Changes")
Text(self.entry.todaysResult.changesLabel)
}
HStack {
Image("player_trophy")
.resizable()
.frame(width: 28, height: 28)
Text("\(self.entry.todaysResult.currentTrophy)")
Text(self.entry.date, style: .time)
}
})
}
}
struct LabelAndCountView: View {
let label: String
let count: Int
var body: some View {
VStack(alignment: .center, spacing: 3, content: {
Text(label)
Text("\(count)")
})
}
}
struct TodaysResultView_Previews: PreviewProvider {
static var previews: some View {
TodaysResultView(entry: SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.dummyData))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}
struct TodaysWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
TodaysResultView(entry: entry) // ここを変更
}
}
2.3 Timeline 部分
最後に時間毎にどのようなデータを生成するかの Provider 部分を見ていきます
struct Provider: TimelineProvider {
typealias Entry = SimpleEntry // ここはさっきの View を作る時に追加しています
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.placeHolderDummyData)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.snapShotDummyData)
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, todaysResult: TodaysResultInfo.dummyData)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
ここでは主に作成するウィジェットが「どのぐらいの頻度で更新して欲しいか」に合わせて getTimeline
メソッドをカスタマイズしていく必要がありそうです。
以下は「30分後にウィジェットを更新する」例になります。
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let date = Date()
var entries: [SimpleEntry] = []
entries.append(SimpleEntry(date: date, todaysResult: TodaysResultInfo.dummyUpdatedData))
// 30分後にウィジェットを更新する
let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: date)!
let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
completion(timeline)
}
※ただし必ず指定時間後に更新される保証はない&最低でも15分は更新間隔を設けたほうが良いとのこと。
詳しくはコチラ:Keeping a Widget Up To Date
※記事が長文になってきてしまったので詳細を省いておりますが、今回のウィジェットではこの getTimeline
メソッド内で RoyaleAPI からプレイヤーデータを取得してタイムラインを生成しております。
その他
今回特に使用しなかった機能で WidgetKit で押さえるべきいくつか機能をここでは挙げておきます。
機能 | 説明 | 補足画像 |
---|---|---|
supportedFamilies | 利用できるウィジェットのサイズを指定する。サイズは3種類。 systemSmall, systemMedium, systemLarge ※特に指定しない場合はウィジェットギャラリーに全てのサイズのウィジェットが追加できます。 |
|
widgetFamily | サポートしているウィジェットの大きさに合わせて特定の SwiftUI View を返すようにすることが出来る。 | |
widgetURL | ウィジェットのビューに特定のURLを持たせてアプリ起動時に特定の処理をさせる事が出来る機能。 systemMedium と systemLarge は ウィジェット内に複数の SwiftUI View を設けることで複数の URL を設定することが可能。 |
|
WidgetBundle | 1つの WidgetKit Extension で複数のウィジェットを定義することが出来る機能。 例) PayPay アプリだと「残高・支払い」用のウィジェットと「ボーナス運用」のウィジェットで複数のウィジェットが存在する |
ハマったポイント
シミュレータで Widget が表示されない
本来であればホーム画面から長押しタップで Widget 一覧が出るはずがシミュレータだと一向に表示されず...
シミュレータではアプリケーション本体をビルドしてもダメなようなので Widget の Extension をビルドターゲットにしてビルドしてデバッグする必要があるらしい
参考:Widgets Not Appearing in Simulator
Failed to get descriptors for extensionBundleID
結論から言うと @main
のエントリーポイントの記述漏れが原因でした。
Apple が提供している サンプルコード(※DLします) を見ながらやっていたのですが、そこにある EmojiRangerWidget.swift
には @main
のエントリーポイントが無く真似していたのが原因でした。
ただもう1つの LeaderboardWidget.swift
の方にちゃんとエントリポイントがありました...
@main
struct EmojiRangerBundle: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
EmojiRangerWidget()
LeaderboardWidget()
}
}
ただ自分の場合は1度 Clean Build した後には以下のスクショのようにクラッシュログが出て原因が分かりやすかったのですが、最初はこのログが出ずにそもそもビルドが出来ていないエラーでした
参考:Failed to get descriptors for extensionBundleID
linking against a dylib which is not safe for use in application extensions:
Widget extension (というか app-extension)で Embedded Framework を利用する際にこの Warning が出るようです。
Warning を消すには Embedded Framework の General -> DevelopmentInfo
にある Allow app extension API only
にチェックを入れる。
app-extension 側で利用される Embedded Framework は App Extension で利用できないコード(UIApplicationなど)を利用した場合にコンパイルエラーを発生して指摘してくれる。
(Warning の意味的に 「extension側で呼び出せないコードをリンクしているdylibで呼び出す可能性があるから安全じゃないよ!」みたいなニュアンスなのかな)
参考:App Extension #2 – Embedded Framework を利用して共有コードを Framework 化する
Realm や UserDefault の値が WidgetKit(App Extesion) で使えない
これも WidgetKit ではなく App Extension の話なのですが、メインプロジェクトで Realm や UserDefault を使って端末に保存しているデータを WidgetKit 側で使うには App Groups
という Capabilities を追加する必要があります。
Signing & Capabilities から App Groups
を追加する。
適当なグループ名(group.WidgetTest
)を設定する。
※画像は WidgetKitExtension 側で行っていますが、メインのターゲットでも同様に設定します。
そしたら Realm 側を以下のように修正します。
// Before
private func realm() -> Realm? {
var config = Realm.Configuration()
config.fileURL = config.fileURL!.appendingPathComponent("test_db.realm")
return try? Realm(configuration: config)
}
// After
private func realm() -> Realm? {
var config = Realm.Configuration()
// 追加
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.WidgetTest")!
config.fileURL = url.appendingPathComponent("test_db.realm")
return try? Realm(configuration: config)
}
UserDefault もほとんど同様で UserDefaults.standard.XXX
と使用していた所を UserDefaults(suiteName: "group.WidgetTest")?.XXX
と使うようにすれば OK です。
参考:【iOS14】Realmで保存したデータをWidgetに表示してみた
おわりに
WidgetKit の「あれもこれも」となってしまいかなり長文の記事になってしまいましたが、 WidgetKit の導入自体はそこまで難しくないと思います。
自分は App Extension 自体が初めてで、 XcodeGen や Embedded Framework の絡みがあってそこで時間がかかった印象です。
WidgetKit 単体だけなら楽しく開発出来ると思います!
※ちなみにアプリでのリリースはまだしていないので「画面は開発中ものになります」というやつです!
参考サイト
Creating a Widget Extension
Keeping a Widget Up To Date
【iOS14】Widget(WidgetKit) まとめ