【SwiftUI】Live Activityのサンプルコードを実行する(iOS16.1/Xcode14.1)

SwiftUI

今回はiOS16.1から追加されるActivityKitのLive Activityを、
Appleのドキュメントに従いサンプルコードを実行していきます。

Appleのドキュメントは以下のリンクから確認して下さい。

Displaying live data with Live Activities | Apple Developer Documentation
Display your app’s data in the Dynamic Island and on the Lock Screen and offer quick interactions.

サンプルコードは一部を抜粋する形で紹介されており、プロジェクト単位の配布ではありません。
その為、本記事では改変を入れつつ動く形にすることを目的とします。
細かい解説については別記事を予定しています。

※2022/10/22 追記
今回はbeta版を使って紹介しています。
Xcode14.1RCだとWidget追加時にLiveActivityのコードを生成してくれる機能があったので、
本記事とは一部異なる場合があります。
コード自体は本記事の通りで動くので、不要なコードはコメントアウトしてしまって下さい。

事前準備

Xcodeは14.1リリース後に読んでる方は読み飛ばして結構です。
※2022/10/25リリース予定だそうです。

本記事の作成時点では正式リリースされているXcodeは14.0.1でiOSは16.0.3です。
LiveActivityは含まれていないのでbeta版またはRC版のXcodeをダウンロードして使用して下さい。
シミュレータも付属しています。

RC版からはリリースが可能なのでRC版がオススメです。
ダウンロードは以下のリンクからです。

Sign In - Apple
Sign in with your Apple ID

基本的にはダウンロードして解凍すればそのまま使えます。

実機テストを行う場合は実機にもiOS16.1が必要となります。
beta版かRC版を使用して下さい。

実行端末について

Live ActivityはロックスクリーンとDynamic Islandに表示できます。
Dynamic Islandの確認をする為に、シミュレータでも構いませんので
iPhone14 ProまたはPro Maxで実行する事を推奨します。

Info.plistに設定を行う

プロジェクトを作成したら初めにInfo.plistに設定を行います。

Add RowでSupports Live Activitiesを追加しValueをYesにして下さい。
Keyを直に打ち込む場合はNSSupportsLiveActivitiesです。

この設定を行わなかった場合はexceptionが発生し以下のエラーメッセージが含まれます。

「com.apple.ActivityKit.ActivityInput error 1.」

許可されていない場合に出る様です。
後に紹介しますが他のパターンでも同じものが出ます。
エラーメッセージだけではどちらか分からないのでしっかり設定して下さい。

Widgetを作成する

次はWidgetを作成して行きます。

Live Activityの項目が個別である訳でなくWidgetExtensionの中に作ります。

TARGETSにWidgetExtensionを追加します。
下部のプラスボタンから追加を行います。

ActivityAttributesを作成する

実際にソースコードに入って行きます。

まずはLiveActivityで使用するデータのActivityAttributesを作成します。

WidgetやViewのコードは長くなるので別ファイルとしました。
また、このクラスはウィジェット側だけでなくアプリ側でも使用する為、
個別ファイルになっているのが望ましいです。

ファイルを作成する際はTarget MembershipでAppとWidgetExtensionが選択されている事を確認して下さい。
作成時に忘れた場合は、ソースの左手に設定箇所が表示されるのでそこから変更して下さい。

コード自体はサンプルそのままで問題ありません。

import Foundation
import ActivityKit

struct PizzaDeliveryAttributes: ActivityAttributes {
    public typealias PizzaDeliveryStatus = ContentState

    public struct ContentState: Codable, Hashable {
        var driverName: String
        var deliveryTimer: ClosedRange<Date>
    }

    var numberOfPizzas: Int
    var totalAmount: String
    var orderNumber: String
}

Viewを作成する

ドキュメントではWidgetの書き換えから入りますが、
最後まで書き切らないとエラーが出るので先にViewを作ります。

コードはそのままで大丈夫です。
ファイルを分ける場合はTarget MembershipでWidgetExtensionが含まれている事を確認して下さい。

import SwiftUI
import WidgetKit

struct LockScreenLiveActivityView: View {
    let context: ActivityViewContext<PizzaDeliveryAttributes>
    
    var body: some View {
        VStack {
            Spacer()
            Text("\(context.state.driverName) is on their way with your pizza!")
            Spacer()
            HStack {
                Spacer()
                Label {
                    Text("\(context.attributes.numberOfPizzas) Pizzas")
                } icon: {
                    Image(systemName: "bag")
                        .foregroundColor(.indigo)
                }
                .font(.title2)
                Spacer()
                Label {
                    Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                        .multilineTextAlignment(.center)
                        .frame(width: 50)
                        .monospacedDigit()
                } icon: {
                    Image(systemName: "timer")
                        .foregroundColor(.indigo)
                }
                .font(.title2)
                Spacer()
            }
            Spacer()
        }
        .activitySystemActionForegroundColor(.indigo)
        .activityBackgroundTint(.cyan)
    }
}

余談ですが、View周りはActivityKitではなくWidgetKitに含まれている様です。

Widgetを書き換える

Widget側の最後はWidgetの書き換えです。

今回は元々あった部分は殆ど使わないので消してしまって結構です。

ドキュメントでは少しずつ説明されていますが、一通り書き切らないとエラーが出ます。
以下に完成系を置いておくので困ったらコピペして下さい。
※今回は他にWidgetを作らないのでWidgetBundleは使っていません。

順に書き足して行っただけなので特に改変は加えていないです。

import WidgetKit
import SwiftUI

@main
struct PizzaDeliveryWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
            // Create the view that appears on the Lock Screen and as a
            // banner on the Home Screen of devices that don't support the
            // Dynamic Island.
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
            // Create the views that appear in the Dynamic Island.
            DynamicIsland {
                // Create the expanded view.
                DynamicIslandExpandedRegion(.leading) {
                    Label("\(context.attributes.numberOfPizzas) Pizzas", systemImage: "bag")
                        .foregroundColor(.indigo)
                        .font(.title2)
                }
                
                DynamicIslandExpandedRegion(.trailing) {
                    Label {
                        Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                            .multilineTextAlignment(.trailing)
                            .frame(width: 50)
                            .monospacedDigit()
                    } icon: {
                        Image(systemName: "timer")
                            .foregroundColor(.indigo)
                    }
                    .font(.title2)
                }
                
                DynamicIslandExpandedRegion(.center) {
                    Text("\(context.state.driverName) is on their way!")
                        .lineLimit(1)
                        .font(.caption)
                }
                
                DynamicIslandExpandedRegion(.bottom) {
                    Button {
                        // Deep link into your app.
                    } label: {
                        Label("Call driver", systemImage: "phone")
                    }
                    .foregroundColor(.indigo)
                }
                
            } compactLeading: {
                Label {
                    Text("\(context.attributes.numberOfPizzas) Pizzas")
                } icon: {
                    Image(systemName: "bag")
                        .foregroundColor(.indigo)
                }
                .font(.caption2)
            } compactTrailing: {
                Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                    .multilineTextAlignment(.center)
                    .frame(width: 40)
                    .font(.caption2)
            } minimal: {
                VStack(alignment: .center) {
                    Image(systemName: "timer")
                    Text(timerInterval: context.state.deliveryTimer, countsDown: true)
                        .multilineTextAlignment(.center)
                        .monospacedDigit()
                        .font(.caption2)
                }
            }
            .keylineTint(.cyan)
        }
    }
}

アプリ側の作成

次はアプリ側を作って行きます。

LiveActivityはWidgetExtensionで作成しますが実態は通知に近いものです。
Widgetとは異なりアプリ側でLiveActivityを発行しなければ表示できません。

特にViewについてのサンプルコードはありませんが、
なるべくLiveActivityのサンプルコードをそのまま活かせる形で作っています。

LiveActivityのリクエスト

ボタンを押してLiveActivityをリクエストして表示します。

ドキュメントのサンプルコードはボタンの中身にあります。

minutesとsecondsが変数なのでTextFieldで変更可能な形にしました。
またdeliveryActivity変数はViewのメンバとしました。

import SwiftUI
import ActivityKit

struct ContentView: View {
    
    @State var deliveryActivity:Activity<PizzaDeliveryAttributes>?
    
    @State var minutes = "10"
    @State var seconds = "10"
    
    var body: some View {
        VStack {
            HStack {
                Text("Minutes:")
                TextField("minutes", text: $minutes)
            }
            HStack {
                Text("Seconds:")
                TextField("seconds", text: $seconds)
            }
            
            Button("Request LiveActivity"){
                var future = Calendar.current.date(byAdding: .minute, value: (Int(minutes) ?? 0), to: Date())!
                future = Calendar.current.date(byAdding: .second, value: (Int(seconds) ?? 0), to: future)!
                let date = Date.now...future
                let initialContentState = PizzaDeliveryAttributes.ContentState(driverName: "Bill James", deliveryTimer:date)
                let activityAttributes = PizzaDeliveryAttributes(numberOfPizzas: 3, totalAmount: "$42.00", orderNumber: "12345")

                do {
                    deliveryActivity = try Activity.request(attributes: activityAttributes, contentState: initialContentState)
                    print("Requested a pizza delivery Live Activity \(String(describing: deliveryActivity?.id)).")
                } catch (let error) {
                    print("Error requesting pizza delivery Live Activity \(error.localizedDescription).")
                }
            }.padding()
        }
        .padding()
    }
}

deliveryActivityは最新の一つのみを保持する形になっている為、
実際に使用する場合は望ましくありません。
今回はサンプルコードの兼ね合いと分かりやすさの為にこの形にしました。

発行したActivityは取得できるので保持する必要は無いように思います。
もし保持するのであれば、一つだけ保持するなら複数のActivityを発行できないようにするべきですし、
そうでなければ配列で必要なものを全て保持するべきでしょう。

発行したLiveActivityはDynamic Islandまたはロックスクリーンで確認できます。

Dynamic Islandはホーム画面や他のアプリに切り替えると表示されます。
タップすると表示が大きくなります。

ロックスクリーンは下部の通知が出る位置に表示されます。

この時表示されるボタンはLiveActivity表示の可否です。
1回ごとのものではなくアプリ単位のもので、通知の可否のようなものです。

ここで拒否してしまうと次にLiveActivityをリクエストした際に例外が発生します。
例外はInfo.plistのものと同じ以下のメッセージなので気をつけてください。

「com.apple.ActivityKit.ActivityInput error 1.」

設定の変更は通知などと同じ様に設定アプリから変更します。

LiveActivityを更新する

先程のViewのVStackに以下の更新ボタンを追加しましょう。

ドキュメントのサンプルコードと異なる点はTaskです。
LiveActivityの更新は非同期で行う為awaitを使用します。

Viewから使う為には該当処理をTaskで囲う必要があります。
今回は更新処理を行なって終わりなのでTaskで囲う程度の認識だけでも大丈夫です。

Button("Update LiveActivity"){
    var future = Calendar.current.date(byAdding: .minute, value: (Int(minutes) ?? 0), to: Date())!
    future = Calendar.current.date(byAdding: .second, value: (Int(seconds) ?? 0), to: future)!
    let date = Date.now...future
    let updatedDeliveryStatus = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Anne Johnson", deliveryTimer: date)
    let alertConfiguration = AlertConfiguration(title: "Delivery Update", body: "Your pizza order will arrive in 25 minutes.", sound: .default)

    Task{
        await deliveryActivity?.update(using: updatedDeliveryStatus, alertConfiguration: alertConfiguration)
    }
}.padding()

Updateを行うと数値やDriverNameが変わります。

AlertConfigurationについてはiPhoneに関してはおそらく通知音のみです。
ドキュメントには「iPhone and Apple Watch」と書いていますが、
AlertConfiguration個別のドキュメントの説明を見ると、
titleとbodyの引数についてはApple Watchとだけ書かれています。

LiveActivityを完了する

最後にLiveActivityを完了させます。

このコードはドキュメントのサンプルをボタンに入れただけです。
何故かこちらはサンプルにTaskも書いてありました。

Button("End LiveActivity"){
    let finalDeliveryStatus = PizzaDeliveryAttributes.PizzaDeliveryStatus(driverName: "Anne Johnson", deliveryTimer: Date.now...Date())
    Task {
        await deliveryActivity?.end(using:finalDeliveryStatus, dismissalPolicy: .default)
    }
}.padding()

完了してもロックスクリーンには残ります。
横にスワイプするなどして削除する事ができます。

何もしない場合は設定した時間か4時間後のどちらか早い方で削除されます。

最後に

LiveActivityのサンプルコードの動かし方でした。

おおよそはそのまま動くので慣れている人は問題ないと思いますが、
Widget Extensionや非同期処理などがある為、慣れていない人は色々と確認が必要かと思います。

また、ロックスクリーンの表示可否が今までの通知等のアラートと異なる事に加え、
例外のメッセージがInfo.plistとロックスクリーンで拒否した場合で同じものが出る部分なども分かりにくいポイントだったと思います。

サンプルコードを触っただけでも便利で色々な可能性を感じる機能なので、
ぜひ使いこなして行きたいところです。

次は自分でよりシンプルなLiveActivityのコードを書いて紹介する予定です。
また、以前に非同期について書いた記事もあるので良ければ確認してみて下さい。

コメント

  1. […] 【SwiftUI】Live Activityのサンプルコードを実行する(iOS16.1/Xcode14.1)今回はiOS…SwiftUIActivityKitiOS16LiveActivitySwiftUIシェアする Twitter Facebook はてブ Pocket LINE コピーthwork.devをフォローする thwork.dev thwork […]

タイトルとURLをコピーしました