mcpField
L
live-activities

live-activities

github.com/dpearson2699開発1.2k465 回インストール13 週間前に更新
リポジトリ

live-activities について

live-activitiesは、ActivityKitの体験を構築・レビューするためのSwift開発スキルです。リアルタイム情報を表示する開発者向けに、アクティビティのライフサイクル、Dynamic Islandのレイアウト、プッシュ更新、新しいスケジュール機能や表示方法を扱います。

主な機能

  1. 01ActivityKitの設定とライフサイクルパターン
  2. 02ロック画面とDynamic Islandのレイアウト
  3. 03APNsによるプッシュ更新と起動
  4. 04スケジュール機能とチャンネル型アクティビティ
  5. 05コードレビュー用チェックリストと注意点

活用シーン

  1. 01ロック画面に配送状況を表示
  2. 02サーバー駆動のアクティビティ更新を送信
  3. 03将来のイベントに合わせてアクティビティを予約
  4. 04Live Activityの実装をレビュー

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill live-activities

インストール後、Claude Code で/live-activitiesを入力して読み込まれたか確認します。

同梱ファイルreferences/live-activity-patterns.md

詳細

ライブアクティビティとDynamic Island

ActivityKitを使用して、ロック画面、Dynamic Island、 StandBy、CarPlay、Macのメニューバーで、リアルタイムに情報をひと目で確認できる体験を構築します。パターンはSwift 6.2を使用するiOS 26以降を対象としており、特に記載がない限りiOS 16.1まで下位互換性があります。

プッシュペイロード形式、複数のアクティビティの同時実行、状態の監視、テストなど、コードパターンの全体については、references/live-activity-patterns.mdを参照してください。

目次

ワークフロー

1. 新しいライブアクティビティを作成する
  1. ホストアプリのInfo.plistにNSSupportsLiveActivities = YESを追加します。
  2. ネストされたContentStateを含むActivityAttributes構造体を定義します。
  3. ウィジェットバンドル内に、ロック画面のコンテンツとDynamic Islandのクロージャーを含むActivityConfigurationを作成します。
  4. Activity.request(attributes:content:pushType:)でアクティビティを開始します。
  5. activity.update(_:)で更新し、activity.end(_:dismissalPolicy:)で終了します。
  6. プッシュトークンをサーバーに転送して、リモート更新できるようにします。
2. 既存のライブアクティビティコードをレビューする

このドキュメントの最後にあるレビュー用チェックリストに従って確認します。

ActivityAttributesの定義

静的データ(アクティビティのライフサイクル中に変更されないデータ)と、動的なContentState(更新ごとに変化するデータ)の両方を定義します。更新やプッシュペイロードのたびに構造体全体がシリアライズされるため、ContentStateは小さく保ちます。

import ActivityKit

struct DeliveryAttributes: ActivityAttributes {
    // Static -- set once at activity creation, never changes
    var orderNumber: Int
    var restaurantName: String

    // Dynamic -- updated throughout the activity lifetime
    struct ContentState: Codable, Hashable {
        var driverName: String
        var estimatedDeliveryTime: ClosedRange<Date>
        var currentStep: DeliveryStep
    }
}

enum DeliveryStep: String, Codable, Hashable, CaseIterable {
    case confirmed, preparing, pickedUp, delivering, delivered

    var icon: String {
        switch self {
        case .confirmed: "checkmark.circle"
        case .preparing: "frying.pan"
        case .pickedUp: "bag.fill"
        case .delivering: "box.truck.fill"
        case .delivered: "house.fill"
        }
    }
}
古くなった日時

ActivityContentにstaleDateを設定し、コンテンツがいつ古くなるかをシステムに伝えます。この日時を過ぎると、システムはcontext.isStaleをtrueに設定します。ビューにはフォールバックUI(例:「更新中…」)を表示します。

let content = ActivityContent(
    state: state,
    staleDate: Date().addingTimeInterval(300), // stale after 5 minutes
    relevanceScore: 75
)

アクティビティのライフサイクル

開始

Activity.requestを使用してライブアクティビティを作成し、表示します。APNs経由のリモート更新を有効にするには、pushTypeに.tokenを渡します。

let attributes = DeliveryAttributes(orderNumber: 42, restaurantName: "Pizza Place")
let state = DeliveryAttributes.ContentState(
    driverName: "Alex",
    estimatedDeliveryTime: Date()...Date().addingTimeInterval(1800),
    currentStep: .preparing
)
let content = ActivityContent(state: state, staleDate: nil, relevanceScore: 75)

do {
    let activity = try Activity.request(
        attributes: attributes,
        content: content,
        pushType: .token
    )
    print("Started activity: \(activity.id)")
} catch {
    print("Failed to start activity: \(error)")
}
更新

アプリから動的なコンテンツ状態を更新します。更新とともに表示バナーやサウンドを通知するには、AlertConfigurationを使用します。

let updatedState = DeliveryAttributes.ContentState(
    driverName: "Alex",
    estimatedDeliveryTime: Date()...Date().addingTimeInterval(600),
    currentStep: .delivering
)
let updatedContent = ActivityContent(
    state: updatedState,
    staleDate: Date().addingTimeInterval(300),
    relevanceScore: 90
)

// Silent update
await activity.update(updatedContent)

// Update with an alert
await activity.update(updatedContent, alertConfiguration: AlertConfiguration(
    title: "Order Update",
    body: "Your driver is nearby!",
    sound: .default
))
終了

追跡しているイベントが完了したら、アクティビティを終了します。終了したアクティビティをロック画面にどれくらい表示し続けるかを、表示終了ポリシーで指定します。

let finalState = DeliveryAttributes.ContentState(
    driverName: "Alex",
    estimatedDeliveryTime: Date()...Date(),
    currentStep: .delivered
)
let finalContent = ActivityContent(state: finalState, staleDate: nil, relevanceScore: 0)

// System decides when to remove (up to 4 hours)
await activity.end(finalContent, dismissalPolicy: .default)

// Remove immediately
await activity.end(finalContent, dismissalPolicy: .immediate)

// Remove after a specific time (max 4 hours from now)
await activity.end(finalContent, dismissalPolicy: .after(Date().addingTimeInterval(3600)))

成功、エラー、キャンセルのすべてのコードパスで、必ずアクティビティを終了してください。終了し忘れたアクティビティは、システムによって終了されるまで(最長8時間)ロック画面に残り、ユーザーの不満につながります。

ロック画面での表示

ロック画面はライブアクティビティの主要な表示場所です。iOS 16.1以降を搭載したすべてのデバイスで、ここにライブアクティビティが表示されます。まずこのレイアウトを設計してください。

struct DeliveryActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lock Screen / StandBy / CarPlay / Mac menu bar content
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Spacer()
                    Text("Order #\(context.attributes.orderNumber)")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                if context.isStale {
                    Label("Updating...", systemImage: "arrow.trianglehead.2.clockwise")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                } else {
                    HStack {
                        Label(context.state.driverName, systemImage: "person.fill")
                        Spacer()
                        Text(timerInterval: context.state.estimatedDeliveryTime,
                             countsDown: true)
                            .monospacedDigit()
                    }
                    .font(.subheadline)

                    // Progress steps
                    HStack(spacing: 12) {
                        ForEach(DeliveryStep.allCases, id: \.self) { step in
                            Image(systemName: step.icon)
                                .foregroundStyle(
                                    step <= context.state.currentStep ? .primary : .tertiary
                                )
                        }
                    }
                }
            }
            .padding()
        } dynamicIsland: { context in
            // Dynamic Island closures (see next section)
            DynamicIsland {
                // Expanded regions...
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "box.truck.fill").font(.title2)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(timerInterval: context.state.estimatedDeliveryTime,
                         countsDown: true)
                        .font(.caption).monospacedDigit()
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.restaurantName).font(.headline)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    HStack(spacing: 12) {
                        ForEach(DeliveryStep.allCases, id: \.self) { step in
                            Image(systemName: step.icon)
                                .foregroundStyle(
                                    step <= context.state.currentStep ? .primary : .tertiary
                                )
                        }
                    }
                }
            } compactLeading: {
                Image(systemName: "box.truck.fill")
            } compactTrailing: {
                Text(timerInterval: context.state.estimatedDeliveryTime,
                     countsDown: true)
                    .frame(width: 40).monospacedDigit()
            } minimal: {
                Image(systemName: "box.truck.fill")
            }
        }
    }
}
ロック画面のサイズ

ロック画面で表示できる縦方向のスペースは限られています。およそ160ポイントを超える高さのレイアウトは避けてください。supplementalActivityFamiliesを使用して、.small(コンパクト)または.medium(標準)のサイズに対応します。

ActivityConfiguration(for: DeliveryAttributes.self) { context in
    // Lock Screen content
} dynamicIsland: { context in
    // Dynamic Island
}
.supplementalActivityFamilies([.small, .medium])

Dynamic Island

Dynamic IslandはiPhone 14 Pro以降で利用できます。表示モードは3種類あります。3種類すべてを設計しますが、すべてのデバイスにDynamic Islandがあるわけではないため、ロック画面を主要な表示場所として扱ってください。

コンパクト(Leading + Trailing)

ライブアクティビティが1つだけ有効な場合、常に表示されます。スペースが非常に限られているため、最も重要な情報だけを表示します。

領域用途
compactLeadingアクティビティを識別するアイコンまたは短いラベル
compactTrailing重要な値を1つ(タイマー、スコア、ステータス)
ミニマル

複数のライブアクティビティがスペースを取り合う場合に表示されます。ミニマル表示枠を利用できるアクティビティは1つだけです。アイコンまたはグリフを1つ表示します。

展開領域

ユーザーが Dynamic Island を長押しすると表示されます。

領域位置
.leadingTrueDepth カメラの左側。下に折り返します
.trailingTrueDepth カメラの右側。下に折り返します
.centerカメラのすぐ下
.bottom他のすべての領域の下
キーラインの色合い

Dynamic Island の境界に控えめな色合いを適用します。

DynamicIsland { /* expanded */ }
    compactLeading: { /* ... */ }
    compactTrailing: { /* ... */ }
    minimal: { /* ... */ }
    .keylineTint(.blue)

プッシュによる更新

プッシュによる更新では、APNs 経由で Live Activity を更新します。アプリからポーリングするより効率的で、アプリが一時停止中でも動作します。

セットアップ

アクティビティの開始時に pushType として .token を渡し、プッシュトークンをサーバーに転送します。

let activity = try Activity.request(
    attributes: attributes,
    content: content,
    pushType: .token
)

// Observe token changes -- tokens can rotate
Task {
    for await token in activity.pushTokenUpdates {
        let tokenString = token.map { String(format: "%02x", $0) }.joined()
        try await ServerAPI.shared.registerActivityToken(
            tokenString, activityID: activity.id
        )
    }
}
APNs ペイロードの形式

次のヘッダーと JSON 本文を指定して、APNs に HTTP/2 POST を送信します。

必須の HTTP ヘッダー:

  • apns-push-type: liveactivity
  • apns-topic: <bundle-id>.push-type.liveactivity
  • apns-priority: 5(低)または 10(高。アラートを表示)

更新ペイロード:

{
    "aps": {
        "timestamp": 1700000000,
        "event": "update",
        "content-state": {
            "driverName": "Alex",
            "estimatedDeliveryTime": {
                "lowerBound": 1700000000,
                "upperBound": 1700001800
            },
            "currentStep": "delivering"
        },
        "stale-date": 1700000300,
        "alert": {
            "title": "Delivery Update",
            "body": "Your driver is nearby!"
        }
    }
}

終了ペイロード: "event": "end" と任意の "dismissal-date" を指定した、同じ構造です。

content-state JSON は、ContentState の Codable 構造体と完全に一致している必要があります。キーや型が一致しないと、エラーが通知されずに失敗します。

プッシュによる開始

アプリを起動せずに、リモートから Live Activity を開始します(iOS 17.2 以降)。

Task {
    for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
        let tokenString = token.map { String(format: "%02x", $0) }.joined()
        try await ServerAPI.shared.registerPushToStartToken(tokenString)
    }
}
頻繁なプッシュ更新

Info.plist に NSSupportsLiveActivitiesFrequentUpdates = YES を追加すると、プッシュ更新の予算を増やせます。1 分に 1 回を超えて更新されるアクティビティ(スポーツのスコア、ライドの追跡など)で使用します。

iOS 26 での追加機能

スケジュールされた Live Activity(iOS 26 以降)

Live Activity が将来の時刻に開始するようスケジュールできます。アプリがフォアグラウンドにない場合でも、システムがアクティビティを自動的に開始します。開始時刻が決まっているイベント(スポーツの試合、フライト、予定された配達など)で使用します。

let scheduledDate = Calendar.current.date(
    from: DateComponents(year: 2026, month: 3, day: 15, hour: 19, minute: 0)
)!

let activity = try Activity.request(
    attributes: attributes,
    content: content,
    pushType: .token,
    start: scheduledDate
)
ActivityStyle(iOS 16.1 以降の型、iOS 26 以降の style: パラメーター)

.standard(終了するまで持続するデフォルト)または .transient(システムが自動的に閉じる場合があります)を指定して、表示の持続性を制御します。公共交通機関の到着案内など、短時間の更新には .transient を使用します。Activity.request の style: パラメーターには iOS 26 以降が必要です。

let activity = try Activity.request(
    attributes: attributes, content: content,
    pushType: .token, style: .transient
)
Mac のメニューバーと CarPlay(iOS 26 以降)

Live Activity は macOS Tahoe のメニューバー(iPhone ミラーリング経由)と CarPlay のホーム画面に自動的に表示されます。追加のコードは必要ありません。ロック画面のレイアウトが小さいサイズでも読みやすいことを確認してください。

チャネルベースのプッシュ(iOS 18 以降)

.channel を使用すると、多数の Live Activity に一度に更新を配信できます。

let activity = try Activity.request(
    attributes: attributes, content: content,
    pushType: .channel("delivery-updates")
)

よくある間違い

やってはいけないこと: コンパクト表示に情報を詰め込みすぎる。表示領域は非常に小さいです。 推奨: コンパクト表示の先頭と末尾には、最も重要な情報(アイコン+値1つ)のみを表示します。

やってはいけないこと: アプリからLive Activitiesを頻繁に更新する(バッテリーを消耗します)。 推奨: サーバー主導の更新にはプッシュ更新を使用します。アプリ側での更新はユーザー操作に限定します。

やってはいけないこと: イベント完了時にアクティビティを終了し忘れる。 推奨: 成功、エラー、キャンセルのすべての経路で、必ずアクティビティを終了します。アクティビティが残ったままだと、ユーザーに不便をかけます。

やってはいけないこと: Dynamic Islandが常に利用できると思い込む(iPhone 14 Pro以降のみ)。 推奨: ロック画面を主要な表示面として設計し、Dynamic Islandは補助的なものとして扱います。

やってはいけないこと: 機密情報をActivityAttributesに保存する(ロック画面に表示されます)。 推奨: 機密データはアプリ内に保持し、安全に表示できる概要のみを表示します。

やってはいけないこと: 古くなった日付への対応を忘れる。 推奨: ビューでcontext.isStaleを確認し、フォールバックUI(「更新中…」など)を表示します。

やってはいけないこと: プッシュトークンのローテーションを無視する。トークンはいつでも変更される可能性があります。 推奨: activity.pushTokenUpdatesの非同期シーケンスを使用し、値が出力されるたびに再登録します。

やってはいけないこと: NSSupportsLiveActivitiesのInfo.plistキーを設定し忘れる。 推奨: ホストアプリのInfo.plistにNSSupportsLiveActivities = YESを追加します(拡張機能ではありません)。

やってはいけないこと: リクエスト/更新/終了に非推奨のcontentStateベースのAPIを使用する。 推奨: すべてのライフサイクル呼び出しにActivityContentを使用します。

やってはいけないこと: Live Activityのビューに重いロジックを入れる。サイズが制限されたウィジェットプロセスでレンダリングされます。 推奨: 表示値を事前に計算し、ContentStateを通じて渡します。

レビューチェックリスト

  • ActivityAttributesで静的プロパティとContentStateを定義している
  • ホストアプリのInfo.plistにNSSupportsLiveActivities = YESが設定されている
  • アクティビティでActivityContentを使用している(非推奨のcontentState APIではない)
  • すべてのコードパス(成功、エラー、キャンセル)でアクティビティを終了している
  • ロック画面のレイアウトでcontext.isStaleに対応している
  • Dynamic Islandのコンパクト表示、展開表示、最小表示を実装している
  • activity.pushTokenUpdatesを介してプッシュトークンをサーバーに転送している
  • 重要な更新にAlertConfigurationを使用している
  • 開始前にActivityAuthorizationInfoを確認している
  • ContentStateを小さく保っている(更新のたびにシリアライズされます)
  • 実機でテストしている(Dynamic Islandの表示はSimulatorと異なります)
  • ActivityAttributesとContentStateの型がSendableであることを確認し、Live ActivityのUIを@MainActorで更新している

参考資料

  • パターンとコード例については、references/live-activity-patterns.mdを参照してください

コメント