【iOS16.1】ActivityKitを触ってみた 〜Dynamic Island編〜

# 1. 概要
　前回の記事([【iOS16.1】ActivityKitを触ってみた 〜Live Activity編〜](https://qiita.com/Cychow/items/f82239979f47260937a9))では、Live ActivityやDynamic Islandとはどういったものなのかについて、またロック画面内でのLive Activityの実装方法に焦点をあてました。今回は、ActivityKitを用いて、Dynamic IslandのLive ActivityのUIを構築するための方法について書いていきたいと思います。
![スクリーンショット 2022-10-15 18.10.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/7124804a-7577-fa33-324b-faa86abe4f20.png)

# 2. ActivityKitとDynamic Island
　前回の記事でも記載しましたが、ActivityKitはApple公式のドキュメントで以下のように記載されています。

> With the ActivityKit framework, you can start a Live Activity to share live updates from your app in the Dynamic Island and on the Lock Screen. For example, a sports app might allow a person to start a Live Activity that makes live information available at a glance for the duration of a game. 

> ActivityKitフレームワークを使用すると、ライブアクティビティを開始して、ダイナミックアイランドとロック画面でアプリからの直接的なアップデートを共有できます。たとえば、スポーツアプリでは、ライブアクティビティを開始して、試合中にライブ情報を一目で確認できるようにすることができます。

![スクリーンショット 2022-10-26 9.10.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/0e74569d-8ee3-6ade-4bd0-86ba04873168.png)
(出典: [Apple Developer: ActivityKit](https://developer.apple.com/documentation/activitykit))

　また、ActivityKitを通して、Live ActivityやDynamic Islandを操作しようとすると、SwiftUIとWidgetKitを用いる必要があるとも述べられています。つまり、Live ActivityあるいはDynamic Islandをアプリ内に導入しようとすると、少なからずSwiftUIの導入は必要となります。

# 3. Dynamic Islandの実装方法

### 3-0. 開発環境
- Macbook Air M1, 2020 (macOS Ventura 13.0)
- Version 14.1 RC (14B47b)

### 3-1. SwiftUIのプロジェクトを作成し、File > New > Targetで、Widget Extensionを選択する
TargetからWidget Extensionを追加することができる。Xcode14.1 beta版では自力でLiveActivityやDynamicIslandを実装するコードを書かないといけなかったが、RC版からはExtension生成時に、LiveActivityを追加するかのチェックが登場し、テンプレートを用意してくれるようになっている。

![スクリーンショット 2022-10-27 21.52.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/527b78de-00ef-2362-c009-0c5df011f58c.png)

![スクリーンショット 2022-10-28 13.39.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/4bca222d-e684-464c-e2d6-ebc0fff5763e.png)

### 3-2. info.plistファイルで「NSSupportsLiveActivities」をBooleanとして追加し、ValueをYesにする。

![スクリーンショット 2022-10-27 21.56.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/eb0270c1-f7a3-d7e0-6718-06033e8b3a34.png)

### 3-3. Widget Extension側のswiftファイルにActivityConfigurationを呼び出す。

下記のコードのdynamicIsland内にて、Dynamic IslandのUIを構築することができる。

```swift:WeatherStatusLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI

struct WeatherStatusAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // コンテンツの状態が更新されるとき、ライブアクティビティのビューを更新する
        var status: Status = .sunny
    }

    // その他の変数
    var temperature: Double
    var weather: String
}

// MARK: Weather Status
enum Status: String, CaseIterable, Codable, Equatable{
    // SFSymbolの画像を設定
    case sunny = "sun.max.fill"
    case rainy = "cloud.rain.fill"
    case cloudy = "cloud.fill"
}

struct WeatherStatusLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: WeatherStatusAttributes.self) { context in
            // ここではLive ActivityのViewを設定する         
        } dynamicIsland: { context in
            // iPhone14Pro/Pro Max向けのDynamic Islandをここで実装することができる
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                }
                DynamicIslandExpandedRegion(.trailing) {
                }
                DynamicIslandExpandedRegion(.center) {
                }
                DynamicIslandExpandedRegion(.bottom) {
                }
            } compactLeading: {
            } compactTrailing: {
            } minimal: {
            }
        }
    }
}

```

ここで、DynamicIsland内の位置関係について見ていきたいと思います。
上記のコードをみていると、DynamicIslandExpandedRegionで.leadingや.trailing、.center、.bottomと指定されていたり、compactLeadingや、compactTrailing、minimalなどが書かれています。
まず、DynamicIslandExpandedRegionはDynamicIsland展開時の位置関係を表しています。
Apple公式のドキュメントでは以下のように書かれていました。

> ・ **center** places content below the TrueDepth camera.
・ **leading** places content along the leading edge of the expanded Live Activity next to the TrueDepth camera and wraps additional content below it.
・ **trailing** places content along the trailing edge of the expanded Live Activity next to the TrueDepth camera and wraps additional content below it.
・ **bottom** places content below the leading, trailing, and center content.

> ・ **center**は、TrueDepth カメラの下にコンテンツを配置します。
・ **leading**は、TrueDepthカメラの横に展開されたLive Activityの前縁に沿ってコンテンツを配置し、その下に追加のコンテンツを包み込みます。
・ **trailing**は、TrueDepthカメラの横に展開されたLive Activityの後縁に沿ってコンテンツを配置し、その下に追加のコンテンツを包み込みます。
・ **bottom**は、leading、trailing、およびcenterのコンテンツの下にコンテンツを配置します。

![スクリーンショット 2022-12-06 17.07.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/0492b4f5-762b-1ad8-8d7e-608eedc6037a.png)
(出典： [Apple Developer: Displaying live data with Live Activities](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities))

次に、compactLeadingや、compactTrailing、minimalについてみていきます。
まずは、compactLeadingや、compactTrailingについてです。
以下のスクショのように、TrueDepthカメラの左部分の領域がcompactLeadingとなります。一方で、TrueDepthカメラの右部分の領域がcompactTrailingになります。
![スクリーンショット 2022-12-06 18.14.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/803da9fd-5001-4204-0ac0-1feb37268f52.png)
続いて、minimalについてです。
minimalは複数のLive Activityが存在するときにTrueDepthカメラの右側部分に表示される、円形の領域です。
![スクリーンショット 2022-12-06 18.22.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/0759cb74-b1eb-4c08-ef10-92dc8def9b9f.png)

上記の内容をまとめると以下のようなコードとなる。

```swift:WeatherStatusLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI

// (中略)

struct WeatherStatusLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: WeatherStatusAttributes.self) { context in
            // ここではLive ActivityのViewを設定する         
        } dynamicIsland: { context in
            // iPhone14Pro/Pro Max向けのDynamic Islandをここで実装することができる
            DynamicIsland {
                // Dynamic IslandのLive Activity展開時のUIを構築する
                DynamicIslandExpandedRegion(.leading) {
                    // TrueDepthカメラの下にコンテンツを配置する
                      
                }
                DynamicIslandExpandedRegion(.trailing) {
                    // TrueDepthカメラの横に展開されたLiveActivityの前縁に沿ってコンテンツを配置し、その下に追加のコンテンツを包み込む
                    
                }
                DynamicIslandExpandedRegion(.center) {
                    // TrueDepthカメラの横に展開されたLive Activityの後縁に沿ってコンテンツを配置し、その下に追加のコンテンツを包み込む

                }
                DynamicIslandExpandedRegion(.bottom) {
                    // leading、trailing、およびcenterのコンテンツの下にコンテンツを配置する

                }
            } compactLeading: {
                // TrueDepthカメラの左側部分の領域にコンテンツを配置する
                
            } compactTrailing: {
                // TrueDepthカメラの右側部分の領域にコンテンツを配置する
                
            } minimal: {
                // 複数のLive Activityが存在するときにTrueDepthカメラの右側部分に表示される、円形の領域にコンテンツを配置する
                
            }
        }
    }
}

```

### 3-4. Dynamic Islandで表示したいViewをActivityConfiguration内で構築する。
[こちらの動画](https://www.youtube.com/watch?v=mBAgCZJr6jw)を写経して、デザインしたものが以下のようになる。

![Simulator Screen Shot - iPhone 14 Pro - 2022-12-06 at 19.26.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/c657406e-faf8-e62b-0fa5-10e98a6bf866.png)
![Simulator Screen Shot - iPhone 14 Pro - 2022-12-06 at 19.26.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/773915/447841b5-2649-89bf-12fe-838e99ec458c.png)


```swift:
@main
struct OrderStatus: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: OrderAttributes.self) { context in

        } dynamicIsland: { context in          
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    HStack {
                        Image("Logo")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .frame(width: 40, height: 40)

                        Text("Store Pickup")
                            .font(.system(size: 14))
                            .foregroundColor(.white)
                            .frame(maxWidth: .infinity, alignment: .leading)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    HStack(spacing: -2) {
                        ForEach(["Burger", "Shake"], id: \.self) { image in
                            Image(image)
                                .resizable()
                                .aspectRatio(contentMode: .fit)
                                .frame(width: 25, height: 25)
                                .background {
                                    Circle()
                                        .fill(Color("Green"))
                                        .padding(-2)
                                }
                                .background {
                                    Circle()
                                        .stroke(.white, lineWidth: 1.5)
                                        .padding(-2)
                                }
                        }
                    }
                }
                DynamicIslandExpandedRegion(.center) {
                }
                DynamicIslandExpandedRegion(.bottom) {
                    dynamicIslandStatusView(context: context)
                }
            } compactLeading: {
                Image("Logo")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(4)
                    .offset(x: -4)
            } compactTrailing: {
                Image(systemName: context.state.status.rawValue)
                    .font(.title3)
            } minimal: {
                Image(systemName: context.state.status.rawValue)
                    .font(.title3)
            }
        }
    }
    
    @ViewBuilder
    func dynamicIslandStatusView(context: ActivityViewContext<OrderAttributes>) -> some View {
        HStack(alignment: .bottom, spacing: 0) {
            VStack(alignment: .leading, spacing: 4) {
                Text(message(status: context.state.status))
                    .font(.callout)
                    .foregroundColor(.white)
                Text(subMessage(status: context.state.status))
                    .font(.caption2)
                    .foregroundColor(.gray)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .offset(x: 5, y: 5)
            
            HStack(alignment: .bottom, spacing: 0) {
                ForEach(Status.allCases, id: \.self) { type in
                    Image(systemName: type.rawValue)
                        .font(context.state.status == type ? .title2 : .body)
                        .foregroundColor(context.state.status == type ? Color("Green") : .white.opacity(0.7))
                        .frame(width: context.state.status == type ? 35 : 26, height: context.state.status == type ? 35 : 26)
                        .background {
                            Circle()
                                .fill(context.state.status == type ? .white : .green.opacity(0.5))
                        }
                        .background(alignment: .bottom, content: {
                            BottomArrow(status: context.state.status, type: type)
                        })
                        .frame(maxWidth: .infinity)
                }
            }
            .overlay(alignment: .bottom, content: {
                Rectangle()
                    .fill(.white.opacity(0.6))
                    .frame(height: 2)
                    .offset(y: 12)
                    .padding(.horizontal, 27.5)
            })
            .offset(y: -5)
        }
    }
}
```

# 参考
https://qiita.com/MaShunzhe/items/6cc8d9d204f40cbee19c  

https://www.youtube.com/watch?v=mBAgCZJr6jw