WWDC Quick Look 💓 By SwiftGGTeam
Live Activitiesの基礎

Live Activitiesの基礎

元の動画を見る

ハイライト

iOS 27では、Live Activitiesが縦向きのロック画面から、横向きのDynamic Island、StandBy、Apple Watch、CarPlayへ広がります。ActivityKitによるデータモデルの分離、WidgetKitによる複数サイズのビュー構成、そしてLiveActivityIntentによる操作を使えば、1つのコードパスであらゆるデバイスにリアルタイム更新を届けられます。

重要ポイント

宅配アプリでコーヒーを注文した場面を想像してください。注文後、いつ準備できるか知りたいけれど、何度もアプリを開いて確認したくはありません。Live Activitiesが解決するのは、まさにこの問題です。

iOS 27以前のLive Activitiesは、縦向きのロック画面とDynamic Islandにしか表示されませんでした。横向きでゲームをしたり動画を見たりしていると、その情報は消えていました。StandByモードで充電中のiPhoneにも進捗は表示されません。Apple WatchやCarPlayにも届きませんでした。

iOS 27ではこれが変わります。横向きでもDynamic IslandはLive Activitiesを表示しますが、幅は圧縮されます。StandByモードではロック画面用ビューが200%に拡大されます。同じLive Activityは、Apple WatchのSmart Stack、macOSのメニューバー、CarPlayのダッシュボードにも自動で同期されます。

つまり、開発者は1つの固定UIを書くだけでは足りません。横向きでは幅が限られ、StandByでは背景を全面に広げる必要があり、Apple WatchとCarPlayでは使える空間が非常に小さくなります。Appleはサイズのハードコードを求めていません。代わりに、現在の表示環境を判断する環境値を用意し、同じビューが環境に応じて内容を自動調整できるようにしています。

もう1つの変化は操作です。以前はLive Activityをタップするとアプリに戻るだけでした。今はLiveActivityIntentを通じて、ロック画面やDynamic Islandの中で軽い操作を直接行えます。たとえば、アプリを開かずにコーヒー注文を評価できます。

詳細

データモデル: 静的データと動的データを分ける

04:16

Live Activitiesでは、データを2種類に分ける必要があります。変わらない値はActivityAttributesへ、時間とともに変わる値はContentStateへ入れます。この分離により、システムは変化した部分だけを効率よくプッシュ更新できます。

コーヒー注文を例にすると、次のようになります。

import ActivityKit
import Foundation

public struct DrinkOrderAttributes: ActivityAttributes {
    let shopName: String
    let drink: Drink
    let orderID: UUID

    public struct ContentState: Codable, Hashable {
        var phase: DrinkOrder.Phase = .waiting
        var estimatedReadyDate: Date
        var rating: DrinkOrder.Rating?
    }
}

ポイント:

  • shopNamedrinkorderIDは静的データで、注文後は変わりません
  • phaseestimatedReadyDateratingは動的データで、注文の進行に合わせて変化します
  • ContentStateCodableHashableに準拠する必要があります。システムがシリアライズし、プロセス間で渡すためです

複数サイズのビュー構成

05:35

Live Activityの画面はWidgetKitで構築します。Widget ExtensionでActivityConfigurationを提供し、各表示場所のビューを定義します。

import ActivityKit
import SwiftUI
import WidgetKit

struct DrinkOrderLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DrinkOrderAttributes.self) { context in
            ActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    ExpandedLeadingView(context: context)
                }
                DynamicIslandExpandedRegion(.center) {
                    ExpandedCenterView(context: context)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    ExpandedTrailingView(context: context)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ExpandedBottomView(context: context)
                }
            } compactLeading: {
                CompactLeadingView(context: context)
            } compactTrailing: {
                CompactTrailingView(context: context)
            } minimal: {
                MinimalView(context: context)
            }
        }
    }
}

ポイント:

  • ActivityConfigurationは、ジェネリック引数DrinkOrderAttributes.selfでデータモデルに結びつきます
  • contentクロージャはロック画面とStandByで表示されるビューを返します
  • dynamicIslandクロージャはDynamic Islandの3サイズを定義します。長押しで開くexpanded、左右の小さな領域に表示するcompact、複数アクティビティが並ぶときの最小表示であるminimalです
  • 各ビューはcontext引数から現在の状態データを読み取ります

開始と更新

07:43

Live Activityを開始する前に、ユーザーが許可しているか確認します。

func launchLiveActivity(order: DrinkOrder) throws {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }

    let attributes = DrinkOrderAttributes(
        shopName: "Coffee Shop",
        drink: order.drink,
        orderID: order.id
    )

    let estimatedReadyDate = Date.now + (15 * 60)
    let contentState = DrinkOrderAttributes.ContentState(
        phase: .waiting,
        estimatedReadyDate: estimatedReadyDate
    )

    let activityContent = ActivityContent(state: contentState, staleDate: nil)
    let activity = try Activity.request(attributes: attributes, content: activityContent)
}

状態が変わったらupdateを呼びます。

await activity.update(
    ActivityContent(
        state: DrinkOrderAttributes.ContentState(
            phase: .preparing,
            estimatedReadyDate: estimatedReadyDate
        ),
        staleDate: nil
    )
)

ポイント:

  • areActivitiesEnabledは、ユーザーが設定でLive Activitiesをオフにしていないか確認します
  • staleDateはコンテンツがいつ古くなるかを示します。期限後、システムは期限切れ状態を表示してユーザーに知らせられます
  • 更新はフォアグラウンドのコードから直接呼ぶことも、プッシュ通知でバックグラウンドから行うこともできます
  • プッシュ更新には2つの戦略があります。スポーツ中継のような大規模同時配信に向くブロードキャストチャンネルと、個別体験に向く単一デバイス向けプッシュです

横向きDynamic Islandの幅に適応する

10:33

iOS 27では、横向きのDynamic Islandは幅が限られます。環境を検出し、内容を切り替える必要があります。

struct CompactTrailingView: View {
    @Environment(\.isDynamicIslandLimitedInWidth) var isDynamicIslandLimitedInWidth
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        if isDynamicIslandLimitedInWidth {
            StepProgressIconView(context: context)
        } else if context.state.phase.showsTimer {
            EstimatedReadyView(context: context, font: .system(.body).monospacedDigit())
                .multilineTextAlignment(.trailing)
                .frame(maxWidth: maximumTimerLabelWidth)
        } else {
            OrderPhaseLabelView(context: context, font: .caption2.bold(), color: .brown)
                .multilineTextAlignment(.trailing)
        }
    }
}

ポイント:

  • isDynamicIslandLimitedInWidthは横向きのときだけtrueになります
  • 幅が限られる場合は、テキストをアイコンに置き換えてレイアウトのはみ出しを避けます
  • 縦向きで十分な空間がある場合は、カウントダウンや状態ラベルを表示できます

StandByの背景に適応する

11:34

StandByモードではロック画面ビューが200%に拡大されるため、元のグラデーション背景だと内容が小さく見えることがあります。環境値で、自分で背景を描くべきか判断します。

struct ActivityView: View {
    @Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        DetailView(context: context)
            .background {
                if showsWidgetContainerBackground {
                    LinearGradient.barista
                }
            }
            .activityBackgroundTint(.espresso)
    }
}

ポイント:

  • showsWidgetContainerBackgroundはロック画面ではtrue、StandByではfalseです
  • ロック画面ではグラデーション背景を残し、StandByでは.activityBackgroundTintで全画面の色を設定します
  • これによりStandByモードでLive Activityが画面領域全体を満たします

Apple WatchとCarPlay向けのsmall対応

12:30

Apple WatchとCarPlayの空間は非常に小さいため、.smallアクティビティファミリーのサポートを宣言し、簡潔なビューを用意します。

struct DrinkOrderLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DrinkOrderAttributes.self) { context in
            ActivityView(context: context)
        } dynamicIsland: { context in
            // ... Dynamic Island の設定
        }
        .supplementalActivityFamilies([.small])
    }
}

ビュー内部ではactivityFamilyでレイアウトを切り替えます。

struct ActivityView: View {
    @Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
    @Environment(\.activityFamily) var activityFamily
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        contentView
            .background {
                if showsWidgetContainerBackground {
                    LinearGradient.barista
                }
            }
            .activityBackgroundTint(.espresso)
    }

    @ViewBuilder
    var contentView: some View {
        if activityFamily == .small {
            SmallView(context: context)
        } else {
            DetailView(context: context)
        }
    }
}

ポイント:

  • .supplementalActivityFamilies([.small])で小さなデバイスへの対応を宣言します
  • activityFamily.smallなら、表示先はApple WatchまたはCarPlayです
  • smallビューは極限まで簡潔にし、最も重要な数値や状態アイコンだけを残します
  • iPhone上のLive ActivityはApple WatchとCarPlayへ自動で転送されるため、追加コードは不要です

App Intentsで操作を追加する

13:36

Live Activityを「見るだけ」から「操作できる」ものにするには、LiveActivityIntentに準拠するIntentを定義します。

struct RateDrinkIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Rate Drink"

    @Parameter(title: "Order ID")
    var orderID: String

    @Parameter(title: "Positive")
    var isPositive: Bool

    func perform() async throws -> some IntentResult {
        await updateLocalDatastore(rating: isPositive ? .great : .poor, dismissPolicy: .after(.now + 15))
        return .result()
    }
}

ビュー内のボタンをIntentに結びつけます。

struct RatingButtons: View {
    var context: ActivityViewContext<DrinkOrderAttributes>

    var body: some View {
        HStack(spacing: 12) {
            Button(intent: RateDrinkIntent(
                orderID: context.attributes.orderID.uuidString, isPositive: false)) {
                Label("Not Good", systemImage: "hand.thumbsdown.fill")
            }
            .buttonStyle(RatingButtonStyle(color: .red))

            Button(intent: RateDrinkIntent(
                orderID: context.attributes.orderID.uuidString, isPositive: true)) {
                Label("Great", systemImage: "hand.thumbsup.fill")
            }
            .buttonStyle(RatingButtonStyle(color: .green))
        }
    }
}

ポイント:

  • LiveActivityIntentはApp Intentsフレームワークを継承し、バックグラウンドで実行されます
  • perform()ではローカルデータベースの更新やネットワーク送信はできますが、UIのアラートは表示できません
  • dismissPolicy: .after(.now + 15)により、操作完了から15秒後にLive Activityを自動で消せます
  • ボタンはButton(intent:)初期化子でIntentに直接結びつき、タップするとシステムが静かに実行します

実装アイデア

配達進捗トラッカー

作るもの: ユーザーが注文したあと、Live Activityで配達員の位置と到着予定時刻をリアルタイムに表示します。横向きでゲーム中でも、Dynamic Islandに進捗を表示できます。

なぜ価値があるか: ユーザーは注文状態を確認するために何度もアプリを開く必要がありません。リアルタイム情報がロック画面とDynamic Islandに常駐します。横向きで幅が限られる場合は地図サムネイルを配達中アイコンに変え、情報を常に見える状態に保ちます。

始め方: ActivityAttributesContentStateのデータモデルを定義し、ActivityConfigurationを作成します。横向きではisDynamicIslandLimitedInWidthを使ってアイコン表示へ切り替えます。

スポーツのスコア速報

作るもの: スポーツアプリが試合中にスコア変化をプッシュします。Apple Watchでは.smallファミリーでスコアと残り時間だけを表示します。

なぜ価値があるか: 何万人ものユーザーが同じ試合を同時に見る場合、ブロードキャストチャンネルで更新すればAPNsの制限を避けやすくなります。複数デバイスへの同期により、ユーザーはどの場面でもアプリを開かずに素早くスコアを確認できます。

始め方: Activity.requestでLive Activityを開始し、ブロードキャストチャンネルのプッシュ更新を設定します。Apple WatchとCarPlayに対応するため、.supplementalActivityFamilies([.small])を宣言します。

ポモドーロ集中アシスタント

作るもの: ユーザーが集中を開始したら、Live Activityに残り時間を表示します。StandByでは全画面のカウントダウンにします。終了時には、ロック画面から直接「完了」または「5分延長」を選べるようにします。

なぜ価値があるか: 集中タイマーは常に見えていてほしい一方、作業を邪魔してはいけません。Live Activityはロック画面とStandByに常駐し、LiveActivityIntentによりロック解除なしで操作できます。

始め方: LiveActivityIntentに準拠するIntentを作り、perform()でタイマー状態を更新します。Button(intent:)でロック画面のボタンに結びつけます。

フライト情報カード

作るもの: 搭乗前はLive Activityで搭乗口と搭乗時刻を表示します。離陸後は手荷物受取所の情報に自動更新します。

なぜ価値があるか: フライト情報は頻繁に変わり、ユーザーは重要なタイミングですぐ確認する必要があります。staleDateでフライト予定時刻を示しておけば、遅延でその時刻を過ぎたときにUIが期限切れ状態を表示し、古い搭乗口情報を見せ続けるのを防げます。

始め方: 開始時にActivityContent(state:staleDate:)を渡し、フライト状態が変わったらupdateで新しい状態を送ります。情報の有効期間を示す適切なstaleDateを設定します。

スマートホームのショートカットパネル

作るもの: エアコンや空気清浄機などの状態をLive Activityで常時表示します。ユーザーはロック画面で直接温度調整や電源切り替えができます。

なぜ価値があるか: スマートホーム機器を頻繁に確認・操作するたびにアプリを開くのは面倒です。Live Activityなら状態が常に見え、LiveActivityIntentと組み合わせてロック画面から直接操作できます。

始め方: LiveActivityIntentを定義し、perform()内でHomeKit APIまたは自分のバックエンドを呼びます。Live Activityビュー内の操作ボタンをButton(intent:)で結びつけます。

関連セッション

コメント

GitHub Issues · utterances