iOS14 Widget機能を実装してみる

今回の記事では、iOS14のWidget機能について、紹介、実装していこうと思います。

環境
OS:Catlina 10.15.4
Xcode:12.2 beta2

##1、Widgetとは
Appleは`iOS8`からホーム画面から左にスワイプすると出てくる通知センターにカレンダーやタイマーといったようなシンプルな機能を持ったアプリをアクセス出来るようなAPIを提供してきたが、`iOS14`ではホーム画面にもその表示ができるようになりました。機能がさらに増加され、表示内容もタイムラインに沿って更新できます。

**!重要:**`Widget`機能は`SwiftUI`でしか実装できないので、`Widget`の本質はタイムラインによって変わる`SwiftUI`ビューと言っても良い。

##2、実装順番

###Widget Extension追加
>`SwiftUI`でマルチプラットホームのプロジェクトを新たに作成したら、`Widget Extension`を追加します。
ちなみに、1つのアプリで複数の`Widget Extension`を追加することができます。

Xcode →File →New →Target →Widget Extension `widgetDemoW target`の追加に成功したら、タイムviewがデフォルトで表示されます。

![スクリーンショット 2020-10-17 14.42.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/5a3007f2-1019-d311-5535-6b25d586851a.png)
![スクリーンショット 2020-10-17 14.45.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/72290714-4b9c-3211-1033-0ba77071c146.png)

###Widgetの入り口
widgetDemoW.swiftクラスの中、`@main`マークは`Widget`の入り口です。

```swift
struct widgetDemoWEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

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

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            widgetDemoWEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemMedium])
    }
}
```
- `configurationDisplayName`はユーザがアプリの`Widget`を追加、編集する時の`Widget`の表示名(１つのアプリに複数の`Widget`の存在もあり得るため)
- `description`は`Widget`の説明
- `supportedFamilies`はサーポットサイズでデフォルトでは大、中、小を全てサーポット。

**provider**は、`Widget`を更新するためのタイムラインを決定するオブジェクトです。 `Widget`を更新するための将来の時間を提供することで、システムは更新プロセスを最適化できます。
widgetDemoWEntryViewの**EntryView**は実際に表示するViewです。

<img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/afe2a0b4-72b6-a8e7-88b9-7f6d598e63e1.png"><img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/4689cec4-3b5b-6828-1225-10231b5045ee.png"><img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/ae3c9659-81b8-40af-e81f-1979b51d8846.png">
`Widget`をクリックしたら本アプリを起動できます。`Widget`を長押すと編集に入ります。

##3、Widgetの表示内容をカスタマイズする
`Widget`の更新は`WidgetCenter`によって完全に制御されます。 開発者は、APIを介して`Widget`ビューをアクティブに更新することはできません。タイムラインを更新する必要があることをWidgetCenterに通知することしかできません。
システム上は、タイムラインのリロードを駆動する2つの方法を提供します。 `System Reloads`と`App-Driven Reloads`。

**注意:**タイムラインのリロードは`Widget`を直接更新しませんが、`WidgetCenter`はデータの次のステージのために`Widget`を再要求します。 タイムラインの`Provider`は、このデータを提供するオブジェクトです。

タイムラインの`Provider`によって提供されるデータには2つの部分があります。1つは`TimelineEntry`で、もう1つは`ReloadPolicy`です。

`TimelineEntry`は、`Widget`が特定のタイムノードの下に表示する必要があるビュー情報と時点です。
`ReloadPolicy`は、次の期間中のタイムラインの更新ポリシーです。3つのタイプがあります。

>**atEnd**：最後のタイムスライスに達したときにタイムラインが更新されることを示します。
**atAfter**：一定時間後の定期的な更新を指します。
**never**：それは将来更新する必要がないことを意味します。 最後の`Entry`が表示されたら、更新せず最後の`Entry`に止まります。


アプリの起動だけでなく、`Widget`には異なるページへ遷移する複数のボタンが含まれることも可能です（中/大サイズの`Widget`のみ）。

###実装例1
>静的表示

```swift
struct widgetDemoWEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        HStack {
            Text("今回の記事では、iOS14のWidget機能について、紹介、実装していこうと思います。 -20201017")
                .foregroundColor(.black)
            VStack {
                Image("widgetPic")
                    .overlay(RoundedRectangle(cornerRadius: 10)
                                .stroke(Color.purple, lineWidth: 3))
                    .shadow(radius: 10)
                    .cornerRadius(10.0)
                Text(entry.date, style: .time)
            }
            .padding()
        }
        .padding()
    }
}
```

####実際の表示
![スクリーンショット 2020-10-17 23.51.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/fec1fe52-49ff-dabf-0e13-223216af9629.png)

###実装例2
>タイムラインに沿って表示する

```swift
import WidgetKit
import SwiftUI
import Intents

struct Provider: IntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), text: "朋遠方より来たるあり。また楽しからずや。", configuration: ConfigurationIntent())
    }

    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), text: "朋遠方より来たるあり。また楽しからずや。", configuration: configuration)
        completion(entry)
    }

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // 現在の日付から開始して、1時間間隔で60エントリで構成されるタイムラインを生成する
        let currentDate = Date()
        var textArray = [String]()
        for i in 0..<60 {
            textArray.append("朋遠方より来たるあり。また楽しからずや。 timeline: \(i)")
        }
        for minOffset in 0 ..< 60 {
            let entryDate = Calendar.current.date(byAdding: .minute, value: minOffset, to: currentDate)!
            let entryText = textArray[minOffset]
            let entry = SimpleEntry(date: entryDate, text: entryText, configuration: configuration)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let text: String
    let configuration: ConfigurationIntent
}

struct widgetDemoWEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        HStack {
            Text(entry.text)
                .foregroundColor(.black)
            VStack {
                Image("widgetPic")
                    .overlay(RoundedRectangle(cornerRadius: 10)
                                .stroke(Color.purple, lineWidth: 3))
                    .shadow(radius: 10)
                    .cornerRadius(10.0)
                Text(entry.date, style: .time)
            }
            .padding()
        }
        //アプリ側ではonOpenURLを通じてURLをハンドリングし、Widgetをタップすれば特定の画面へ遷移するアクションを実現
        .widgetURL(URL(string: "widgetdemo://go2secondview"))
        .padding()
    }
}

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

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            widgetDemoWEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemMedium])
    }
}

struct widgetDemoW_Previews: PreviewProvider {
    static var previews: some View {
        widgetDemoWEntryView(entry: SimpleEntry(date: Date(), text: "朋遠方より来たるあり。また楽しからずや。", configuration: ConfigurationIntent()))
            .previewContext(WidgetPreviewContext(family: .systemMedium))
    }
}


```

悟空アイコンが静的に表示し、論語のコンテンツとタイムを1分単位で更新します。

####実際の表示
<img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/5364da9d-57d5-00a4-5294-739c08d88e54.png"><img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/84e18070-2f8a-920a-f91b-5d8f24f695e4.png"><img width="355" alt="2020-10-17 16.39.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678085/ffb78d2e-78f0-0319-8e9a-da96a5f3ca82.png">

##4、終わり
`WidgetKit`の発想はすごく良いですが、OSのパフォーマンスと電力消費の考慮で、`Widget`はビデオと動的画像の表示ができません。但し、うまく利用すればアプリの品質向上に繋がるには間違いないと思うので、もっと優秀な`Widget`が出るのを期待したいです！