【iOS14】アプリにウィジェット(WidgetKit)を導入してみた


【[watnowテックカレンダー](https://bit.ly/watnowTechCal2021)2020/2/26】

今日は[watnow](http://www.watnow.jp/)のryotaが担当します
よろしくお願いします
#　はじめに

2020年9月にiOS14がリリースされ、その追加機能の一つとしてウィジェットという機能が利用可能になりました。

Apple公式のWidget Kitについて
[Apple Introducing WidgetKit](https://developer.apple.com/widgets/)


ウィジェットはアプリの一部情報をiOSのホーム画面へ表示させられる機能です。この機能を用いればアプリを開かずにちょっとした情報を確認することができます。またウィジェットに任意の画面へのリンクを設定することもできユーザのアプリの利用率も向上させられます。
一方でWidgetKitを作成するにはswiftUIで実装する必要があり、なかなか導入しにくいと思う部分もあると思いますが、やってみると簡単に作成することができたので共有しようと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/6ea905dc-4583-fea7-de73-406379fbe7f6.png)




# Widget Kitを導入する
まずWidget Kitを導入するにあったってQ&Aです。(主に僕が思ってたことです)

* Swiftでプロジェクトを作ってるけど、SwiftUIのWidget Kitってそもそも共存できるの?
 * SwiftUIはSwift言語で使用できるUI構築フレームワークということもあり問題なく共存できます。  
   ただしWidget KitはiOS14以降のみの対応のため、それより以前のバージョンの端末では使用できない点に注意してください。

* Swiftしか触ったことないけどSwiftUIでコードを書くのはちょっと..
 * Swiftを書いたことのある方にとってSwiftUIはなんとなくであれば実装しやすい言語だと思います。swiftUIではソースコードと、iPhone画面を同時に表示するプレビュー機能(名称はCanvas)があるので簡単にViewを確認できます。Widget Kitを導入する場合でもそこまで複雑な処理やUIを実装しない限り、実装するコードは少なくてすみます。

* Widgetにメモリ制限とかはあるの?
  * Widget Kitではホーム画面に常時設置されるという特性上、厳しいメモリ制限があるようです。トータルで最大30MB以上のデータはロードできないといった報告があるようです。似たような例としてApp extensionsを実装した[こちらの記事]()によると、使用する機種によって使用できるメモリ量も増減するようです。いずれにせよ複雑なグラフの描画や、解像度の高いデータの取得などは行わない方が良さそうです。
  

  * メモリ制限とかについて  
 [10 Tips on Developing iOS 14 Widgets](https://medium.com/swlh/10-tips-on-developing-ios-14-widgets-f17b865fbdbc)

もしSwiftUIを初めて使うという方はまずは、公式のチュートリアルから始めるとわかりやすいです。

Appleが掲載している公式のSwiftUIチュートリアル
* [Introducing SwiftUI](https://developer.apple.com/tutorials/swiftui/)

上記のSwiftUIチュートリアルを日本語で解説されているページもあります
* [【第1回】日本語版SwiftUIチュートリアル【基本要素を学ぶ】](https://hirlab.net/nblog/category/programming/art_1375/)

# 導入

まずはWidgetKitのデータの取り扱い方についてはこちらの記事が非常にわかりやすくまとまっているので概要を掴むとわかりやすいです。
[【iOS14】Widget(WidgetKit) まとめ](https://qiita.com/shiz/items/309349d9cdb75084e74e)

Widget Kitを導入していきます。
プロジェクトを作成したあとに、File=>New=>Target
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/01c47bd9-28dc-ab63-a66b-36879be48937.png)
真ん中らへんにWidget Extensionがあるのでクリックし、Next
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/4d0208dd-9c5e-41bf-dd08-3f4a969b0816.png)
Product Nameを適当に決めてFinish
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/c9a03690-ff5d-5ad6-41ef-cc5737afc3e9.png)
するとProduct Name以下にこのようなファイルが出来上がっていると思います。
それぞれの役割として 

  * widgetDemo.swift 
   *  =>データやViewとかの処理をまとめて行う。追加時には時刻を表示する場合のサンプルコードが記入されている。もし独自のウェイジェットを実装していくならViewとかでファイルを分けるとわかりやすい。
  * widgetDemo.intendefinition　
   * => メインのアプリとデータを共有したりするときにここで設定する
  * Assets.xcassets 
   * => 静的な画像データとかはここにおく
  * info.plist　
   * =>　Widgetの設定プロパティ
   

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/b8ee3bcf-e3fd-9c66-1044-3040e30dccf4.png)
以下のようにWidgetExtensionのViewが表示されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/10f9c611-cbf2-3beb-e69a-5127e6f41817.png)
この状態で　アプリのビルドを行い、ホーム画面を長押し=>widgetを追加する
これでホーム画面に現在の時間が表示されるウィジェットが追加されます。時計を表示させるだけなら実はこれだけで実装となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/7a503cd0-3afc-18e0-9035-1dbea3093799.png)
Simulator Screen Shot - iPod touch (7th generation) - 2021-02-26 at 11.59.45.png



## APIからデータをとってきて表示させる

ここまでで、すでにホーム画面にWidgetを追加して、時間を表示させることができました。

ここをベースに開発していくことでアプリ側からデータを受け取って表示させたりサーバからデータを取得して表示させたりすることができます。 
では今回はAPIからデータをとってきて表示させるWidgetを開発しようと思います。

完成はこんな感じです。今回は[NewsAPI](https://newsapi.org/)からJSONで技術系のニュースデータを取得してその１つめのデータをWidgetに追加するようにします。
![Simulator Screen Shot - iPod touch (7th generation) - 2021-02-26 at 17.47.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/3e353487-1f83-dcb8-ad65-9e5c51ffde9e.png)


まずはニュースを取得するためにNewsAPIを利用するのでそのAPIKeyを取得します。News系のAPIで探してみるとこれが多く使われてるみたいです。APIKeyを取得したら以下のようにGETするとレスポンスがかえってきます。

```
http://newsapi.org/v2/top-headlines?sources=techcrunch&apiKey=APIキー
```
WidgetKitがHTTP通信を行うことができるようにinfo.plistを編集してあげる必要があります。HTTPS通信が必須化しているためHTTP通信を行うには許可する必要があります。 
 今回編集するのはアプリのinfo.plistではなく、Widgetのフォルダに作成されたinfo.plistを編集する必要があることに注意してください。  
HTTP通信の許可する方法についてはこちらが参考になります。
[iOSアプリのhttp通信を許可する方法](https://qiita.com/Howasuto/items/f8e97796c6eb30de4112)
  


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187470/c2e81be9-3781-1d7f-0844-f4bc80ebcf2e.png)

まずはCodableなモデルを作成していきます。  
JSONからモデルを作成する時は、[Convert JSON into gorgeous](https://quicktype.io/)が便利です。JSONを入力するだけでいい感じにモデルを作ってくれます。

```{Swift}
import WidgetKit
import SwiftUI
import Intents



import Foundation

// MARK: - News
struct News: Codable {
    let status: String?
    let totalResults: Int?
    let articles: [Article]?
}

// MARK: - Article
struct Article: Codable {
    let source: Source?
    let author, title, articleDescription: String?
    let url: String?
    let urlToImage: String?
    let publishedAt: String?
    let content: String?

    enum CodingKeys: String, CodingKey {
        case source, author, title
        case articleDescription = "description"
        case url, urlToImage, publishedAt, content
    }
}

// MARK: - Source
struct Source: Codable {
    let id: ID?
    let name: Name?
}

enum ID: String, Codable {
    case techcrunch = "techcrunch"
}

enum Name: String, Codable {
    case techCrunch = "TechCrunch"
}

```

次はWidgetKitのメインとなる部分です。今回は最初からあるSimpleEntryにnewsDataの項目を追加しています。取得したデータからTimelineEntryを作成し、それをもとにTimelineを構成していくイメージです。

```{Swift}


import WidgetKit
import SwiftUI
import Intents


struct Provider: IntentTimelineProvider {
    @ObservedObject var NewsStore = SessionStore()
    func placeholder(in context: Context) -> SimpleEntry {
        #Widgetが読み込み中の時に呼ばれる
        SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData:  [] as? News)
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
         #Widgetを追加しようとするときに例として表示される
         #ダミーのデータをいれるとよい
        let entry = SimpleEntry(date: Date(), configuration: configuration, newsData:  [] as? News)
        completion(entry)
    }

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        
        /// 1時間ごとに更新する
        let refresh = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
        NewsStore.fetch{ newData in
            entries.append(SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData: newData))
            
            let timeline = Timeline(entries: entries, policy: .after(refresh))
            completion(timeline)
        }
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationIntent
    let newsData:News?
   
}

struct newsWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        
        VStack{
            URLImageView(url: URL(string: entry.newsData?.articles?[0].urlToImage ?? "")!)
                .aspectRatio(contentMode: .fill)
                Text(entry.newsData?.articles?[0].title ?? "")
                    .foregroundColor(.black)
                    .font(.headline)
                    .padding(8)
        } 
    }
}

@main
struct newsWidget: Widget {
    let kind: String = "newsWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            newsWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("News Widget")
        .description("News Widgetの説明")
        #大きなwidgetのみ対応する
        .supportedFamilies([.systemLarge])
    }
}

struct newsWidget_Previews: PreviewProvider {
    static var previews: some View {
        newsWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData: [] as? News))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

```

データを取得するクラスはこんな感じで実装しています。

```{Swift}
import Foundation
import Combine

final class SessionStore: ObservableObject {
    @Published  var current : News?
    init(){
        self.fetch{ val in
            print(val)
        }
    }
}
extension SessionStore{
    func fetch(completion : @escaping(News)->()){
        NewsClient.fetchSummary {
            self.current = $0
            completion($0)
        }
    }
}

```

最後にURLから画像を読み込む処理についてです。
Swiftで非同期で画像を取得するときはKing Fisherとかが便利なんですが、今回はSwiftUIで実装するということもあり処理を別途書く必要があります。



```{Swift}
import SwiftUI
struct URLImageView: View {
    let url: URL
    @ViewBuilder
    var body: some View {
        if let data = try? Data(contentsOf: url), let uiImage = UIImage(data: data) {
            Image(uiImage: uiImage)
                .resizable()

        } else {
            Image(systemName: "photo")
                .resizable()
        }
    }
}
```

以上で、Widgetの実装は終わりになります。
これでWidgetを追加すると最新のニュースが表示されます。


今回使用したサンプルプロジェクトは以下になります。
https://github.com/ryota2425/widgetExample

# 最後に

WidgetKitはSwiftUIでの実装で難しそうに見えますが、ぜひチャレンジしてみてください。
今回はAPIの情報を表示させるだけでしたが、これを発展させてクリックした場にアプリで記事を表示させるとより実用的になっていくかと思います!