17
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

and factoryAdvent Calendar 2020

Day 15

【Swift】既存アプリケーションに WidgetKit を追加するまでの軌跡

Last updated at Posted at 2020-12-14

この記事は and factory Advent Calendar 2020 の15日目の記事です。
昨日は @MatsuNaoPen さんの GASを使ったwrikeのwebhook でした。

はじめに

Apple が今年の WWDC2020 で WidgetKit を発表して早半年。
瞬く間に iOS エンジニア内で WidgetKit の知見が共有されました。

そこで今回は個人開発しているアプリケーションに WidgetKit を追加するまでの軌跡を記事にしました。

個人アプリの紹介

クラッシュ・ロワイヤル(以下クラロワと呼びます)というゲームのツールアプリを現在開発しています。
主な機能としては以下3つ。

トロフィー(強さを示す数値)の期間毎の推移 デッキを作成してクラロワ本体のアプリにコピー機能 RoyaleAPIというウェブを閲覧できる機能

そこで今回はウィジェットと相性が良さそうな下記機能でウィジェットを作ってみることにしました。

トロフィー(強さを示す数値)の期間毎の推移

この機能を選んだ理由としてクラロワをプレイしたあとにアプリを起動せずともホームに置いてあるウィジェットにその内容が反映されたら便利だと思ったためです。

【完成図】

直近の勝敗とトロフィー数がどれだけ変わったのか、あとは現在のトロフィー数と最後に更新された時間を載せたシンプルなウィジェットになっています。

実装

1. Widget Extension を追加する

スクリーンショット 2020-12-13 18.09.29.png
Xcode の File -> New -> Target を選択。

スクリーンショット 2020-12-13 18.09.39.png

Widget と検索して Widget Extension を追加します。

スクリーンショット 2020-12-13 18.36.12.png
Extension 作成後に出てくる上記は Activate を選択します。

スクリーンショット 2020-12-13 18.38.22.png

そうすると上記のようにスキーマに TodaysWidgetExtension になっていて左のプロジェクトフォルダに TodaysWidget があれば作成成功です。
この時点でビルドすると現在時刻だけが表示されるウィジェットがシミュレータ上で確認できます!(すごく簡単!)

2. Widget のソースコードに手を加える

先ほどのスクショでも挙げたように基本となるソースコードは Extension 以下にある1つのファイルです。
(今回だとTodaysWidget.swift)
なのでそのソースコードを見ていきます

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 を使ってビューを作っていく
        })
    }
}
TodaysResultInfo
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
//
//  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))
    }
}
TodaysWidget.swift
struct TodaysWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        TodaysResultView(entry: entry) // ここを変更
    }
}

ここまでやると Preview 画面でこうなります。
スクリーンショット 2020-12-13 20.06.12.png

2.3 Timeline 部分

最後に時間毎にどのようなデータを生成するかの Provider 部分を見ていきます

TodaysWidget.swift
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)
    }
}
メソッド 自分の解釈 補足画像
func placeholder ウィジェットが初めて表示される際に扱われるビューと Apple のドキュメントに有りました。
プレースホルダーのビューはウィジェットの一般的な見た目をユーザに提供して、このウィジェットがどういうウィジェットかを示すのに使われるそうです。
(が、正直ここはよく理解できていません...)

ビューの部分がプレースホルダー(モザイク)がかかった状態で表示されている
func getSnapshot ウィジェットを追加する際のギャラリー(ウィジェットギャラリー)でプレビューとして表示されるデータを返すメソッドとなります。
ここではユーザに素早くプレビューのスナップショットを見せることが大事だとドキュメントにあったので、何か重たい処理をして Entry を生成するような事はしない方が良いかもしれません。

ウィジェット追加する際のウィジェットギャラリーでの見た目を定義出来る
func getTimeline getSnapshot が最初に呼ばれた後にこの 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 の方にちゃんとエントリポイントがありました...

LeaderboardWidget.swift
@main
struct EmojiRangerBundle: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        EmojiRangerWidget()
        LeaderboardWidget()
    }
}

ただ自分の場合は1度 Clean Build した後には以下のスクショのようにクラッシュログが出て原因が分かりやすかったのですが、最初はこのログが出ずにそもそもビルドが出来ていないエラーでした

スクリーンショット 2020-12-09 23.02.52.png

参考: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 にチェックを入れる。

スクリーンショット 2020-12-10 0.59.27.png

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 側を以下のように修正します。

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) まとめ

17
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?