← すべての記事

Live Activitiesはバッジではなく、ステートマシンである

ジャンル: shipped-code。本記事では、私が妻、母、そして数千人の見知らぬユーザーが使うSwiftUI製の瞑想タイマー「Return」に組み込んだLive Activityについて記録しています。1 ここで紹介するパターンは、本番環境を生き抜いたものです。末尾の「ブルータル・オネスト」セクションでは、まだ自分が把握しきれていないことを率直に書いています。

ReturnのLive ActivityはLock ScreenとDynamic Islandに表示されるカウントダウン数値のように見えます。2 しかし、それは単なる数値ではありません。5つのライフサイクル状態を持つステートマシンであり、3つの外部解除パスと、自分自身から自分自身を守らなければならない1つの再入可能な開始パスを備えています。

私がリリースしたv1では、Live Activityをバッジのように扱っていました。「現在の残り時間」がデータで、それ以外は装飾だったのです。そのバージョンには、TestFlightで気づいたバグが3つ、本番で気づいたバグが1つありました:

  1. 開始処理が実行中にもう一度startをタップすると、2つ目のActivityが作成されて最初のものが孤立する。
  2. Dynamic Islandではカウントダウンが正しく描画されるものの、Lock Screenのビューでは一時停止中のタイマーがendTime <= Date()に該当してしまい、ユーザーが再開するまで0:00と表示される。
  3. 解除ポリシーが.defaultだったため、ユーザーがタイマーをリセットした後もLive Activityが長時間表示され続けた。Appleはこのポリシーで最大4時間ほど表示を保つ場合がある。
  4. (本番環境にて。)右から左に書く言語ロケール(アラビア語、ヘブライ語)では、Dynamic Islandのcompact-trailing領域で数字が逆向きに描画された。ラテン数字なのにRTLレイアウトになっていたのです。修正は1行で済みました。

これらはすべてステートマシンのバグでした。カウントダウンの数値そのものは問題ありません。プロダクトは数値ではなく、状態(ステート)なのです。

以下に示すステートマシンは、これらのバグを乗り越えて生き残ったものです。

TL;DR

  • 本番運用中のLiveActivityManagerは、5つの遷移メソッド(startActivityupdateActivityshowCycleCompleteshowFinalCompletionendActivity)と、1つの読み取り(hasActiveActivity)を公開しています。224行の本番コードは、startActivity内部にある特定のハザードを1つだけ守っています:並行する開始呼び出しと、そのメソッド内の各await境界をまたぐキャンセルチェックです。3
  • ContentStateは6つのフィールドを持ちます:endTimecurrentCycletotalCyclesisPausedisCompletedremainingSeconds。最初の5つはステートマシンのラベルです。6つ目(remainingSeconds)は、ActivityKitのライブなtimerIntervalでは対応できない静的表示用のフォールバックです。
  • 解除ポリシーの選択こそが、本当のプロダクト判断です。ユーザーリセットには.immediate、完了表示には.after(Date().addingTimeInterval(3))を使い、システムデフォルトは決して使いません。
  • Dynamic Islandのcompact-trailing領域では、タイマーテキストに.environment(\.layoutDirection, .leftToRight)を指定する必要があります。RTLのシステムロケール下でもラテン数字をLTRに保つためです。

ステートマシン

リリース済みのLive Activityには、1つのアイドル状態、ユーザーが観察できる3つのライブ状態、1つの終端状態、そして開発者が観察すべき1つの再入ゲートがあります:

┌──────────────────────────────────────────────────────────────────┐
│                  Lifecycle states                                 │
├──────────────────────────────────────────────────────────────────┤
│  IDLE          currentActivity == nil; no Live Activity present   │
│  RUNNING       isPaused=false, endTime > Date()                   │
│  PAUSED        isPaused=true, remainingSeconds=N                  │
│  CYCLE_END     isPaused=false, endTime <= Date(), isCompleted=false│
│  COMPLETE      isCompleted=true (terminal; transitions to IDLE)   │
└──────────────────────────────────────────────────────────────────┘
              │
              ↓
┌──────────────────────────────────────────────────────────────────┐
│             Dismissal policies (Apple)                            │
├──────────────────────────────────────────────────────────────────┤
│  .immediate            user reset                                  │
│  .after(now + 3s)      completion display window                   │
│  .default              system decides; can stay up to 4 hours      │
└──────────────────────────────────────────────────────────────────┘

Reentrancy gate inside startActivity():
  isStartingActivity flag + cancellable startActivityTask
  prevents two concurrent startActivity() calls from creating
  two Live Activities for one timer. Cancellation checks across
  each await keep the in-flight task safe to abort.

レンダリングパスでは、まずisPausedをチェックします。この順序こそが、壁時計時間がendTimeを越えても、一時停止中のタイマーがCYCLE_ENDとして描画されるのを防いでいるのです。7

状態名は数値に貼られたラベルではありません。状態名は、LiveActivityManager(私のSwiftUIビューが暮らすアプリ側)とReturnLiveActivity(Appleのプロセスがサーフェスを描画するwidget extension)の間の契約です。

その契約がTimerActivityAttributes.ContentStateであり、6つのフィールドすべてです:3

public struct ContentState: Codable, Hashable {
    var endTime: Date
    var currentCycle: Int
    var totalCycles: Int?
    var isPaused: Bool
    var isCompleted: Bool = false
    var remainingSeconds: Int = 0
}

すべての状態遷移はこの構造体を変更し、ActivityKitに対してプロセス境界をまたいでwidget extensionまで届けるよう要求します。そしてwidgetが再描画します。共有メモリはありません。コールバックもありません。あるのは、遷移のたびにプロセス境界を渡るCodableな構造体だけです。

この事実は、クロージャ、ビューモデル、observableオブジェクト、computed propertyでやりたかったことすべてを排除します。状態はシリアライズ可能なデータとして表現できなければなりません。エンコードできないなら、遷移もできないのです。

再入可能な開始処理

Live Activitiesには、同時実行できるActivity数のハードリミットと、Activity.requestを実行中にもう一度呼び出した場合のソフトリミットがあります。ハードリミットは十分にドキュメント化されています。4 ソフトリミットは「2回目の呼び出しは成功してしまい、孤立したActivityが作られるかもしれない」というものです。孤立Activityとは、マネージャーのcurrentActivityにもう紐付いていないLive Activityのことです。それは生き残ります。コードに戻る経路はありません。最終的には、それ自身の陳腐化タイマーで自動解除されます。それまでの間、ユーザーには重複したタイマーが見えてしまいます。

孤立Activityこそが、Returnのv1でリリースしてしまったバグでした。修正は再入ゲートと、LiveActivityManager.swift内のキャンセル可能なTaskです:3

private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?

func startActivity(...) {
    #if os(iOS)
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
    guard !isStartingActivity else { return }
    isStartingActivity = true

    startActivityTask?.cancel()

    startActivityTask = Task {
        defer {
            isStartingActivity = false
            startActivityTask = nil
        }
        guard !Task.isCancelled else { return }

        await endActivity()  // explicit cleanup of any prior state

        guard !Task.isCancelled else { return }

        // ... build attributes + contentState ...

        do {
            let activity = try Activity.request(...)
            guard !Task.isCancelled else { return }
            currentActivity = activity
        } catch {
            // log; flag clears via defer
        }
    }
    #endif
}

このパターンについて、ドキュメントが触れていないことが3つあります:

isStartingActivityフラグが能動的な保護で、startActivityTask?.cancel()は防御的なクリーンアップです。 フラグは、最初の処理が実行中の間に2回目のstartActivity呼び出しが入ってきた時に短絡させるので、公開パス自体ではレースは発生しません。それでもキャンセルしてから差し替える操作が重要なのは、実行中のTaskが非同期であり、短命な呼び出し元よりも長く生き残る可能性があるからです。キャンセルは、呼び出し元が先に進んだ後にも古いTaskが処理を続けることを防ぎます。

guard !Task.isCancelledは各await境界でチェックします。 Swiftにおけるキャンセルは協調的です。cancelが呼ばれても、Task自身が明示的にチェックするまで処理は走り続けます。各awaitはチェックの機会となります。await後のチェックがなければ、キャンセルされたTaskでもActivity状態を組み立て続け、Activity.requestを呼び、成功した時点で静かに孤立Activityを生成してしまうのです。

deferはTaskの本体が完了する前にフラグをクリアします。 deferがないと、(キャンセルチェックからの)早期returnによってisStartingActivity = trueが永久に残り、アプリを再起動するまでActivityが二度と開始されなくなります。フラグはロックです。ロックは、すべての出口で解放されなければなりません。

pushType: nil引数。 ReturnはAPNs経由でプッシュされるLive Activity更新を使いません。アプリはactivity.updateでローカルにActivityを更新します。プッシュ駆動の更新が必要な場合(配送追跡、スポーツのスコア、リアルタイムデータなど)、種類はpushType: .tokenとなり、契約は劇的に複雑化します。5 ローカル更新の方がシンプルで、タイマー/カウンター/単一アプリのワークフローならすべてカバーできます。

一時停止の問題

ActivityKitは美しいText(timerInterval: Date()...endTime, countsDown: true)ビューを提供しており、アプリからの更新なしにライブなカウントダウンを描画してくれます。6 終了時刻を渡せば、システムがライブなタイマーを描画する。Timer.publishもwidgetリフレッシュも、バッテリードレインもありません。

タイマーが動作中なら素晴らしい仕組みです。一時停止中になると、これは間違った動作になります。

timerIntervalテキストは、状態に「pause」シグナルがあろうとなかろうと、endTimeに向かってカウントを進めます。AppleのAPIには「10:23で凍結」モードはありません。endTime = Date().addingTimeInterval(623)を渡してユーザーが10:23の時点で一時停止しても、widget内のタイマーテキストはゼロまでカウントダウンを続けます。状態フィールドはpausedと言っているのに、widgetはrunningを描画しているのです。

修正は、同じ状態から2つの異なるビューを描画することです:7

if context.state.isPaused {
    // static text
    Text(formatTime(context.state.remainingSeconds))
        .monospacedDigit()
} else if context.state.endTime > Date() {
    // live countdown
    Text(timerInterval: Date()...context.state.endTime, countsDown: true)
        .monospacedDigit()
} else {
    // post-end static
    Text("0:00")
        .monospacedDigit()
}

このデュアルトラックの描画こそが、ContentStateremainingSecondsを別フィールドとして持たせる理由です。タイマーが動作中の時は冗長です(システムがendTimeから計算してくれます)。タイマーが一時停止中の時は、唯一の真実の源となります。構造体の2つの半分は、2つの異なる描画モードを担い、isPausedブール値がそれらを切り替えるのです。

解除ポリシー

activity.end(_:dismissalPolicy:)は3つのActivityUIDismissalPolicy値のうちの1つを取ります。間違った選択をすると、私のv1のように、リセット後もユーザーのLock Screenに永遠とも思える時間表示され続けることになります:13

ポリシー 使うべき場面 得られる挙動
.immediate ユーザーリセット、エラー、追跡対象のActivityがない状態でアプリがバックグラウンドに Activityが直ちに消える。猶予なし
.after(date) 完了表示:「瞑想が完了しました」を一瞬読めるようにする必要がある場合。dateはApple許容の4時間以内である必要があります Activityが最終状態を表示し、date時点で解除される
.default 本当にAppleのヒューリスティクスに任せたい場合 システムが「しばらくの間」(Appleの表現)表示を保持。end呼び出し後最大4時間まで

Returnは、自然な完了パスには.after(Date().addingTimeInterval(3))を使います:3

await activity.end(
    .init(state: contentState, staleDate: nil),
    dismissalPolicy: .after(Date().addingTimeInterval(3))
)

3秒という時間は、ユーザーがLock Screenをちらりと見て、タイマーが終わったことを認識し、チェックマークの満足感を味わうのに必要な時間です。3秒未満ではせかせかし、3秒以上ではActivityが終わったことを知らないように感じられます。

ユーザーが起動したリセットの場合、呼び出しはdismissalPolicy: .immediateです。猶予はなし。ユーザーはすでに知っています。

v1で間違っていたのは.defaultでした。完了した瞑想タイマーに対して、システムはActivityを十分長く表示し続けたため、ユーザーはアプリが完了を認識していないと思ってしまったのです。Appleのドキュメントでは、.defaultは終了したActivityを「しばらくの間」最大4時間まで表示すると述べています。13 タイマーに対する正しい姿勢は、解除を明示的に行うことです。

Dynamic Islandのコンパクト領域

Dynamic Islandには3つの描画モードがあり、シンプルなタイマーであっても3つすべてが必要になります:2

  • Compact(デフォルトのDynamic Islandシェイプ):先頭にアイコン+末尾にタイマー
  • Minimal(同じDynamic Islandを別のLive Activityと取り合う場合):先頭のアイコンのみ
  • Expanded(長押し時):4つの名前付き領域(leadingtrailingcenterbottom

Returnでその地位を勝ち取ったパターンは、expandedビューをcompactとほぼ同一にすることでした:8

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image("AppIconSmall")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 16, height: 16)
            .clipShape(RoundedRectangle(cornerRadius: 4))
    }
    DynamicIslandExpandedRegion(.trailing) {
        TimerText(...)
    }
    DynamicIslandExpandedRegion(.center) { EmptyView() }
    DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
    Image("AppIconSmall")...
} compactTrailing: {
    TimerText(...)
} minimal: {
    Image("AppIconSmall")...
}

Live Activityのチュートリアルの多くは、expandedビューを「本物の」デザインとして扱い、bottom領域にリッチなコンテンツを置くことを推奨します。瞑想タイマーにとって、expandedは無駄です。ユーザーは長押しでexpandedビューを開きますが、その長押し自体が「何かが起きた」というハプティックフィードバックをすでに与えています。コンテンツを足すことは、ユーザーが要求していないことをexpansionに言わせることになります。expandedモードの空の領域は、デザインの失敗ではなく、デザインそのものなのです。

RTLバグ

本番のバグです。iOSのアラビア語およびヘブライ語ユーザーから、Dynamic Islandのcompact-trailingタイマーが数字を逆向きに描画していると報告がありました。ラテン数字の5:2332:5として描画されていたのです。これは、compact-trailingレイアウト方向がシステムロケールのRTL設定を継承していたためでした。

SwiftUIはwidgetプロセス内でシステムレイアウト方向を継承するため、ユーザーの携帯がアラビア語またはヘブライ語に設定されていると、Dynamic IslandのタイマーテキストはRTLを引き継ぎました。それ以外がRTLのUIであっても、ラテン数字はLTRで描画されるべきです。修正は、数値のテキストビューにレイアウト方向を固定することでした:7

.environment(\.layoutDirection, .leftToRight)

このオーバーライドは、TimerText内(Dynamic Islandのcompact/expanded)とLock Screenビュー内の数値Textビューにのみ適用し、ビュー全体ではありません。ラテン数字はユーザーのシステムロケールに関係なく左から右に読みます。「Cycle 2 of 3」のようなサイクルラベルはローカライズされたままにしておき、システムレイアウト方向に従います。

このバグは国内ロケールのTestFlightでは表面化しません。実際のRTLユーザーがタイマーを開いた瞬間に表面化します。教訓は、RTLロケールで動作する可能性のあるすべてのLive Activityで、ラテン数字を含むあらゆるテキストビューにLTR固定の環境オーバーライドを必ず入れるということです。

ローカライゼーションの話

TimerActivityAttributesは、Activity作成時にアプリが設定するlanguageCode: Stringフィールドを持ちます:9

let attributes = TimerActivityAttributes(
    timerDuration: duration,
    languageCode: settings.appLanguage  // app's selected language, not system's
)

widget extensionはこれを読んでローカライズされた文字列を描画します:

private var locale: Locale {
    let code = context.attributes.languageCode
    return code.isEmpty ? .current : Locale(identifier: code)
}

private func localized(_ key: String.LocalizationValue) -> String {
    String(localized: key, locale: locale)
}

なぜアプリが自身の言語コードを渡し、widgetにLocale.currentを読ませないのか:widget extensionは独自のプロセスで動作するからです。そのLocale.currentはシステムロケールであり、アプリで選択されたロケールではありません。ユーザーがiPhoneを英語に設定した状態でReturnを韓国語に設定している場合、このオーバーライドがないとwidgetは英語を話します。アプリの言語設定はActivityの属性に乗って渡され、widgetはそれを尊重するのです。

Localizable.xcstringsはアプリのものと一緒にwidgetターゲットにも配置されますが、これらは別ファイルです。widget内で使われる文字列は、同じ文字列がReturn/Localizable.xcstringsにあったとしても、ReturnWidgets/Localizable.xcstringsにも存在しなければなりません。これを忘れると、アプリは韓国語を話していてもwidgetは開発言語にフォールバックしてしまいます。

もし作り直すなら

ContentStateをもっと小さくします。 6フィールドは多すぎます。endTimeremainingSecondsの冗長性は、timerIntervalにpauseモードがないことを回避するための代償です。最初からやり直すなら、単一のdisplayMode列挙型(runningpaused(remainingSeconds: Int)cycleEndcomplete)を持たせて、レンダリングコードがcaseで分岐するようにします。6フィールドを5つの遷移メソッド全体で正しく変更し続けるのは、4つのcaseよりずっと難しいのです。

インタラクティブなLive Activityボタン(iOS 17+)を追加します。 ReturnはまだDynamic Island内にpause/resumeコントロールを公開していません。一時停止するにはユーザーがアプリを開かなければなりません。iOS 17では、Live Activity内のApp Intent向けにButton(intent:)が追加されました。10 インタラクティブな一時停止コントロールは明らかな拡張で、Returnの次のリリースで実装する予定です。

プッシュ更新型のLive Activitiesによるデバイス間タイマー同期。 ReturnはiPhone、iPad、Watch、Apple TVの間でNSUbiquitousKeyValueStoreによりセッションを同期しています(Five Apple Platforms, Three Shared Filesで詳述)。現在、Activityの開始も更新もiPhoneまたはiPadのアプリからローカルに行われています。理想的には、Apple Watchでタイマーを開始したユーザーがiPhoneのLive Activityにそれがリアルタイムで反映されるのを見られるようにすべきです。Live ActivityへのAPNsプッシュがその経路となります。5 まだ作っていません。

Live Activityを使うべきでない場面

ワンショットの一時的な状態。 「保存しました!」のトーストにLive Activityは不要です。システムにはバナーがあります。それを使ってください。

タイマー次元のない頻繁に変化するデータ。 Live Activitiesは明確な時間軸(タイマー、配送ETA、ゲームクロック、通話時間)を持つものに最適です。株価ティッカーやスポーツのスコアが機能するのは、セッションウィンドウがあるからです。汎用ダッシュボードはそうはなりません。

Lock Screen/スタンバイ用途のないアプリ。 Live Activitiesはエンジニアリング投資が大きい機能です(ターゲット設定、ContentState設計、解除ポリシーの判断、RTL対応、ローカライゼーション配線)。使用中にLock Screenを参照することなくユーザーが直接開くアプリは、適切な形ではありません。写真エディタには不要です。ワークアウトトラッカーには必要です。

iOS以外のサーフェスでは留保あり。 ReturnのLiveActivityManagerは、タイマーがiPhoneまたはiPadアプリから開始されるため、#if os(iOS)の背後に実装を配置しています。ActivityKit自体は、Lock Screenバナー、Dynamic Island、Apple Watchのスマートスタック、Mac、CarPlayをプレゼンテーションサーフェスとして説明しており、iOS 26ではいくつかが拡張されました。4 watchOSはフルスクリーン描画のために独自のcomplications APIを持っています。macOSにはメニューバーアプリがあります。iPadOSはiPadOS 17からLive Activitiesをサポートしていますが、Dynamic Island領域はありません。Returnのマネージャーには、224行のファイルにわたって8つの#if os(iOS)ガードがあります。

このパターンがiOS 26+でリリースするアプリにとって意味すること

ポイントは2つです。

  1. Live Activityを数値ではなくステートマシンとして扱うこと。 ステートマシンは明確な状態、明確な遷移、明確な解除ルールを持ちます。画面上の数値は、ある状態の1つの描画に過ぎません。まずは状態を正しく作りましょう。

  2. 再入ガードは、まだ遭遇していないバグです。 実環境で見たLive Activityマネージャーで、isStartingActivity + キャンセル可能なTaskを実装していないものは、すべて少なくとも1つの孤立Activityバグをリリース済みです。ガードは6行です。一度書きましょう。

この記事は、同じファミリーのアプリに関する私の以前の書き物と組み合わせてお読みください:Apple Intelligence向けに型付けされたApp Intents、デバイス間エージェント向けのMCPサーバー、ビジュアル層向けのLiquid Glassパターン、デバイス間到達向けのマルチプラットフォーム配信。Live Activitiesは同じスタックのiOS-Lock-Screen-and-Dynamic-Island層です。一式はApple Ecosystem Series hubにまとまっています。AIエージェントとiOSのより広い文脈については、iOS Agent Developmentガイドをご覧ください。

FAQ

Live ActivitiesとWidgetKitのwidgetの違いは何ですか?

WidgetKitのwidgetは、TimelineProviderによって定義された間隔で描画されます。システムがいつリフレッシュするかを決め、widgetは静的なタイムラインから再描画されます。11 Live Activitiesは特定のアプリ駆動のactivity.update(...)呼び出しに応答して描画され、基となるActivity(タイマー、配送、ワークアウトなど)の継続時間中だけ生きます。両方ともwidget extensionターゲットでリリースされますが、違いはトリガーモデルにあります。

Live ActivitiesはiPadで動作しますか?

はい、iPadOS 17+で動作します。Lock Screenバナーが主要な描画サーフェスです。iPadにはDynamic Islandがありません。同じActivityConfigurationコードが動作します。ただし、iPadではDynamic Island領域は決して描画されないと考えてください。

Live Activityはアプリのプロセスより長く生き残れますか?

はい。一度Activity.requestが成功すれば、ActivityKitがそのActivityを所有します。アプリのプロセスはシステムによって終了させられても、Activityは明示的に終了するまで(あるいはシステムの陳腐化ルールが解除するまで)、Lock ScreenとDynamic Islandで描画され続けます。明示的なendActivity()呼び出しが重要なのはそのためです。アプリリセット時に明示的な終了がないと、Activityはタイマーより長く生き残ってしまいます。

なぜこの記事はプッシュ更新型のLive Activitiesを扱わないのですか?

ReturnでまだプッシュアップデートされたLive Activitiesをリリースしていないからです。このクラスタのジャンルルールに則り、shipped-codeの記事は本番コードが何をしているかだけを記録します。プッシュ更新は「もし作り直すなら」セクションで触れています。それをリリースした後、別の記事で扱う予定です。

SwiftUIアプリにおけるLive Activitiesの実際のファイル構成は?

3つのピースがあります:3712

  • メインアプリのターゲット内: LiveActivityManager.swift(Activityのライフサイクル管理)、TimerActivityAttributes.swift(widgetと共有されるActivityAttributes構造体。両方のターゲットがこのファイルをコンパイルします)。
  • widget extensionのターゲット内: ReturnLiveActivity.swiftActivityConfigurationを本体とするWidgetへの準拠)、ReturnWidgetsBundle.swift@main WidgetBundle)。
  • 設定: アプリターゲットのInfo.plistNSSupportsLiveActivities = YES

widget extensionターゲットには、ActivityKitとWidgetKitのインポートが必要です。TimerActivityAttributesが両方のターゲットで共有される唯一のファイルで、それ以外はターゲットごとに分離されています。


Live ActivityはLock Screen上の数値ではありません。それは遷移のたびにプロセス境界をまたぐステートマシンです。状態を正しく作り、再入を守り、解除ポリシーを意図して選び、レイアウト方向を固定すること。数値はそれで自ずと正しくなります。

References


  1. 著者のReturn。SwiftUI製の瞑想タイマーで、2026年4月21日にApp Storeで公開。iPhone、iPad、Mac、Apple Watch、Apple TVで利用可能。Live ActivitiesはiOSターゲットでのみリリース。 

  2. Apple Developer, “ActivityKit framework”. Lock Screenバナー、Dynamic Islandのcompact/minimal/expandedモード、Activityのライフサイクル。iOS 16.1+で利用可能。Dynamic IslandはiPhone 14 Pro以降で利用可能。 

  3. 本番コード Return/Return/LiveActivityManager.swift(224行、#if os(iOS)ブロック8個)と Return/Return/TimerActivityAttributes.swift(43行)。アプリターゲットとwidget extensionターゲット間でターゲットメンバーシップを通じて共有。 

  4. Apple Developer, “Displaying live data with Live Activities”. 並行性制限、サポートされるプラットフォーム(iOS 16.1+、iPadOS 17+)、NSSupportsLiveActivities Info.plistキー。 

  5. Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. pushType: .tokenの経路には、別途のAPNs認証キー、サーバーサイドのプッシュトークン登録、ローカルのactivity.update(...)呼び出しとは異なる更新プロトコルが必要。 

  6. Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. システム描画のライブカウントダウンタイマー。Activityが動作中はアプリの更新なしに描画される。 

  7. 本番コード Return/ReturnWidgets/ReturnLiveActivity.swift(232行)。widget extensionのWidgetへの準拠で、本体はActivityConfiguration<TimerActivityAttributes>。61〜102行のTimerTextビューが、paused/running/post-endの3状態描画を処理。 

  8. Apple Developer, “DynamicIsland”. 名前付きのexpanded領域4つ(leadingtrailingcenterbottom)と、3つのcompactモードビュー(compactLeadingcompactTrailingminimal)。 

  9. widget extensionは独自のプロセスで動作し、アプリで選択されたロケールではなくシステムロケールを継承する。アプリ内言語切り替えをサポートするアプリ(Returnは27言語をサポート)は、widgetがユーザーが選んだ言語で描画できるよう、ActivityAttributesを通じて言語コードを渡す必要がある。パターン:Locale.currentではなくLocale(identifier: context.attributes.languageCode)。 

  10. Apple Developer, “Button(intent:)”. iOS 17+のwidgetおよびLive Activityビューで利用可能。アプリのフォアグラウンド化を必要とせず、App IntentをLock Screen/Dynamic Islandコントロールに橋渡しする。 

  11. Apple Developer, “TimelineProvider”. Live Activitiesに先立つwidgetリフレッシュモデル。事前計算されたエントリと、システム管理の再読み込みウィンドウ。 

  12. 本番コード Return/ReturnWidgets/ReturnWidgetsBundle.swift(16行)。ReturnLiveActivityをwidget extensionの唯一のwidgetとして登録する@main WidgetBundle。widget extensionで必須のパターン。バンドルがシステムが読み込む対象。 

  13. Apple Developer, “ActivityUIDismissalPolicy”. 3つのケース:.default.immediate.after(_:)。Appleは.defaultが終了したLive Activityを「しばらくの間」最大4時間まで表示し続けると述べており、.after(_:)は同じ4時間ウィンドウ内のdateを受け取る。 

関連記事

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 分で読める

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 分で読める

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分で読める