blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2020.12.03 技術記事

iOS WidgetKitを実践解説

by hang-gao

#ios #widget-kit #swift-ui

iOS14から新機能として、ホーム画面上で表示されるWidgetが提供されました、現在様々なアプリでもWidgetを使った便利な機能を提供しています。

今回は実際に簡単なWidgetを作成する過程を通し、公式ドキュメントの内容も交えて、詳しくWidgetを作る方法を説明します!

開発のマインド

まずWidgetの開発を始める前に、どのような技術Stackが必要なのか、簡単にみてみましょう。

  • アプリ開発の基本知識
  • SwiftUIの知識
  • WidgetKitの使い方

アプリ開発の基本知識

まずWidget自体はApp Extensionの一種で、本体アプリとは独立な環境を持ちます。これはWidgetのライフサイクル、プロセス管理もアプリとは独立していることを意味します。

Widgetも本体アプリも交互のコードにアクセスできない、そのためWidgetではデータを取得際に独立したネットワーク機能やデータを保存する必要があればキャッシング機能も必要です。

SwiftUI

Widgetではそんなに複雑な画面は作らないので、基本のレイアウトに関する知識や簡単なViewの使い方がわかれば簡単に実装できます。

実際のSwiftUIの開発ではUIViewRepresentableを使ってUIKitで作ったViewを使うことができるんですが、残念ながらWidgetでは対応していません。ホーム画面に常に表示されるので、多分性能などを考慮してApple側で制限していると思われます。

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をタップすると、アプリが開いてAppDelegateopenURLが呼び出され、特定の画面へ遷移します。

  • 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記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。