iPhone 14 ProのDynamic Islandにウィジェットを追加、Live Activityライブ・アクティビティを開始（iOS16.1以降）ActivityKit

今回は、iPhone 14 Proの新エリア「Dynamic Island」にウィジェットを追加する方法についてご紹介します。また、iOS 16.1以上のデバイスで利用可能なライブ・アクティビティを追加する方法についても説明します。

![compact-format.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/2d217bd8-e3e0-582e-1f0a-73c4639eb5ab.png)

## 利用可能な環境

iOS 16のリリースでは。ライブ・アクティビティは除外されています。

ライブアクティビティ機能は、パブリックベータテストプログラムで一般公開されているiOS 16.1でも利用可能です。

ライブアクティビティは、ロック画面に表示されます。
iPhone 14 Proでは、Dynamic Islandエリアにもライブアクティビティが表示されます。

ライブ・アクティビティはiPhoneのみです。

## Dynamic Islandの形態

Dynamic Islandには、いくつかの形態があります。例えば、基本情報のみを表示するコンパクト(compact)な形態もあります。

### コンパクト(compact)

![スクリーンショット 2022-09-16 13.05.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/ca7c34c9-89a6-7666-d2bc-38b9ec40742e.png)

コンパクトな形状では、左側(leading)と右側(trailing)に領域があります。フレーム幅の目安はCGFloatで50単位程度です。

### 拡大表示

Dynamic Islandを長押しすると展開される、エクスパンデッド(expanded)フォームもあります。

![スクリーンショット 2022-09-16 13.42.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/97fa2f55-3bef-b632-7621-0b4af78d1ba1.png)

### ミニフォーム

2つのウィジェットを同時に表示する場合、ミニ(minimal)サイズもあります（その場合、両方ともミニサイズを使用します）。

![スクリーンショット 2022-09-16 13.13.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/53bad793-44c9-d921-338b-13e629fe4765.png)

### ロック画面のライブ活動

ライブアクティビティを有効にすることで、iOS 16.1以降を搭載する他のすべてのデバイスでも、ロック画面にライブアクティビティを表示できるようになります。

![Simulator Screen Shot - iPhone 14 Pro - 2022-09-16 at 12.54.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/f8ba6aef-db58-b815-0e78-058fc8862749.png)

さっそくこの機能を実装してみましょう。

## ライブアクティビティの有効化

まず、iOS アプリに widget 拡張ターゲットが作成されていることを確認します。

それから、最小deployment targetを iOS 16.1 に設定します。もし、あなたのコードを古い iOS デバイスでも動作させたい場合は、コード内で `if #available(iOS 16.1, *)` ステートメントも使用することができます。

メインの iOS アプリとウィジェット拡張の両方で、`Info.plist` ファイル内の `NSSupportsLiveActivities` を `YES` に設定し、
アプリがライブアクティビティをサポートしていることを示します。

## ライブアクティビティの属性を作成する

ライブ活動を開始するには、`ActivityAttributes`属性が必要です。

アクティビティ属性には2つの部分があります。
静的変数は、ライブ・アクティビティの間、同じ状態を維持するデータです。
また、コンテンツ (ContentState) も含まれており、セッション中に更新される可能性があります。

例えば、スポーツスコアを表示するウィジェットでは、チーム名や選手名は静的で、スコアは動的（状態）であるべきです。

```swift
import Foundation
import ActivityKit

struct TripAppAttributes: ActivityAttributes {
    
    enum TripStatus: String {
        case predeparture
        case inflight
        case landed
    }

    public struct ContentState: Codable, Hashable {
        var tripStatus: String
        var arrivalTime: Date
    }
    
    var shipNumber: String
    var departureTime: Date
    var userStopPlanetName: String
    var userCabinNumber: String

}
```

ここで、上記の船舶宇宙旅行の例では、船舶番号、出発時刻、目的地、ユーザーの室番号は固定されており、変化することはない。

しかし、旅行状況や到着時刻は変化する。したがって、それらを `ContentState` という別の構造体に格納する。

## ライブアクティビティの設定を作成する

ウィジェットコードで、`Widget` に適合する構造体で、
静的なウィジェットの設定を削除し、新しい `ActivityConfiguration` を追加します。
これは、ライブアクティビティの設定を表します。

```diff
@main
struct LiveActivitiesTestWidget: Widget {
    let kind: String = "LiveActivitiesTestWidget"

    var body: some WidgetConfiguration {
+        ActivityConfiguration(for: TripAppAttributes.self) { context in
+            LiveActivitiesTestWidgetEntryView(attribute: context.attributes,
+                                              state: context.state)
+        } dynamicIsland: { context in
+            // ToDo
+        }
    }
}
```

`ActivityConfiguration` の初期化関数に、属性を格納する構造体の名前（例えば、`TripAppAttributes`）を入力します。

アクティビティ設定のブロック (`ActivityConfiguration(for: TripAppAttributes.self) { context in`) の中で、ライブアクティビティのビューを定義します。
これは、ライブアクティビティの実行時に、ロック画面の下に表示されるビューです。

`dynamicIsland`ブロック (`dynamicIsland: { context in`) 内では、次のセクションで紹介するのDynamic Islandビューを定義します。

## ライブアクティビティの SwiftUI ビューを作成する

`ActivityConfiguration`の最初のブロック内で、ロック画面の下に表示するビューを定義します。

![Simulator Screen Shot - iPhone 14 Pro - 2022-09-16 at 12.54.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/0b5ca8c0-d576-400e-67e9-0ebf698db60a.png)

```swift
struct LiveActivitiesTestWidgetEntryView : View {
    @State var attribute: TripAppAttributes
    @State var state: TripAppAttributes.ContentState

    var body: some View {
        VStack(alignment: .center) {
            HStack {
                Label("Ship number", systemImage: "moon.stars")
                Spacer()
                Text(attribute.shipNumber)
            }
            HStack {
                Label("Your stop", systemImage: "lanyardcard")
                Spacer()
                Text(attribute.userStopPlanetName)
            }
            switch TripAppAttributes.TripStatus(rawValue: state.tripStatus) {
            case .predeparture:
                HStack {
                    Label("Your cabin", systemImage: "person.fill")
                    Spacer()
                    Text(attribute.userCabinNumber)
                        .font(.title3.bold())
                }
            case .inflight:
                Label("Currently in trip", systemImage: "clock")
            case .landed:
                Label("Landed", systemImage: "checkmark.circle.fill")
                    .foregroundColor(.green)
                    .font(.title3)
                Text("Thanks for traveling with us!")
                    .font(.headline)
            default:
                Text("Unknown trip status")
            }
            
        }
        .padding()
    }
}
```

## Dynamic Islandダイナミックアイランドのビューを作成する

次に、Dynamic Island用のビューを `dynamicIsland` ブロック内に作成する必要があります。
上で紹介したの各コンポーネントに対して、すべてのビューを定義する必要があります。

まず、パラメータを追加します。

```diff
@main
struct LiveActivitiesTestWidget: Widget {
    let kind: String = "LiveActivitiesTestWidget"

    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TripAppAttributes.self) { context in
            LiveActivitiesTestWidgetEntryView(attribute: context.attributes,
                                              state: context.state)
        } dynamicIsland: { context in
+            DynamicIsland {
+                DynamicIslandExpandedRegion(.leading) {
+                    // TODO
+                }
+                DynamicIslandExpandedRegion(.trailing) {
+                    // TODO
+                }
+                DynamicIslandExpandedRegion(.center) {
+                    // TODO
+                }
+                DynamicIslandExpandedRegion(.bottom) {
+                    // TODO
+                }
+            } compactLeading: {
+                // TODO
+            } compactTrailing: {
+                // TODO
+            } minimal: {
+                // TODO
+            }
        }
    }
}

```

`DynamicIslandExpandedRegion(.leading)` は、Dynamic Islandが拡張されたとき（ユーザーがウィジェットを長押ししたとき）の領域を表します。

![スクリーンショット 2022-09-16 13.42.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/97fa2f55-3bef-b632-7621-0b4af78d1ba1.png)

```diff
DynamicIslandExpandedRegion(.leading) {
+    Text("🚀")
}
DynamicIslandExpandedRegion(.trailing) {
+    Text(context.state.arrivalTime, style: .timer)
+        .font(.caption2)
}
DynamicIslandExpandedRegion(.center) {
+    Text("次の目的地は\(context.state.userStopPlanetName)です。")
}
DynamicIslandExpandedRegion(.bottom) {
+    Button("宇宙機アクセスバッジ") {
+        return
+    }.buttonStyle(.borderedProminent)
}
```

通常、左 (leading) 右 (trailing) のエリアにはごく短いテキスト、
中央のエリアには1文程度のテキスト説明（またはスコアボード）、
下部にはユーザーがクリックするためのボタンを配置することができます。

そして、コンパクトサイズに対応したビューを提供します。

![スクリーンショット 2022-09-16 13.05.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/ca7c34c9-89a6-7666-d2bc-38b9ec40742e.png)

```diff
compactLeading: {
+    Text("🚀 - \(context.attributes.shipNumber)")
} compactTrailing: {
+    Text(context.state.arrivalTime, style: .relative)
+        .frame(width: 50)
+        .monospacedDigit()
+        .font(.caption2)
}

```

通常、左側 (leading) にはグラフと非常に短いテキスト（タイマーやイベント名など）、
右側 (trailing) にはアニメーション（タイマーやシェイプのアニメーション）が含まれる

最後に、ミニフォーマットで。

![スクリーンショット 2022-09-16 13.13.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/53bad793-44c9-d921-338b-13e629fe4765.png)


```diff
minimal: {
    Text("🚀")
}
```

このエリアは非常に小さいので、ユーザーがタップしてアプリを開いたり、長押ししてウィジェットを展開できるように、アイコン（または円形の進捗表示）を表示することができます。

なお、Dynamic Islandダイナミックアイランドに2つ以上のウィジェットが表示されている場合、すべてのウィジェットが最小サイズで表示されることに注意してください。

## ライブアクティビティを開始する

メインの iOS アプリでライブアクティビティを作成することができます。

まず、属性を含むオブジェクトを初期化します。
これらは、ライブアクティビティのセッション中、変わらない値（static）です。

```swift
let attributes = TripAppAttributes(shipNumber: "火星へ", ... )
```

次に、アクティビティの初期状態 (state) を設定します。
状態（動的な値 dynamic）は、ライブ・アクティビティ・セッション中に変更することができます。

```swift
let contentState = TripAppAttributes.ContentState(tripStatus: TripAppAttributes.TripStatus.inflight.rawValue,
                                                  arrivalTime: Calendar.current.date(byAdding: .minute, value: 8, to: Date()) ?? Date())
```

ライブ活動の開始をリクエストすることができます。

```swift
do {
    self.currentActivity = try Activity<TripAppAttributes>.request(
        attributes: attributes,
        contentState: contentState,
        pushType: nil)
} catch (let error) {
    print(error.localizedDescription)
}
```

`Activity` オブジェクトの `.activites` を呼び出すことで、いつでもアクティビティを取得することができます。

```swift
let activities = Activity<TripAppAttributes>.activities
```

ユーザーは、アプリからライブアクティビティの機能をオフにすることもできることに注意してください。
デフォルトでは、すべてのアプリはライブアクティビティを表示することができます。
権限設定を確認することができます。

```swift
let isEnabled = ActivityAuthorizationInfo().areActivitiesEnabled
```

## 時間制限

Appleのドキュメントによると

- ライブアクティビティは、アプリまたはユーザーが終了させない限り、最大8時間までアクティブにすることができます。
- この制限を過ぎると、自動的に終了します。
- 終了すると、システムは直ちにDynamic Islandからそのライブアクティビティを削除します。
- 終了後、ユーザーがライブアクティビティを削除するまで、またはシステムが削除するまでの最大4時間、ロック画面に残ります（いずれか早い方）。
- その結果、ライブアクティビティは最大12時間ロック画面に表示されます。

## 既存のライブ活動を更新する

### ローカルで

iOSのメインアプリの中から、既存のライブアクティビティを更新することができます。
更新するには、新しいステート (state) オブジェクトを作成し、それを既存のライブアクティビティに更新します。

```swift
Button("Trip arrival time +10 minutes") {
    Task {
        guard let currentActivity else { return }
        let updatedState = TripAppAttributes.ContentState(tripStatus: TripAppAttributes.TripStatus.inflight.rawValue,
                                                          arrivalTime: Calendar.current.date(byAdding: .minute, value: 10, to: currentActivity.contentState.arrivalTime) ?? Date())
        await currentActivity.update(using: updatedState)
    }
}
```

### プッシュ通知による更新

ライブ活動では、フライト情報やスポーツ情報など、実時間のデータが表示されることがほとんどです。
この場合、サーバーからiOSデバイスにプッシュ通知することで、ライブアクティビティーを更新することができます。

まず、`self.currentActivity?.pushToken`を使用してライブアクティビティを識別するために使用されるトークンを取得します。そのプッシュトークンをサーバーに送信します。

サーバーで、

まず、最新の状態を `content-state` 内で JSON を使ってエンコードします。
`content-state` のフォーマットは Swift の構造体のフォーマットと一致しなければならないことに注意してください。

`apns-push-type` は `liveactivity` に設定します。

`apns-topic` には、バンドル ID を指定して、 `[Bundle ID].push-type.liveactivity` と設定します。

ライブアクティビティを更新したい場合は、 `event` を `update` に設定します。
ライブアクティビティを終了したい場合は、 `event` を `end` に設定します。

プッシュトークン (pushToken) が変更される可能性があることに注意してください。
プッシュトークンの変更を知るために、 `self.currentActivity?.pushTokenUpdates` を使用する必要があります。トークンが変更された場合は、サーバーに通知する必要があります。

## ライブ活動を終了する

ライブ・アクティビティを終了するには、終了状態を指定し、ウィジェットがいつ閉じるべきかをシステムに知らせます。

ウィジェットはすぐに閉じることができます。しかし、ほとんどの場合、ウィジェットは最適な時間または特定の時間にシステムによって閉じることができます（たとえば、ユーザーはスポーツイベントが終了した後もスコアを表示したい場合があります）。

```swift
Button("End activity") {
    Task {
        guard let currentActivity else { return }
        let updatedState = TripAppAttributes.ContentState(tripStatus: TripAppAttributes.TripStatus.landed.rawValue,
                                                          arrivalTime: Date())
        await currentActivity.end(using: updatedState, dismissalPolicy: .default)
    }
}
```

# Github repo

https://github.com/mszpro/iOS16.1-LiveActivities-DynamicIsland-Demo

---


お読みいただきありがとうございました。

[☺️ Twitter @MszPro](https://twitter.com/MszPro)
[🐘 Mastodon @me@mszpro.com](https://sns.mszpro.com/@me)

<img width="150" src="https://cdn.mszmagic.com/static-web-content/AppClipImage_small.png">

---
![writing-quickly_emoji_400.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/640938e9-c121-8152-b8c4-8ab63124afcc.png)

Written by MszPro~

---

# 関連記事

・[UICollectionViewの行セル、ヘッダー、フッター、またはUITableView内でSwiftUIビューを使用（iOS 16, UIHostingConfiguration）](https://qiita.com/MaShunzhe/items/3d98fa897aa1bcffc74b#github-repository)

・[iPhone 14 ProのDynamic Islandにウィジェットを追加し、Live Activitiesを開始する（iOS16.1以降）](https://qiita.com/MaShunzhe/items/6cc8d9d204f40cbee19c)

・[iOS 16：秘密値の保存、FaceID認証に基づく個人情報の表示/非表示（LARight）](https://qiita.com/MaShunzhe/items/07c1e65277a5008106db)

・[iOS16 MapKitの新機能 : 地図から場所を選ぶ、通りを見回す、検索補完](https://qiita.com/MaShunzhe/items/693e6302876707d5684d)

・[SwiftUIアプリでバックグラウンドタスクの実行(ネットワーク、プッシュ通知) (BackgroundTasks, URLSession)](https://qiita.com/MaShunzhe/items/4e0d1032748fe4f398de)

・[WWDC22、iOS16：iOSアプリに画像からテキストを選択する機能を追加（VisionKit）](https://qiita.com/MaShunzhe/items/ed3985d05975bf35292d)

・[WWDC22、iOS16：数行のコードで作成できるSwiftUIの新機能（26本）](https://qiita.com/MaShunzhe/items/0ed36d8a6de7c56b1d34)

・[WWDC22、iOS 16：SwiftUIでChartsフレームワークを使ってチャートを作成する](https://qiita.com/MaShunzhe/items/cae62af8b07f4f474fd8)

・[WWDC22, iOS 16: WeatherKitで気象データを取得](https://qiita.com/MaShunzhe/items/dce45ccdd05c1f70cf54)

・[WWDC 2022の基調講演のまとめ記事](https://qiita.com/MaShunzhe/items/037580d2ed51a5a6e9d9)